Files
2026-10-13 17:51:30 +02:00

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