"""Program manifests: Programs//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//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