mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Program manifests: Programs/<Name>/Manifest.toml, plus optional Templates/
|
|
and Hooks.py next to it. See README.md for the full manifest format.
|
|
|
|
A manifest's Platforms (default ["Linux"]) says where the program runs; each
|
|
SaraSuite only lists the programs for its own system. Any list item can also
|
|
carry Platform = "Linux" or "Windows" to only apply there (e.g. desktop
|
|
entries on Linux, Start Menu shortcuts on Windows); others are dropped at load."""
|
|
|
|
import importlib.util
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Optional
|
|
|
|
import tomlkit
|
|
|
|
from Core import (
|
|
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
|
|
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir,
|
|
)
|
|
|
|
# Never worth copying out of a source checkout, whatever Include says.
|
|
DefaultExclude = [
|
|
".git", ".venv", "__pycache__", "*.pyc", "build", "dist", ".flet", ".flatpak-builder",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class Option:
|
|
"""A checkbox in the install dialog. Templates, IniEdits and Services
|
|
that name it are only applied while it's ticked."""
|
|
|
|
Id: str
|
|
Label: str
|
|
Default: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Question:
|
|
"""A value asked for at install time, usable as {Id} in templates and
|
|
config Set values. Kind "Path" expands ~ and makes it absolute."""
|
|
|
|
Id: str
|
|
Prompt: str
|
|
Default: str = ""
|
|
Kind: str = "Text"
|
|
|
|
|
|
@dataclass
|
|
class TemplateFile:
|
|
"""A file SaraSuite writes and fully owns (desktop entries, units, launchers).
|
|
Rewritten on every update; the user isn't expected to edit these."""
|
|
|
|
Source: str # relative to the program's Templates/ folder
|
|
Target: str
|
|
Option: str = ""
|
|
Root: bool = False # lives outside $HOME, written through pkexec
|
|
Executable: bool = False
|
|
|
|
|
|
@dataclass
|
|
class ConfigFile:
|
|
"""A config the user is expected to edit. Placed once, then merged smartly
|
|
on updates (see ConfigMerge.py) instead of being overwritten."""
|
|
|
|
Source: str # relative to the program's source checkout
|
|
Target: str
|
|
Set: dict[str, str] = field(default_factory=dict) # dotted TOML key -> value
|
|
|
|
|
|
@dataclass
|
|
class IniEdit:
|
|
"""One key in a file SaraSuite doesn't own (mimeapps.list, portals.conf).
|
|
The previous value is remembered and put back on removal."""
|
|
|
|
File: str
|
|
Section: str
|
|
Key: str
|
|
Value: str
|
|
Option: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Service:
|
|
"""A background service. Linux with systemd: a --user Unit (placed by a
|
|
Template). Windows, and Linux without systemd (runit, OpenRC, ...):
|
|
Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
|
|
at login through a Startup-folder shortcut / an XDG autostart entry
|
|
(Services.py), so a Linux service gives both Unit and Name/Script."""
|
|
|
|
Unit: str = ""
|
|
Option: str = "" # enabled + started only while this option is ticked
|
|
Name: str = "" # run by SaraSuite: the service's name (PID file, log, autostart)
|
|
Script: str = ""
|
|
WorkingDir: str = ""
|
|
|
|
@property
|
|
def RunBySaraSuite(self) -> bool:
|
|
return IsWindows or not HasSystemd
|
|
|
|
@property
|
|
def Id(self) -> str:
|
|
"""What the receipt records: the unit under systemd, else the name."""
|
|
return (self.Name or self.Unit) if self.RunBySaraSuite else (self.Unit or self.Name)
|
|
|
|
|
|
@dataclass
|
|
class Shortcut:
|
|
"""Windows: a Start Menu shortcut running Script (relative to the app
|
|
folder) with the program's pythonw.exe, from the app folder, with Icon
|
|
(a PNG in the app folder, turned into an .ico)."""
|
|
|
|
Name: str
|
|
Script: str
|
|
Arguments: str = ""
|
|
Icon: str = ""
|
|
Description: str = ""
|
|
Option: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Leftover:
|
|
"""Something the program makes by itself while running (caches, logs,
|
|
its own config dir). Only touched on removal.
|
|
Kind: "Runtime" or "Config" (only removed when "Remove configs" is ticked).
|
|
Mode: "Remove" (delete it, directories included) or "IfEmpty" (only
|
|
remove a directory that's empty, e.g. one that might hold the user's files).
|
|
Path may contain * globs."""
|
|
|
|
Path: str
|
|
Kind: str = "Runtime"
|
|
Mode: str = "Remove"
|
|
Note: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Program:
|
|
Name: str
|
|
Description: str
|
|
Folder: Path # the manifest's own folder
|
|
Parent: str = "" # a program this one belongs to (e.g. SaraSort -> SaraSortd): listed indented under it
|
|
Git: str = "" # e.g. "{GitHost}/SaraSortd"
|
|
Branch: str = "" # empty = the repo's default branch
|
|
Include: list[str] = field(default_factory=lambda: ["**"])
|
|
Exclude: list[str] = field(default_factory=list)
|
|
VersionFile: str = ""
|
|
VersionPattern: str = ""
|
|
Requirements: list[str] = field(default_factory=list)
|
|
RequirementsFile: str = ""
|
|
Options: list[Option] = field(default_factory=list)
|
|
Questions: list[Question] = field(default_factory=list)
|
|
Templates: list[TemplateFile] = field(default_factory=list)
|
|
Configs: list[ConfigFile] = field(default_factory=list)
|
|
IniEdits: list[IniEdit] = field(default_factory=list)
|
|
Services: list[Service] = field(default_factory=list)
|
|
RestartUnits: list[str] = field(default_factory=list)
|
|
Leftovers: list[Leftover] = field(default_factory=list)
|
|
Shortcuts: list[Shortcut] = field(default_factory=list)
|
|
Platforms: list[str] = field(default_factory=lambda: ["Linux"])
|
|
Hooks: Optional[ModuleType] = None
|
|
|
|
@property
|
|
def ProgramDir(self) -> Path:
|
|
return ProgramsDir / self.Name
|
|
|
|
@property
|
|
def AppDir(self) -> Path:
|
|
return self.ProgramDir / "App"
|
|
|
|
@property
|
|
def IconPath(self) -> Optional[Path]:
|
|
"""The installed copy's own icon if it's there (always current; the
|
|
Icon= line of the program's desktop entry template, or its Windows
|
|
Shortcut's Icon, says where), otherwise the copy bundled next to the
|
|
manifest (Programs/<Name>/Icon.png), so the card has an icon before
|
|
installing too."""
|
|
for Link in self.Shortcuts:
|
|
if Link.Icon and (self.AppDir / Link.Icon).is_file():
|
|
return self.AppDir / Link.Icon
|
|
for Template in self.Templates:
|
|
if Template.Target.endswith(".desktop"):
|
|
for Line in (self.Folder / "Templates" / Template.Source).read_text().splitlines():
|
|
if Line.startswith("Icon="):
|
|
Installed = Path(Render(Line.removeprefix("Icon="), self.Context()))
|
|
if Installed.is_file():
|
|
return Installed
|
|
Bundled = self.Folder / "Icon.png"
|
|
return Bundled if Bundled.is_file() else None
|
|
|
|
@property
|
|
def ConfigDir(self) -> Path:
|
|
return SharedConfigDir / self.Name
|
|
|
|
@property
|
|
def GitUrl(self) -> str:
|
|
return Render(self.Git, self.Context())
|
|
|
|
@property
|
|
def Python(self) -> Path:
|
|
Venv = self.ProgramDir / ".venv"
|
|
return Venv / "Scripts" / "python.exe" if IsWindows else Venv / "bin" / "python"
|
|
|
|
@property
|
|
def PythonW(self) -> Path:
|
|
"""Windows: the venv's console-less python, for GUI programs and services."""
|
|
return self.Python.with_name("pythonw.exe") if IsWindows else self.Python
|
|
|
|
@property
|
|
def UsesPython(self) -> bool:
|
|
return bool(self.Requirements or self.RequirementsFile)
|
|
|
|
def OptionEnabled(self, OptionId: str, Options: dict[str, bool]) -> bool:
|
|
return not OptionId or Options.get(OptionId, False)
|
|
|
|
def Context(self, Answers: Optional[dict[str, str]] = None) -> dict[str, str]:
|
|
"""The {Variable} values templates, targets and Set values can use."""
|
|
Desktop = CurrentDesktop()
|
|
Context = {
|
|
"Name": self.Name,
|
|
"GitHost": LoadConfig().GitHost.rstrip("/"),
|
|
"Home": str(Home),
|
|
"ConfigHome": str(ConfigHome),
|
|
"DataHome": str(DataHome),
|
|
"CacheHome": str(CacheHome),
|
|
"ProgramDir": str(self.ProgramDir),
|
|
"AppDir": str(self.AppDir),
|
|
"Python": str(self.Python),
|
|
"PythonW": str(self.PythonW),
|
|
"ConfigDir": str(self.ConfigDir),
|
|
"DesktopName": Desktop,
|
|
"Desktop": Desktop.lower(),
|
|
}
|
|
if IsWindows:
|
|
Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir))
|
|
else:
|
|
Context.update(
|
|
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
|
|
AutostartDir=str(AutostartDir),
|
|
ApplicationsDir=str(DataHome / "applications"),
|
|
DBusServicesDir=str(DataHome / "dbus-1" / "services"),
|
|
)
|
|
Context.update(Answers or {})
|
|
return Context
|
|
|
|
|
|
def Render(Text: str, Context: dict[str, str]) -> str:
|
|
"""Replace {Variable}s. Unknown names are left alone, so shell's ${VAR}
|
|
or a literal {thing} survive untouched."""
|
|
return re.sub(r"\{(\w+)\}", lambda Match: Context.get(Match[1], Match[0]), Text)
|
|
|
|
|
|
def _LoadHooks(Folder: Path, Name: str) -> Optional[ModuleType]:
|
|
HooksPath = Folder / "Hooks.py"
|
|
if not HooksPath.exists():
|
|
return None
|
|
Spec = importlib.util.spec_from_file_location(f"SaraSuiteHooks{Name}", HooksPath)
|
|
Module = importlib.util.module_from_spec(Spec)
|
|
Spec.loader.exec_module(Module)
|
|
return Module
|
|
|
|
|
|
def _ForPlatform(Items: list[dict]) -> list[dict]:
|
|
"""Drop the items meant for the other system (their Platform key)."""
|
|
Kept = []
|
|
for Item in Items:
|
|
Item = dict(Item)
|
|
if Item.pop("Platform", Platform) == Platform:
|
|
Kept.append(Item)
|
|
return Kept
|
|
|
|
|
|
def LoadProgram(Folder: Path) -> Program:
|
|
Data = tomlkit.parse((Folder / "Manifest.toml").read_text(encoding="utf-8")).unwrap()
|
|
Source = Data.get("Source", {})
|
|
Version = Data.get("Version", {})
|
|
Python = Data.get("Python", {})
|
|
return Program(
|
|
Name=Data["Name"],
|
|
Description=Data.get("Description", ""),
|
|
Folder=Folder,
|
|
Parent=Data.get("Parent", ""),
|
|
Git=Source["Git"],
|
|
Branch=Source.get("Branch", ""),
|
|
Include=Source.get("Include", ["**"]),
|
|
Exclude=DefaultExclude + Source.get("Exclude", []),
|
|
VersionFile=Version.get("File", ""),
|
|
VersionPattern=Version.get("Pattern", ""),
|
|
Requirements=Python.get("Requirements", []),
|
|
RequirementsFile=Python.get("RequirementsFile", ""),
|
|
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
|
|
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
|
|
# Without systemd, its unit files would just sit there unused.
|
|
Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))
|
|
if HasSystemd or "{SystemdUserDir}" not in Item.get("Target", "")],
|
|
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
|
|
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
|
|
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
|
|
RestartUnits=[] if IsWindows else Data.get("RestartUnits", []),
|
|
Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))],
|
|
Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))],
|
|
Platforms=Data.get("Platforms", ["Linux"]),
|
|
Hooks=_LoadHooks(Folder, Data["Name"]),
|
|
)
|
|
|
|
|
|
def _LoadFrom(Folder: Path) -> list[Program]:
|
|
return [LoadProgram(Child) for Child in sorted(Folder.iterdir()) if (Child / "Manifest.toml").exists()]
|
|
|
|
|
|
def LoadPrograms() -> list[Program]:
|
|
"""Alphabetical, with each program's children (Parent = its Name) right
|
|
after it. A child whose parent isn't there keeps its own place.
|
|
Read from the catalog fetched from the SaraSuite repo (Catalog.py) when
|
|
there is one, falling back to the bundled copy if that can't be loaded
|
|
(e.g. it uses a manifest feature this SaraSuite doesn't know yet)."""
|
|
from Catalog import BundledManifestsDir, ManifestsDir
|
|
|
|
try:
|
|
Loaded = _LoadFrom(ManifestsDir())
|
|
except Exception:
|
|
Loaded = _LoadFrom(BundledManifestsDir)
|
|
# Only the programs that run on this system (the .exe lists Windows ones).
|
|
Loaded = [Current for Current in Loaded if Platform in Current.Platforms]
|
|
Names = {Current.Name for Current in Loaded}
|
|
Ordered = []
|
|
for Current in Loaded:
|
|
if Current.Parent in Names:
|
|
continue
|
|
Ordered.append(Current)
|
|
Ordered.extend(Child for Child in Loaded if Child.Parent == Current.Name)
|
|
return Ordered
|