mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
221 lines
7.3 KiB
Python
221 lines
7.3 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."""
|
|
|
|
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, ManifestsDir, 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
|
|
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 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,
|
|
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 LoadPrograms() -> list[Program]:
|
|
return [
|
|
LoadProgram(Folder)
|
|
for Folder in sorted(ManifestsDir.iterdir())
|
|
if (Folder / "Manifest.toml").exists()
|
|
]
|