"""Program manifests: Programs//Manifest.toml, plus optional Templates/ and Hooks.py next to it. See README.md for the full manifest format.""" 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 ( CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir, SharedConfigDir, ) # 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: Unit: str # a systemd --user unit, placed by a Template Option: str = "" # enabled + started only while this option is ticked @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) 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 says where), otherwise the copy bundled next to the manifest (Programs//Icon.png), so the card has an icon before installing too.""" 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 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), "SystemdUserDir": str(ConfigHome / "systemd" / "user"), "ApplicationsDir": str(DataHome / "applications"), "DBusServicesDir": str(DataHome / "dbus-1" / "services"), "ProgramDir": str(self.ProgramDir), "AppDir": str(self.AppDir), "Python": str(self.ProgramDir / ".venv" / "bin" / "python"), "ConfigDir": str(self.ConfigDir), "DesktopName": Desktop, "Desktop": Desktop.lower(), } 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 LoadProgram(Folder: Path) -> Program: Data = tomlkit.parse((Folder / "Manifest.toml").read_text()).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 Data.get("Options", [])], Questions=[Question(**Item) for Item in Data.get("Questions", [])], Templates=[TemplateFile(**Item) for Item in Data.get("Templates", [])], Configs=[ConfigFile(**Item) for Item in Data.get("Configs", [])], IniEdits=[IniEdit(**Item) for Item in Data.get("IniEdits", [])], Services=[Service(**Item) for Item in Data.get("Services", [])], RestartUnits=Data.get("RestartUnits", []), Leftovers=[Leftover(**Item) for Item in Data.get("Leftovers", [])], 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) 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