commit bea0f43947fc7e386b0688866893208b00bca09d Author: SRHTNIN Date: Thu Sep 24 22:04:04 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4cf677 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +build/ +dist/ +.flet/ diff --git a/Cli.py b/Cli.py new file mode 100644 index 0000000..2d3c2c3 --- /dev/null +++ b/Cli.py @@ -0,0 +1,70 @@ +"""Command-line mode, for machines without a desktop (e.g. a Fedora Server box): + + SaraSuite list + SaraSuite install [--set Id=Value ...] [--option Id=on|off ...] + SaraSuite update + SaraSuite remove [--configs] [--keep-runtime] + +Install uses each question's default and each option's default unless set. +""" + +import argparse + +from Installer import Apply, GetStatus, Remove +from Manifests import LoadPrograms + + +def _Pairs(Items: list[str]) -> dict[str, str]: + Pairs = {} + for Item in Items: + Key, Separator, Value = Item.partition("=") + if not Separator: + raise SystemExit(f"Expected Id=Value, got {Item!r}") + Pairs[Key] = Value + return Pairs + + +def Run(Arguments: list[str]) -> int: + Parser = argparse.ArgumentParser(prog="SaraSuite") + Commands = Parser.add_subparsers(dest="Command", required=True) + Commands.add_parser("list") + Install = Commands.add_parser("install") + Install.add_argument("Name") + Install.add_argument("--set", action="append", default=[], dest="Answers") + Install.add_argument("--option", action="append", default=[], dest="Options") + Commands.add_parser("update").add_argument("Name") + RemoveCommand = Commands.add_parser("remove") + RemoveCommand.add_argument("Name") + RemoveCommand.add_argument("--configs", action="store_true") + RemoveCommand.add_argument("--keep-runtime", action="store_true") + Args = Parser.parse_args(Arguments) + + Programs = {Program.Name.lower(): Program for Program in LoadPrograms()} + if Args.Command == "list": + for Program in Programs.values(): + Current = GetStatus(Program) + State = "update available" if Current.UpdateAvailable else "installed" if Current.Installed else "not installed" + print(f"{Program.Name:12} {State:16} {Current.Error}") + return 0 + + Program = Programs.get(Args.Name.lower()) + if Program is None: + print(f"Unknown program {Args.Name!r}. Known: {', '.join(P.Name for P in Programs.values())}") + return 1 + Installed = GetStatus(Program).Installed + + if Args.Command == "install": + if Installed: + print(f"{Program.Name} is already installed; use update.") + return 1 + Options = {Option.Id: Option.Default for Option in Program.Options} + Options.update({Key: Value.lower() in ("on", "yes", "true", "1") for Key, Value in _Pairs(Args.Options).items()}) + Apply(Program, print, Options, _Pairs(Args.Answers)) + elif Args.Command == "update": + if not Installed: + print(f"{Program.Name} isn't installed.") + return 1 + Apply(Program, print) + elif Args.Command == "remove": + Remove(Program, Args.configs, not Args.keep_runtime, print) + return 0 diff --git a/ConfigMerge.py b/ConfigMerge.py new file mode 100644 index 0000000..e151fcf --- /dev/null +++ b/ConfigMerge.py @@ -0,0 +1,126 @@ +"""Smart config updating: a three-way merge between + Base - the default config as it was last installed (kept in Pristine/), + Ours - the user's config as it is on disk now, + Theirs - the new default config from the updated program. + +Per key: the user's edits always win; defaults the user never touched follow +the new version; new keys are added; keys dropped from the new default are +removed only if the user never changed them. TOML is merged key by key with +tomlkit, so the user's comments and layout survive. Anything else that both +sides changed is left alone and the new default is written beside it as .new. +""" + +import copy +from dataclasses import dataclass, field +from typing import Any, Optional + +import tomlkit +from tomlkit.container import Container + +_Missing = object() + + +@dataclass +class MergeResult: + # "Place": no config yet, the default is written. + # "Keep": nothing new in the default, the user's file is untouched. + # "Replace": the user never edited it, so it becomes the new default. + # "Merge": both changed, merged key by key (Changes lists what moved). + # "KeepAndNew": both changed and can't be merged; the new default goes to .new. + Action: str + Text: Optional[str] = None # what to write to the target (None = leave it) + Changes: list[str] = field(default_factory=list) + + def Describe(self) -> str: + Summary = { + "Place": "new, will be placed", + "Keep": "no changes in the new version, yours is kept as-is", + "Replace": "you haven't edited it, updated to the new default", + "Merge": "merged with your edits", + "KeepAndNew": "you and the new version both changed it; yours is kept, new default saved as .new", + }[self.Action] + if self.Action == "Merge" and not self.Changes: + return "your edits already cover everything new, kept as-is" + return Summary + + +def _Plain(Value: Any) -> Any: + return Value.unwrap() if hasattr(Value, "unwrap") else Value + + +def _MergeTables(Base: dict, Ours: Container, Theirs: Container, Prefix: str, Changes: list[str]) -> None: + for Key in list(Theirs.keys()): + KeyPath = f"{Prefix}{Key}" + TheirsItem = Theirs[Key] + TheirsValue = _Plain(TheirsItem) + BaseValue = Base.get(Key, _Missing) + + if Key not in Ours: + if BaseValue is not _Missing: + continue # was in the old default and the user deleted it: respect that + Ours[Key] = copy.deepcopy(TheirsItem) + Changes.append(f"added {KeyPath}") + continue + + OursItem = Ours[Key] + OursValue = _Plain(OursItem) + if isinstance(TheirsValue, dict) and isinstance(OursValue, dict): + _MergeTables(BaseValue if isinstance(BaseValue, dict) else {}, OursItem, TheirsItem, f"{KeyPath}.", Changes) + continue + if OursValue == TheirsValue: + continue + if BaseValue is not _Missing and OursValue == BaseValue: + Ours[Key] = copy.deepcopy(TheirsItem) + Changes.append(f"updated {KeyPath}") + # Otherwise the user changed it (or there's no base to tell): keep the user's value. + + for Key in list(Ours.keys()): + if Key in Theirs or Key not in Base: + continue + if _Plain(Ours[Key]) == Base[Key]: + del Ours[Key] + Changes.append(f"removed {Prefix}{Key}") + + +def MergeConfig(Base: Optional[str], Ours: Optional[str], Theirs: str, IsToml: bool) -> MergeResult: + """Base is None when there's no record of the old default (first install + over an existing config, or the Pristine copy went missing): every key + already in the user's file is then treated as the user's choice.""" + if Ours is None: + return MergeResult("Place", Theirs) + if Ours == Theirs or Base == Theirs: + return MergeResult("Keep") + if Ours == Base: + return MergeResult("Replace", Theirs) + if not IsToml: + return MergeResult("KeepAndNew") + + try: + OursDoc = tomlkit.parse(Ours) + TheirsDoc = tomlkit.parse(Theirs) + BaseData = tomlkit.parse(Base).unwrap() if Base is not None else {} + except tomlkit.exceptions.TOMLKitError: + return MergeResult("KeepAndNew") + + Changes: list[str] = [] + _MergeTables(BaseData, OursDoc, TheirsDoc, "", Changes) + if not Changes: + return MergeResult("Merge") + return MergeResult("Merge", tomlkit.dumps(OursDoc), Changes) + + +def ApplySet(Text: str, Values: dict[str, str]) -> str: + """Set dotted TOML keys (e.g. "DirectoryPaths.RootDir") in a config's text, + used to fill install-time answers into a default config.""" + if not Values: + return Text + Doc = tomlkit.parse(Text) + for DottedKey, Value in Values.items(): + *Tables, LastKey = DottedKey.split(".") + Table = Doc + for TableName in Tables: + if TableName not in Table: + Table[TableName] = tomlkit.table() + Table = Table[TableName] + Table[LastKey] = Value + return tomlkit.dumps(Doc) diff --git a/Core.py b/Core.py new file mode 100644 index 0000000..13a1b2c --- /dev/null +++ b/Core.py @@ -0,0 +1,182 @@ +"""Shared app-wide constants: paths, palette, theme. + +The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's), +and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme +picked for one Sara program looks the same in all of them. +""" + +import os +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +import flet as ft +import tomlkit + +AppName = "SaraSuite" +FontFamily = "Fixedsys Excelsior" + +Home = Path.home() +ConfigHome = Home / ".config" +DataHome = Home / ".local" / "share" +CacheHome = Home / ".cache" + +SharedConfigDir = ConfigHome / "SaraSuite" +ThemesDir = SharedConfigDir / "Themes" +ConfigDir = SharedConfigDir / "SaraSuite" +ConfigPath = ConfigDir / "Config.toml" + +# Everything SaraSuite itself keeps about installed programs. Nothing in here +# is user-edited, so it's all fair game to delete when a program is removed. +DataDir = DataHome / "SaraSuite" +ProgramsDir = DataDir / "Programs" # installed program files + venvs +ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact removal +PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base) +BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies +DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives + +# Bundled read-only files (program manifests, fonts). Inside a PyInstaller +# build they're unpacked to sys._MEIPASS rather than next to the code. +BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) +ManifestsDir = BundleDir / "Programs" +AssetsDir = BundleDir / "assets" + + +class Palette: + """Live values for the active theme (see ApplyTheme). Same attribute names + and SaraView role mapping as SaraFem's Palette.""" + + Mauve = "#cba6f7" + Red = "#f38ba8" + Peach = "#fab387" + Yellow = "#f9e2af" + Green = "#a6e3a1" + Blue = "#89b4fa" + Text = "#cdd6f4" + White = "#cdd6f4" + Overlay0 = "#6c7086" + Surface1 = "#45475a" + Surface0 = "#313244" + Base = "#1e1e2e" + Mantle = "#181825" + Crust = "#11111b" + + +_ThemeRoleMap = { + "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", + "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", + "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", + "Blue": "Blue", "Purple": "Mauve", +} + + +def ApplyTheme(ThemeName: str) -> None: + """Mutate Palette in place from ~/.config/SaraSuite/Themes/.toml. + Falls back to the built-in Catppuccin Mocha values if the file is missing + (SaraFem seeds that directory; SaraSuite only reads it). Must run before + any control reads Palette.X.""" + ThemeFile = ThemesDir / f"{ThemeName}.toml" + if not ThemeFile.exists(): + return + try: + RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) + except tomlkit.exceptions.TOMLKitError: + return + for RoleName, AttrName in _ThemeRoleMap.items(): + if RoleName in RoleValues: + setattr(Palette, AttrName, RoleValues[RoleName]) + + +@dataclass +class AppConfig: + Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/ + # Where the Sara repos live (Gitea on SpineS, Tailscale-only); manifests + # use it as {GitHost}. + GitHost: str = "http://git.srhtnin.garden/sarah" + + def Save(self) -> None: + ConfigDir.mkdir(parents=True, exist_ok=True) + ConfigPath.write_text(tomlkit.dumps(asdict(self))) + + +def LoadConfig() -> AppConfig: + if not ConfigPath.exists(): + Config = AppConfig() + Config.Save() + return Config + try: + Data = tomlkit.parse(ConfigPath.read_text()).unwrap() + except tomlkit.exceptions.TOMLKitError: + return AppConfig() + KnownFields = set(AppConfig.__dataclass_fields__) + return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields}) + + +def ChildEnvironment() -> dict[str, str]: + """The environment for child processes (systemctl, pip, python3 -m venv). + A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries + and keeps the original in LD_LIBRARY_PATH_ORIG; system tools need the + original back or they can load the wrong libraries.""" + Environment = dict(os.environ) + if getattr(sys, "frozen", False): + Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None) + if Original is not None: + Environment["LD_LIBRARY_PATH"] = Original + else: + Environment.pop("LD_LIBRARY_PATH", None) + for Key in ("PYTHONHOME", "PYTHONPATH"): + Environment.pop(Key, None) + return Environment + + +def CurrentDesktop() -> str: + """First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for + portal descriptors' UseIn= and the -portals.conf file name.""" + return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0] + + +def BuildTheme() -> ft.Theme: + ColorScheme = ft.ColorScheme( + primary=Palette.Mauve, + on_primary=Palette.Crust, + primary_container=Palette.Surface0, + on_primary_container=Palette.Mauve, + secondary=Palette.Mauve, + on_secondary=Palette.Crust, + secondary_container=Palette.Surface0, + on_secondary_container=Palette.Mauve, + surface=Palette.Base, + on_surface=Palette.Text, + on_surface_variant=Palette.Overlay0, + surface_container=Palette.Mantle, + surface_container_low=Palette.Mantle, + surface_container_high=Palette.Surface0, + surface_container_highest=Palette.Surface1, + outline=Palette.Surface1, + outline_variant=Palette.Surface1, + error=Palette.Red, + on_error=Palette.Crust, + ) + return ft.Theme( + font_family=FontFamily, + color_scheme=ColorScheme, + use_material3=True, + scaffold_bgcolor=Palette.Base, + canvas_color=Palette.Base, + dialog_theme=ft.DialogTheme( + bgcolor=Palette.Mantle, + shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), + title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=FontFamily), + ), + ) + + +def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: + """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius. + Accent defaults to Mauve; Remove buttons pass Palette.Red.""" + return ft.ButtonStyle( + color=Palette.Text, + bgcolor=Palette.Surface0, + side=ft.BorderSide(1, Accent or Palette.Mauve), + shape=ft.RoundedRectangleBorder(radius=4), + ) diff --git a/Dialogs.py b/Dialogs.py new file mode 100644 index 0000000..1502344 --- /dev/null +++ b/Dialogs.py @@ -0,0 +1,143 @@ +"""Install, Update and Remove dialogs. Each only gathers choices and shows +what's about to happen; the actual work runs back in Main.py.""" + +from typing import Callable + +import flet as ft + +from Core import ChipButtonStyle, Home, Palette +from Installer import ConfigPlan, RemovalItem, RemovalPlan, Status +from Manifests import Program + +_MaxListedItems = 12 + + +def _Short(PathText: str) -> str: + return PathText.replace(str(Home), "~", 1) if PathText.startswith(str(Home)) else PathText + + +def _Heading(Text: str) -> ft.Text: + return ft.Text(Text, size=14, color=Palette.Mauve) + + +def _ItemList(Items: list[RemovalItem]) -> ft.Column: + Lines = [] + for Item in Items[:_MaxListedItems]: + Spans = [ft.TextSpan(_Short(Item.Path), ft.TextStyle(color=Palette.Text))] + if Item.Note: + Spans.append(ft.TextSpan(f" {Item.Note}", ft.TextStyle(color=Palette.Overlay0))) + Lines.append(ft.Text(spans=Spans, size=12, selectable=True)) + if len(Items) > _MaxListedItems: + Lines.append(ft.Text(f"+ {len(Items) - _MaxListedItems} more", size=12, color=Palette.Overlay0)) + if not Items: + Lines.append(ft.Text("(nothing)", size=12, color=Palette.Overlay0)) + return ft.Column(Lines, spacing=2) + + +def _Dialog(Page: ft.Page, Title: str, Body: list[ft.Control], ConfirmText: str, OnConfirm: Callable[[], None], Danger: bool = False) -> ft.AlertDialog: + def Confirm(e) -> None: + Page.pop_dialog() + OnConfirm() + + return ft.AlertDialog( + title=Title, + content=ft.Container( + content=ft.Column(Body, spacing=10, scroll=ft.ScrollMode.AUTO, tight=True), + width=640, + ), + actions=[ + ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), + ft.OutlinedButton( + content=ConfirmText, + style=ChipButtonStyle(Palette.Red if Danger else ""), + on_click=Confirm, + ), + ], + ) + + +def ShowInstallDialog(Page: ft.Page, Program: Program, OnConfirm: Callable[[dict[str, bool], dict[str, str]], None]) -> None: + Fields = {Question.Id: ft.TextField(label=Question.Prompt, value=Question.Default) for Question in Program.Questions} + Boxes = {Option.Id: ft.Checkbox(label=Option.Label, value=Option.Default) for Option in Program.Options} + + Body: list[ft.Control] = [ft.Text(Program.Description, size=13, color=Palette.Overlay0)] + Body += list(Fields.values()) + if Boxes: + Body.append(_Heading("Options")) + Body += list(Boxes.values()) + + Page.show_dialog(_Dialog( + Page, f"Install {Program.Name}", Body, "Install", + lambda: OnConfirm( + {Id: bool(Box.value) for Id, Box in Boxes.items()}, + {Id: (Field.value or "").strip() for Id, Field in Fields.items()}, + ), + )) + + +def ShowUpdateDialog( + Page: ft.Page, Program: Program, Current: Status, NewVersion: str, NewCommit: str, + Plans: list[ConfigPlan], OnConfirm: Callable[[], None], +) -> None: + Old = Current.Receipt + OldLabel = f"{Old.Version} ({Old.Commit[:7]})".strip() if Old else "?" + NewLabel = f"{NewVersion} ({NewCommit[:7]})".strip() + Body: list[ft.Control] = [ft.Text(f"{OldLabel} -> {NewLabel}", size=14, color=Palette.Text)] + if Old is not None and Old.Commit == NewCommit: + Body.append(ft.Text( + "Same commit: this reapplies the install (e.g. a newer SaraSuite changed how it's set up).", + size=12, color=Palette.Overlay0, + )) + Body.append(ft.Text( + "Program files, desktop entries and services are replaced. Configs are merged, so your edits stay.", + size=12, color=Palette.Overlay0, + )) + if Plans: + Body.append(_Heading("Configs")) + for Plan in Plans: + Color = Palette.Peach if Plan.Result.Action == "KeepAndNew" else Palette.Text + Body.append(ft.Text(spans=[ + ft.TextSpan(_Short(Plan.Target), ft.TextStyle(color=Color)), + ft.TextSpan(f" {Plan.Result.Describe()}", ft.TextStyle(color=Palette.Overlay0)), + ], size=12)) + for Change in Plan.Result.Changes: + Body.append(ft.Text(f" {Change}", size=12, color=Palette.Overlay0)) + + Page.show_dialog(_Dialog(Page, f"Update {Program.Name}", Body, "Update", OnConfirm)) + + +def ShowRemoveDialog(Page: ft.Page, Program: Program, Plan: RemovalPlan, Installed: bool, OnConfirm: Callable[[bool, bool], None]) -> None: + ConfigItems = Plan.Group("Config") + RuntimeItems = Plan.Group("Runtime") + ConfigList = _ItemList(ConfigItems) + RuntimeList = _ItemList(RuntimeItems) + ConfigList.opacity = 0.5 + + ConfigBox = ft.Checkbox(label="Remove configs", value=False) + RuntimeBox = ft.Checkbox(label="Remove files it made while running", value=True) + + def Dim(e) -> None: + ConfigList.opacity = 1 if ConfigBox.value else 0.5 + RuntimeList.opacity = 1 if RuntimeBox.value else 0.5 + ConfigList.update() + RuntimeList.update() + + ConfigBox.on_change = Dim + RuntimeBox.on_change = Dim + + Body: list[ft.Control] = [] + Always = Plan.Group("Program") + Plan.Group("Integration") + if Always: + Body += [_Heading("Always removed"), _ItemList(Always)] + Body += [ConfigBox, ConfigList, RuntimeBox, RuntimeList] + Body.append(ft.Text( + "Your own files are never removed: folders that might hold them are only removed when empty.", + size=12, color=Palette.Overlay0, + )) + + Page.show_dialog(_Dialog( + Page, f"{'Remove' if Installed else 'Clean up'} {Program.Name}", Body, + "Remove" if Installed else "Clean up", + lambda: OnConfirm(bool(ConfigBox.value), bool(RuntimeBox.value)), + Danger=True, + )) diff --git a/Installer.py b/Installer.py new file mode 100644 index 0000000..1c2ddbd --- /dev/null +++ b/Installer.py @@ -0,0 +1,641 @@ +"""Install, update and remove programs described by a manifest. + +Install and Update are the same operation (_Apply): make everything match the +manifest, recording each placed piece in the Receipt as it goes. Remove walks +the Receipt plus the program's Leftovers and takes all of it back out. +""" + +import configparser +import datetime +import hashlib +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from glob import glob +from pathlib import Path, PurePosixPath +from typing import Callable, Optional + +from ConfigMerge import ApplySet, MergeConfig, MergeResult +from Core import BackupsDir, ChildEnvironment, DownloadsDir, PristineDir +from Manifests import Leftover, Program, Render +from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt +from Sources import ClearDownloads, Download, LatestCommit, SourceError + +LogFn = Callable[[str], None] + + +class InstallError(Exception): + pass + + +# --- Small helpers ------------------------------------------------------------ + + +def _Now() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + + +def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int: + """Run a command, streaming its output into the log.""" + Log(f"$ {' '.join(Command)}") + Process = subprocess.Popen( + Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(), + ) + for Line in Process.stdout: + Log(f" {Line.rstrip()}") + Code = Process.wait() + if Check and Code != 0: + raise InstallError(f"{Command[0]} failed (exit code {Code})") + return Code + + +def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int: + return _Run(["systemctl", "--user", *Args], Log, Check=Check) + + +def _RemovePath(Target: Path) -> None: + if Target.is_symlink() or Target.is_file(): + Target.unlink() + elif Target.is_dir(): + shutil.rmtree(Target) + + +def _BackupName(Target: str) -> str: + """A flat, unique file name for backing up an absolute path.""" + return Target.strip("/").replace("/", "__") + + +def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None: + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary: + Temporary.write(Text) + os.chmod(Temporary.name, 0o644) + try: + _Run(["pkexec", "install", "-D", "-m", "644", Temporary.name, Target], Log) + finally: + os.unlink(Temporary.name) + + +def _RemoveRoot(Target: str, Log: LogFn) -> None: + _Run(["pkexec", "rm", "-f", Target], Log) + + +def _ReadIni(FilePath: Path) -> configparser.ConfigParser: + Parser = configparser.ConfigParser(interpolation=None, strict=False, delimiters=("=",)) + Parser.optionxform = str # keep key case (MIME types, D-Bus names) + if FilePath.exists(): + Parser.read(FilePath) + return Parser + + +def _WriteIni(FilePath: Path, Parser: configparser.ConfigParser) -> None: + FilePath.parent.mkdir(parents=True, exist_ok=True) + with FilePath.open("w") as File: + Parser.write(File, space_around_delimiters=False) + + +# --- Sources ------------------------------------------------------------------ + + +@dataclass +class Fetched: + """One downloaded commit of a program, ready to install from.""" + + Commit: str + Dir: Path + + +def Fetch(Program: Program, Log: LogFn) -> Fetched: + Commit = LatestCommit(Program.GitUrl, Program.Branch) + return Fetched(Commit, Download(Program.Name, Program.GitUrl, Commit, Log)) + + +def SourceFiles(Program: Program, SourceDir: Path) -> list[PurePosixPath]: + """Relative paths of the files to copy, per the manifest's Include/Exclude.""" + Found = [] + for DirPath, DirNames, FileNames in os.walk(SourceDir): + RelDir = PurePosixPath(Path(DirPath).relative_to(SourceDir).as_posix()) + DirNames[:] = sorted( + Name for Name in DirNames + if not any((RelDir / Name).full_match(Pattern) or Name == Pattern for Pattern in Program.Exclude) + ) + for Name in sorted(FileNames): + Rel = RelDir / Name if str(RelDir) != "." else PurePosixPath(Name) + if any(Rel.full_match(Pattern) or Name == Pattern for Pattern in Program.Exclude): + continue + if any(Rel.full_match(Pattern) for Pattern in Program.Include): + Found.append(Rel) + return Found + + +def ManifestHash(Program: Program) -> str: + """Changes when the bundled manifest/templates change (a new SaraSuite + AppImage), so that shows up as an update even with no new commit.""" + Hasher = hashlib.sha256() + for ManifestFile in sorted(Program.Folder.rglob("*")): + if ManifestFile.is_file() and "__pycache__" not in ManifestFile.parts: + Hasher.update(str(ManifestFile.relative_to(Program.Folder)).encode()) + Hasher.update(ManifestFile.read_bytes()) + return Hasher.hexdigest() + + +def SourceVersion(Program: Program, SourceDir: Path) -> str: + if not Program.VersionFile: + return "" + VersionPath = SourceDir / Program.VersionFile + if not VersionPath.exists(): + return "" + Match = re.search(Program.VersionPattern, VersionPath.read_text(errors="replace")) + return Match[1] if Match else "" + + +# --- Status ------------------------------------------------------------------- + + +@dataclass +class Status: + Receipt: Optional[Receipt] + LatestCommit: str = "" # empty when the git host couldn't be reached + ManifestHash: str = "" + Error: str = "" + + @property + def Installed(self) -> bool: + return self.Receipt is not None + + @property + def Broken(self) -> bool: + return self.Receipt is not None and not self.Receipt.Complete + + @property + def UpdateAvailable(self) -> bool: + if self.Receipt is None: + return False + NewCommit = bool(self.LatestCommit) and self.LatestCommit != self.Receipt.Commit + return self.Broken or NewCommit or self.ManifestHash != self.Receipt.ManifestHash + + +def GetStatus(Program: Program) -> Status: + """Installed state from the receipt, plus the latest commit from the git + host (one small HTTPS request, no download).""" + Current = Status(Receipt=LoadReceipt(Program.Name), ManifestHash=ManifestHash(Program)) + try: + Current.LatestCommit = LatestCommit(Program.GitUrl, Program.Branch) + except SourceError as Error: + Current.Error = str(Error) + return Current + + +# --- Configs -------------------------------------------------------------------- + + +@dataclass +class ConfigPlan: + Target: str + Result: MergeResult + Pristine: str # the new default, saved as the next merge's Base + + +def PlanConfigs(Program: Program, SourceDir: Path, Answers: dict[str, str]) -> list[ConfigPlan]: + """What an install/update would do to each config (also shown in the + Update dialog before anything is touched).""" + Context = Program.Context(Answers) + Plans = [] + for Config in Program.Configs: + Target = Render(Config.Target, Context) + ConfigSource = SourceDir / Config.Source + if not ConfigSource.exists(): + raise InstallError(f"Config {Config.Source} is missing from {SourceDir}") + Theirs = ConfigSource.read_text() + Theirs = ApplySet(Theirs, {Key: Render(Value, Context) for Key, Value in Config.Set.items()}) + + PristinePath = PristineDir / Program.Name / Path(Target).name + Base = PristinePath.read_text() if PristinePath.exists() else None + Ours = Path(Target).read_text() if Path(Target).exists() else None + Plans.append(ConfigPlan(Target, MergeConfig(Base, Ours, Theirs, Target.endswith(".toml")), Theirs)) + return Plans + + +def _ApplyConfigs(Program: Program, Plans: list[ConfigPlan], NewReceipt: Receipt, Log: LogFn) -> None: + MergeBackups = BackupsDir / Program.Name / "Configs" + Stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + for Plan in Plans: + Target = Path(Plan.Target) + Result = Plan.Result + Log(f"Config {Target.name}: {Result.Describe()}") + for Change in Result.Changes: + Log(f" - {Change}") + + if Result.Text is not None: + Target.parent.mkdir(parents=True, exist_ok=True) + if Result.Action == "Merge" and Target.exists(): + MergeBackups.mkdir(parents=True, exist_ok=True) + BackupPath = MergeBackups / f"{Target.name}.{Stamp}" + shutil.copy2(Target, BackupPath) + Log(f" (your previous version is backed up at {BackupPath})") + Target.write_text(Result.Text) + + NewFile = Target.with_name(Target.name + ".new") + if Result.Action == "KeepAndNew": + NewFile.write_text(Plan.Pristine) + NewReceipt.Files.append(PlacedFile(str(NewFile), "ConfigNew")) + + PristinePath = PristineDir / Program.Name / Target.name + PristinePath.parent.mkdir(parents=True, exist_ok=True) + PristinePath.write_text(Plan.Pristine) + NewReceipt.Files.append(PlacedFile(str(Target), "Config")) + + +# --- Install / Update ------------------------------------------------------------- + + +def _CopyProgramFiles(Program: Program, SourceDir: Path, Log: LogFn) -> None: + Files = SourceFiles(Program, SourceDir) + if not Files: + raise InstallError(f"No files matched Include {Program.Include} in {SourceDir}") + Staging = Program.ProgramDir / "App.new" + _RemovePath(Staging) + for Rel in Files: + Destination = Staging / Rel + Destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(SourceDir / Rel, Destination) + _RemovePath(Program.AppDir) + Staging.rename(Program.AppDir) + Log(f"Copied {len(Files)} program files to {Program.AppDir}") + + +def _RequirementsText(Program: Program) -> str: + if Program.RequirementsFile: + return (Program.AppDir / Program.RequirementsFile).read_text() + return "\n".join(Program.Requirements) + + +def _SetUpVenv(Program: Program, OldHash: str, Log: LogFn) -> str: + """(Re)install requirements only when they changed. Returns the new hash.""" + if not Program.UsesPython: + return "" + Venv = Program.ProgramDir / ".venv" + NewHash = hashlib.sha256(_RequirementsText(Program).encode()).hexdigest() + if (Venv / "bin" / "python").exists() and NewHash == OldHash: + Log("Python requirements unchanged, skipping pip") + return NewHash + if not (Venv / "bin" / "python").exists(): + # Always the system python: inside the AppImage sys.executable is the + # AppImage itself, and the venv has to outlive SaraSuite anyway. + Python = shutil.which("python3") + if Python is None: + raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)") + _Run([Python, "-m", "venv", str(Venv)], Log) + Pip = [str(Venv / "bin" / "python"), "-m", "pip", "install", "--disable-pip-version-check"] + if Program.RequirementsFile: + _Run([*Pip, "-r", str(Program.AppDir / Program.RequirementsFile)], Log) + else: + _Run([*Pip, *Program.Requirements], Log) + return NewHash + + +def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None: + if not Placed.Displaced or not Path(Placed.Displaced).exists(): + return + Current = Path(Placed.Path) + if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes(): + pass # already identical (typical for a root file that was never removed) + elif Placed.Root: + _WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log) + else: + shutil.copy2(Placed.Displaced, Placed.Path) + Path(Placed.Displaced).unlink() + Log(f"Restored your previous {Placed.Path}") + + +def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None: + if Placed.Root and Placed.Displaced: + pass # overwritten in place by _PutBackDisplaced: one password prompt, not two + elif Placed.Root: + if Path(Placed.Path).exists(): + _RemoveRoot(Placed.Path, Log) + Log(f"Removed {Placed.Path}") + else: + Path(Placed.Path).unlink(missing_ok=True) + Log(f"Removed {Placed.Path}") + _PutBackDisplaced(Placed, Log) + + +def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + Wanted = [Template for Template in Program.Templates if Program.OptionEnabled(Template.Option, NewReceipt.Options)] + WantedTargets = {Render(Template.Target, Context) for Template in Wanted} + + # Anything the old install placed that this one no longer wants (an option + # was unticked, or the manifest dropped it). + if OldReceipt is not None: + for Placed in OldReceipt.Files: + if Placed.Kind == "Template" and Placed.Path not in WantedTargets: + _RemovePlacedTemplate(Placed, Log) + + for Template in Wanted: + Target = Render(Template.Target, Context) + Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context) + Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None + Placed = PlacedFile(Target, "Template", Root=Template.Root, Displaced=Previous.Displaced if Previous else "") + + # A file that's already there but wasn't placed by SaraSuite (e.g. a + # hand-made desktop entry from development) is moved aside, not lost. + if Previous is None and Path(Target).exists(): + Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target) + Displaced.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(Target, Displaced) + Placed.Displaced = str(Displaced) + Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)") + + NewReceipt.Files.append(Placed) + if Template.Root: + if Path(Target).exists() and Path(Target).read_text() == Text: + continue + _WriteRoot(Target, Text, Log) + else: + Path(Target).parent.mkdir(parents=True, exist_ok=True) + Path(Target).write_text(Text) + if Template.Executable: + Path(Target).chmod(0o755) + Log(f"Wrote {Target}") + + +def _RevertIni(Record: IniRecord, Log: LogFn) -> None: + FilePath = Path(Record.File) + Parser = _ReadIni(FilePath) + if not Parser.has_section(Record.Section): + return + Current = Parser.get(Record.Section, Record.Key, fallback=None) + if Current is not None and Current != Record.Value: + Log(f"Left {Record.Key} in {FilePath} alone (it was changed after SaraSuite set it)") + return + if Record.HadPrevious: + Parser.set(Record.Section, Record.Key, Record.Previous) + Log(f"Restored {Record.Key}={Record.Previous} in {FilePath}") + else: + Parser.remove_option(Record.Section, Record.Key) + if not Parser.options(Record.Section): + Parser.remove_section(Record.Section) + Log(f"Removed {Record.Key} from {FilePath}") + if Parser.sections(): + _WriteIni(FilePath, Parser) + else: + FilePath.unlink(missing_ok=True) + + +def _ApplyIniEdits(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + Wanted = [] + for Edit in Program.IniEdits: + if Program.OptionEnabled(Edit.Option, NewReceipt.Options): + Wanted.append((Render(Edit.File, Context), Edit.Section, Edit.Key, Render(Edit.Value, Context))) + + if OldReceipt is not None: + WantedKeys = {(File, Section, Key) for File, Section, Key, _Value in Wanted} + for Record in OldReceipt.IniEdits: + if (Record.File, Record.Section, Record.Key) not in WantedKeys: + _RevertIni(Record, Log) + + for File, Section, Key, Value in Wanted: + Parser = _ReadIni(Path(File)) + Previous = OldReceipt.FindIni(File, Section, Key) if OldReceipt is not None else None + if Previous is not None: + Record = IniRecord(File, Section, Key, Value, Previous.HadPrevious, Previous.Previous) + else: + Existing = Parser.get(Section, Key, fallback=None) if Parser.has_section(Section) else None + Record = IniRecord(File, Section, Key, Value, Existing is not None, Existing or "") + NewReceipt.IniEdits.append(Record) + if Parser.has_section(Section) and Parser.get(Section, Key, fallback=None) == Value: + continue + if not Parser.has_section(Section): + Parser.add_section(Section) + Parser.set(Section, Key, Value) + _WriteIni(Path(File), Parser) + Log(f"Set {Key}={Value} in {File}") + + +def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: + if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices): + return + _Systemctl(Log, "daemon-reload") + WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set() + for Service in Program.Services: + if Program.OptionEnabled(Service.Option, NewReceipt.Options): + NewReceipt.EnabledServices.append(Service.Unit) + if Service.Unit in WasEnabled: + _Systemctl(Log, "try-restart", Service.Unit) + else: + _Systemctl(Log, "enable", "--now", Service.Unit, Check=True) + elif Service.Unit in WasEnabled: + _Systemctl(Log, "disable", "--now", Service.Unit) + + +def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None: + Applications = Path(Program.Context()["ApplicationsDir"]) + if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists(): + _Run(["update-desktop-database", str(Applications)], Log, Check=False) + for Unit in Program.RestartUnits: + _Systemctl(Log, "try-restart", Unit) + + +def Apply( + Program: Program, + Log: LogFn, + Options: Optional[dict[str, bool]] = None, + Answers: Optional[dict[str, str]] = None, + Source: Optional[Fetched] = None, +) -> Receipt: + """Install (no receipt yet) or update (receipt exists) a program to the + latest commit, or to Source if it was already fetched (the Update dialog + fetches first to preview the config merges). + Options/Answers are only used on install; updates keep the recorded ones.""" + OldReceipt = LoadReceipt(Program.Name) + Action = "Updating" if OldReceipt else "Installing" + Log(f"== {Action} {Program.Name}") + + Source = Source or Fetch(Program, Log) + SourceDir = Source.Dir + NewReceipt = Receipt( + Name=Program.Name, + Version=SourceVersion(Program, SourceDir), + Commit=Source.Commit, + ManifestHash=ManifestHash(Program), + InstalledAt=OldReceipt.InstalledAt if OldReceipt else _Now(), + UpdatedAt=_Now(), + Options=dict(OldReceipt.Options) if OldReceipt else dict(Options or {}), + Answers=dict(OldReceipt.Answers) if OldReceipt else dict(Answers or {}), + ) + # Options added to the manifest since this program was installed. + for Option in Program.Options: + NewReceipt.Options.setdefault(Option.Id, Option.Default) + for Question in Program.Questions: + NewReceipt.Answers.setdefault(Question.Id, Question.Default) + if Question.Kind == "Path": + NewReceipt.Answers[Question.Id] = str(Path(NewReceipt.Answers[Question.Id]).expanduser().absolute()) + Context = Program.Context(NewReceipt.Answers) + ConfigPlans = PlanConfigs(Program, SourceDir, NewReceipt.Answers) + + try: + Program.ProgramDir.mkdir(parents=True, exist_ok=True) + _CopyProgramFiles(Program, SourceDir, Log) + NewReceipt.RequirementsHash = _SetUpVenv(Program, OldReceipt.RequirementsHash if OldReceipt else "", Log) + _ApplyTemplates(Program, Context, OldReceipt, NewReceipt, Log) + _ApplyConfigs(Program, ConfigPlans, NewReceipt, Log) + # .new files from an earlier update that are still around stay tracked. + if OldReceipt is not None: + for Placed in OldReceipt.Files: + if Placed.Kind == "ConfigNew" and Path(Placed.Path).exists() and NewReceipt.FindFile(Placed.Path) is None: + NewReceipt.Files.append(Placed) + _ApplyIniEdits(Program, Context, OldReceipt, NewReceipt, Log) + _ApplyServices(Program, OldReceipt, NewReceipt, Log) + _AfterIntegrationChanges(Program, Log) + NewReceipt.Complete = True + ClearDownloads(Program.Name) + finally: + if OldReceipt is not None and not NewReceipt.Complete: + # A failure partway: keep everything the old receipt knew about too, + # so Remove still finds every file either install placed. + for Placed in OldReceipt.Files: + if NewReceipt.FindFile(Placed.Path) is None: + NewReceipt.Files.append(Placed) + for Record in OldReceipt.IniEdits: + if NewReceipt.FindIni(Record.File, Record.Section, Record.Key) is None: + NewReceipt.IniEdits.append(Record) + NewReceipt.EnabledServices = sorted(set(NewReceipt.EnabledServices) | set(OldReceipt.EnabledServices)) + NewReceipt.Save() + + Label = f"{Program.Name} {NewReceipt.Version} ({NewReceipt.Commit[:7]})".replace(" ", " ") + Log(f"== {Label} {'updated' if OldReceipt else 'installed'}") + return NewReceipt + + +# --- Remove ------------------------------------------------------------------------- + + +@dataclass +class RemovalItem: + Path: str + Group: str # "Program", "Integration", "Config" or "Runtime" + Note: str = "" + Mode: str = "Remove" # "Remove" or "IfEmpty" + + +@dataclass +class RemovalPlan: + Items: list[RemovalItem] = field(default_factory=list) + + def Group(self, Name: str) -> list[RemovalItem]: + return [Item for Item in self.Items if Item.Group == Name] + + +def _ExpandLeftover(Item: Leftover, Context: dict) -> list[RemovalItem]: + Pattern = str(Path(Render(Item.Path, Context)).expanduser()) + Paths = sorted(glob(Pattern, include_hidden=True)) if any(Char in Pattern for Char in "*?[") else [Pattern] + return [ + RemovalItem(Found, Item.Kind, Item.Note, Item.Mode) + for Found in Paths + if Path(Found).exists() or Path(Found).is_symlink() + ] + + +def PlanRemoval(Program: Program) -> RemovalPlan: + Plan = RemovalPlan() + Current = LoadReceipt(Program.Name) + Context = Program.Context(Current.Answers if Current else None) + + if Program.ProgramDir.exists(): + Plan.Items.append(RemovalItem(str(Program.ProgramDir), "Program", "program files and Python venv")) + for Downloaded in sorted(DownloadsDir.glob(f"{Program.Name}-*")): + Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source")) + + if Current is not None: + for Unit in Current.EnabledServices: + Plan.Items.append(RemovalItem(Unit, "Integration", "systemd user service, stopped and disabled")) + for Placed in Current.Files: + if Placed.Kind == "Template": + Note = "put back to your previous version" if Placed.Displaced else "" + if Placed.Root: + Note = ", ".join(Part for Part in ("needs your password", Note) if Part) + Plan.Items.append(RemovalItem(Placed.Path, "Integration", Note)) + elif Path(Placed.Path).exists(): + Plan.Items.append(RemovalItem(Placed.Path, "Config")) + for Record in Current.IniEdits: + Note = f"restore {Record.Key}={Record.Previous}" if Record.HadPrevious else f"remove {Record.Key}" + Plan.Items.append(RemovalItem(Record.File, "Integration", Note)) + + if Program.ConfigDir.exists(): + Plan.Items.append(RemovalItem(str(Program.ConfigDir), "Config", "the whole config folder")) + MergeBackups = BackupsDir / Program.Name / "Configs" + if MergeBackups.exists(): + Plan.Items.append(RemovalItem(str(MergeBackups), "Config", "config backups made during updates")) + + Leftovers = list(Program.Leftovers) + if Program.Hooks is not None and hasattr(Program.Hooks, "Leftovers"): + Leftovers += Program.Hooks.Leftovers(Context) + for Item in Leftovers: + Plan.Items.extend(_ExpandLeftover(Item, Context)) + + # The same path can come from both the receipt and a Leftover. + Seen: set[str] = set() + Unique = [] + for Item in Plan.Items: + if Item.Path in Seen: + continue + Seen.add(Item.Path) + Unique.append(Item) + Plan.Items = Unique + return Plan + + +def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogFn) -> None: + Log(f"== Removing {Program.Name}") + Plan = PlanRemoval(Program) + Current = LoadReceipt(Program.Name) + + if Current is not None: + for Unit in Current.EnabledServices: + _Systemctl(Log, "disable", "--now", Unit) + for Placed in Current.Files: + if Placed.Kind == "Template": + _RemovePlacedTemplate(Placed, Log) + for Record in Current.IniEdits: + _RevertIni(Record, Log) + if Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files): + _Systemctl(Log, "daemon-reload") + + Groups = {"Program"} + if RemoveConfigs: + Groups.add("Config") + if RemoveRuntime: + Groups.add("Runtime") + + # Files first, then "IfEmpty" folders deepest-first, so a folder that only + # held things removed above is empty by the time it's checked. + Chosen = [Item for Item in Plan.Items if Item.Group in Groups] + for Item in [Item for Item in Chosen if Item.Mode == "Remove"]: + Target = Path(Item.Path) + if Target.exists() or Target.is_symlink(): + _RemovePath(Target) + Log(f"Removed {Target}") + for Item in sorted((Item for Item in Chosen if Item.Mode == "IfEmpty"), key=lambda Item: -len(Path(Item.Path).parts)): + Target = Path(Item.Path) + if Target.is_dir() and not any(Target.iterdir()): + Target.rmdir() + Log(f"Removed empty {Target}") + elif Target.exists(): + Log(f"Kept {Target} (not empty)") + + # SaraSuite's own bookkeeping. The Pristine configs are kept along with the + # user's configs, so a later reinstall can still merge against them. + if RemoveConfigs: + _RemovePath(PristineDir / Program.Name) + _RemovePath(BackupsDir / Program.Name / "Displaced") + ProgramBackups = BackupsDir / Program.Name + if ProgramBackups.is_dir() and not any(ProgramBackups.iterdir()): + ProgramBackups.rmdir() + if Current is not None: + Current.FilePath.unlink(missing_ok=True) + + _AfterIntegrationChanges(Program, Log) + Log(f"== {Program.Name} removed" + ("" if RemoveConfigs else " (configs kept)")) diff --git a/Main.py b/Main.py new file mode 100644 index 0000000..071337d --- /dev/null +++ b/Main.py @@ -0,0 +1,211 @@ +"""SaraSuite entry point: installs, updates and removes Sara programs. +With arguments it runs the command-line mode instead (see Cli.py).""" + +import os +import shutil +import sys +import traceback +from typing import Callable + +import flet as ft + +from Core import ( + AppName, ApplyTheme, AssetsDir, BuildTheme, ChipButtonStyle, DataDir, DataHome, FontFamily, + LoadConfig, Palette, +) +from Dialogs import ShowInstallDialog, ShowRemoveDialog, ShowUpdateDialog +from Installer import Apply, Fetch, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion +from Manifests import LoadPrograms +from ProgramCard import ProgramCard + +_MaxLogLines = 2000 + +# When running as an AppImage, SaraSuite can put itself in the app menu. +_AppImage = os.environ.get("APPIMAGE", "") +_MenuEntry = DataHome / "applications" / "SaraSuite.desktop" +_MenuIcon = DataDir / "SaraSuite.png" + + +def _SetMenuEntry(Enabled: bool) -> None: + if not Enabled: + _MenuEntry.unlink(missing_ok=True) + _MenuIcon.unlink(missing_ok=True) + return + _MenuIcon.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(AssetsDir / "Icon.png", _MenuIcon) + _MenuEntry.parent.mkdir(parents=True, exist_ok=True) + _MenuEntry.write_text( + "[Desktop Entry]\nType=Application\nName=SaraSuite\n" + "Comment=Install, update and remove Sara programs\n" + f"Exec={_AppImage}\nIcon={_MenuIcon}\nTerminal=false\n" + "Categories=System;Settings;\nStartupNotify=true\n" + ) + + +def Main(Page: ft.Page) -> None: + Config = LoadConfig() + ApplyTheme(Config.Theme) # must run before anything reads Palette.X + + Page.title = AppName + Page.theme_mode = ft.ThemeMode.DARK + Page.theme = BuildTheme() + Page.dark_theme = BuildTheme() + Page.fonts = {FontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} + Page.padding = 0 + Page.bgcolor = Palette.Base + Page.window.width = 960 + Page.window.height = 720 + + LogView = ft.ListView(expand=True, auto_scroll=True, spacing=0, padding=8) + + def Log(Line: str) -> None: + if Line.startswith("=="): + Color = Palette.Mauve + elif Line.startswith("Error"): + Color = Palette.Red + elif Line.startswith(("$", " ")): + Color = Palette.Overlay0 + else: + Color = Palette.Text + LogView.controls.append(ft.Text(Line, size=12, color=Color, selectable=True)) + if len(LogView.controls) > _MaxLogLines: + del LogView.controls[: len(LogView.controls) - _MaxLogLines] + LogView.update() + + Programs = LoadPrograms() + Cards: list[ProgramCard] = [] + State = {"Busy": False} + + def RefreshCard(Card: ProgramCard) -> None: + try: + Current = GetStatus(Card.Program) + Leftovers = PlanRemoval(Card.Program) + except Exception as Error: + Log(f"Error checking {Card.Program.Name}: {Error}") + return + Card.ShowStatus(Current, Leftovers, State["Busy"]) + Card.update() + + def RefreshAll() -> None: + for Card in Cards: + RefreshCard(Card) + + def SetBusy(Busy: bool, Working: ProgramCard | None = None) -> None: + State["Busy"] = Busy + RefreshButton.disabled = Busy + for Card in Cards: + Card.SetBusy(Busy, Card is Working) + Card.update() + RefreshButton.update() + + def RunOperation(Card: ProgramCard | None, Work: Callable[[], None]) -> None: + """Run Work in a background thread with every card locked, then + refresh the card's status. Only one operation runs at a time.""" + if State["Busy"]: + return + SetBusy(True, Card) + + def Worker() -> None: + try: + Work() + except Exception as Error: + Log(f"Error: {Error}") + for Line in traceback.format_exc().rstrip().splitlines(): + Log(f" {Line}") + finally: + SetBusy(False) + RefreshAll() + + Page.run_thread(Worker) + + def OnInstall(Card: ProgramCard) -> None: + Program = Card.Program + ShowInstallDialog( + Page, Program, + lambda Options, Answers: RunOperation(Card, lambda: Apply(Program, Log, Options, Answers)), + ) + + def OnUpdate(Card: ProgramCard) -> None: + # Download the new commit and plan the config merges first, so the + # dialog shows exactly what the update will do; Apply then installs + # that same download. + def Prepare() -> None: + Program = Card.Program + Source = Fetch(Program, Log) + Current = GetStatus(Program) + Plans = PlanConfigs(Program, Source.Dir, Current.Receipt.Answers) + SetBusy(False) + ShowUpdateDialog( + Page, Program, Current, SourceVersion(Program, Source.Dir), Source.Commit, Plans, + lambda: RunOperation(Card, lambda: Apply(Program, Log, Source=Source)), + ) + + RunOperation(Card, Prepare) + + def OnRemove(Card: ProgramCard) -> None: + Program = Card.Program + Installed = Card.CurrentStatus is not None and Card.CurrentStatus.Installed + ShowRemoveDialog( + Page, Program, PlanRemoval(Program), Installed, + lambda RemoveConfigs, RemoveRuntime: RunOperation( + Card, lambda: Remove(Program, RemoveConfigs, RemoveRuntime, Log), + ), + ) + + for Program in Programs: + Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove)) + + def ToggleMenuEntry(e) -> None: + _SetMenuEntry(not _MenuEntry.exists()) + MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu" + MenuButton.update() + + MenuButton = ft.OutlinedButton( + content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu", + style=ChipButtonStyle(), + visible=bool(_AppImage), + on_click=ToggleMenuEntry, + ) + + RefreshButton = ft.IconButton( + icon=ft.Icons.REFRESH, + icon_color=Palette.Mauve, + tooltip="Check for updates", + on_click=lambda e: RunOperation(None, RefreshAll), + ) + + Page.add( + ft.Column( + expand=True, + spacing=0, + controls=[ + ft.Container( + content=ft.Row([ + ft.Text(AppName, size=24, color=Palette.Mauve), + ft.Container(expand=True), + MenuButton, + RefreshButton, + ]), + padding=ft.Padding.symmetric(horizontal=16, vertical=8), + bgcolor=Palette.Mantle, + border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)), + ), + ft.ListView(Cards, expand=True, spacing=12, padding=16), + ft.Container( + content=LogView, + height=200, + bgcolor=Palette.Crust, + border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)), + ), + ], + ) + ) + Page.run_thread(RefreshAll) + + +if __name__ == "__main__": + if len(sys.argv) > 1: + from Cli import Run + + sys.exit(Run(sys.argv[1:])) + ft.run(Main, assets_dir=str(AssetsDir)) diff --git a/Manifests.py b/Manifests.py new file mode 100644 index 0000000..4292e66 --- /dev/null +++ b/Manifests.py @@ -0,0 +1,220 @@ +"""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, 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() + ] diff --git a/Packaging/BuildAppImage.sh b/Packaging/BuildAppImage.sh new file mode 100755 index 0000000..63e7158 --- /dev/null +++ b/Packaging/BuildAppImage.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Builds dist/SaraSuite-x86_64.AppImage: a PyInstaller one-file build (with +# the program manifests, fonts and icon bundled) wrapped by appimagetool, +# the same way SaraFem's AppImage is made. Needs appimagetool on PATH. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +.venv/bin/pip install --quiet --disable-pip-version-check pyinstaller +find Programs -name __pycache__ -type d -prune -exec rm -rf {} + + +.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \ + --onefile --windowed --name SaraSuite --collect-data flet \ + --add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \ + --distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \ + "$PWD/Main.py" + +AppDir=build/AppDir +rm -rf "$AppDir" +mkdir -p "$AppDir/usr/bin" +cp build/PyInstaller/dist/SaraSuite "$AppDir/usr/bin/SaraSuite" +ln -s usr/bin/SaraSuite "$AppDir/AppRun" +cp assets/Icon.png "$AppDir/SaraSuite.png" +ln -s SaraSuite.png "$AppDir/.DirIcon" +cat > "$AppDir/SaraSuite.desktop" << DESKTOP +[Desktop Entry] +Type=Application +Name=SaraSuite +Comment=Install, update and remove Sara programs +Exec=SaraSuite +Icon=SaraSuite +Terminal=false +Categories=System;Settings; +DESKTOP + +mkdir -p dist +ARCH=x86_64 appimagetool --no-appstream "$AppDir" dist/SaraSuite-x86_64.AppImage +echo "Built dist/SaraSuite-x86_64.AppImage" diff --git a/Packaging/Launch.sh b/Packaging/Launch.sh new file mode 100755 index 0000000..514065b --- /dev/null +++ b/Packaging/Launch.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Desktop-entry launcher: resolves the venv relative to this script rather +# than the caller's cwd, since .desktop Exec= lines are invoked with no +# guaranteed working directory. +cd "$(dirname "$(readlink -f "$0")")/.." || exit 1 +exec .venv/bin/python Main.py "$@" diff --git a/ProgramCard.py b/ProgramCard.py new file mode 100644 index 0000000..7937da2 --- /dev/null +++ b/ProgramCard.py @@ -0,0 +1,119 @@ +"""One card per program: name, status, versions, and its action buttons.""" + +from typing import Callable, Optional + +import flet as ft + +from Core import ChipButtonStyle, Palette +from Installer import RemovalPlan, Status +from Manifests import Program + + +class ProgramCard(ft.Container): + def __init__( + self, + Program: Program, + OnInstall: Callable[["ProgramCard"], None], + OnUpdate: Callable[["ProgramCard"], None], + OnRemove: Callable[["ProgramCard"], None], + ) -> None: + self.Program = Program + self.CurrentStatus: Optional[Status] = None + + self.StatusText = ft.Text("Checking...", size=12, color=Palette.Overlay0) + self.StatusChip = ft.Container( + content=self.StatusText, + bgcolor=Palette.Surface0, + border=ft.Border.all(1, Palette.Overlay0), + border_radius=4, + padding=ft.Padding.symmetric(horizontal=8, vertical=2), + ) + self.DetailText = ft.Text("", size=12, color=Palette.Overlay0, selectable=True) + self.Spinner = ft.ProgressRing(width=16, height=16, stroke_width=2, visible=False) + + self.InstallButton = ft.OutlinedButton( + content="Install", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnInstall(self), + ) + self.UpdateButton = ft.OutlinedButton( + content="Update", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnUpdate(self), + ) + self.RemoveButton = ft.OutlinedButton( + content="Remove", style=ChipButtonStyle(Palette.Red), visible=False, on_click=lambda e: OnRemove(self), + ) + + super().__init__( + content=ft.Column( + spacing=6, + controls=[ + ft.Row( + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Text(Program.Name, size=20, color=Palette.Mauve), + self.StatusChip, + ft.Container(expand=True), + self.Spinner, + self.InstallButton, + self.UpdateButton, + self.RemoveButton, + ], + ), + ft.Text(Program.Description, size=13, color=Palette.Text), + self.DetailText, + ], + ), + bgcolor=Palette.Mantle, + border=ft.Border.all(1, Palette.Surface1), + border_radius=8, + padding=16, + ) + + def _SetChip(self, Text: str, Color: str) -> None: + self.StatusText.value = Text + self.StatusText.color = Color + self.StatusChip.border = ft.Border.all(1, Color) + + def ShowStatus(self, Current: Status, Leftovers: RemovalPlan, Busy: bool) -> None: + self.CurrentStatus = Current + Receipt = Current.Receipt + + if Current.Broken: + self._SetChip("Incomplete", Palette.Peach) + elif Current.UpdateAvailable: + self._SetChip("Update available", Palette.Yellow) + elif Current.Installed: + self._SetChip("Installed", Palette.Green) + elif Current.Error: + self._SetChip("Unreachable", Palette.Red) + else: + self._SetChip("Not installed", Palette.Overlay0) + + Details = [] + if Receipt is not None: + Installed = f"Installed {Receipt.Version} ({Receipt.Commit[:7]})".replace(" ", " ") + Details.append(f"{Installed} on {Receipt.UpdatedAt.replace('T', ' ')}") + if Current.LatestCommit and (Receipt is None or Current.LatestCommit != Receipt.Commit): + Details.append(f"Latest commit {Current.LatestCommit[:7]}") + Details.append(f"From {self.Program.GitUrl}") + if Current.Error: + Details.append(Current.Error) + if Receipt is None and Leftovers.Items: + Details.append(f"{len(Leftovers.Items)} file(s)/folder(s) it made are still around") + self.DetailText.value = " ยท ".join(Details) + + self.InstallButton.visible = not Current.Installed + self.InstallButton.disabled = Busy or not Current.LatestCommit + self.UpdateButton.visible = Current.Installed + self.UpdateButton.disabled = Busy or not Current.UpdateAvailable or not Current.LatestCommit + self.UpdateButton.content = "Repair" if Current.Broken else "Update" + # Not installed but configs/runtime files were kept: still removable. + self.RemoveButton.visible = Current.Installed or bool(Leftovers.Items) + self.RemoveButton.content = "Remove" if Current.Installed else "Clean up" + self.RemoveButton.disabled = Busy + self.Spinner.visible = False + + def SetBusy(self, Busy: bool, Working: bool = False) -> None: + """Busy greys out the buttons (only one operation runs at a time); + Working also shows this card's spinner.""" + for Button in (self.InstallButton, self.UpdateButton, self.RemoveButton): + Button.disabled = Busy + self.Spinner.visible = Working diff --git a/Programs/SaraFem/Manifest.toml b/Programs/SaraFem/Manifest.toml new file mode 100644 index 0000000..79f5ae9 --- /dev/null +++ b/Programs/SaraFem/Manifest.toml @@ -0,0 +1,67 @@ +Name = "SaraFem" +Description = "Desktop file manager that understands SaraTag Books, and can stand in as the system file picker." + +# The portal reads its descriptors and preferences once at startup. +RestartUnits = ["xdg-desktop-portal.service"] + +[Source] +Git = "{GitHost}/SaraFem" +Include = ["*.py", "requirements.txt", "assets/**"] +Exclude = ["Packaging"] + +[Python] +RequirementsFile = "requirements.txt" + +[[Options]] +Id = "FilePicker" +Label = "Use SaraFem as the system file picker (asks for your password: the portal file lives in /usr/share)" +Default = true + +[[Options]] +Id = "DefaultFileManager" +Label = "Make SaraFem the default file manager" +Default = false + +[[Templates]] +Source = "Launch.sh" +Target = "{AppDir}/Packaging/Launch.sh" # where PortalService.py looks for it +Executable = true + +[[Templates]] +Source = "SaraFem.desktop" +Target = "{ApplicationsDir}/SaraFem.desktop" + +[[Templates]] +Source = "PortalService.service" +Target = "{DBusServicesDir}/org.freedesktop.impl.portal.desktop.SaraFem.service" +Option = "FilePicker" + +[[Templates]] +Source = "SaraFem.portal" +Target = "/usr/share/xdg-desktop-portal/portals/SaraFem.portal" +Option = "FilePicker" +Root = true + +[[IniEdits]] +File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf" +Section = "preferred" +Key = "org.freedesktop.impl.portal.FileChooser" +Value = "SaraFem;{Desktop};gtk;" +Option = "FilePicker" + +[[IniEdits]] +File = "{ConfigHome}/mimeapps.list" +Section = "Default Applications" +Key = "inode/directory" +Value = "SaraFem.desktop" +Option = "DefaultFileManager" + +[[Leftovers]] +Path = "{CacheHome}/SaraFem" +Kind = "Runtime" +Note = "thumbnail cache" + +[[Leftovers]] +Path = "{ConfigHome}/SaraFem" +Kind = "Config" +Note = "old pre-SaraSuite config location" diff --git a/Programs/SaraFem/Templates/Launch.sh b/Programs/SaraFem/Templates/Launch.sh new file mode 100644 index 0000000..c606b43 --- /dev/null +++ b/Programs/SaraFem/Templates/Launch.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Written by SaraSuite. Runs from the app folder so Flet finds assets/. +cd "{AppDir}" || exit 1 +exec "{Python}" Main.py "$@" diff --git a/Programs/SaraFem/Templates/PortalService.service b/Programs/SaraFem/Templates/PortalService.service new file mode 100644 index 0000000..3c495cb --- /dev/null +++ b/Programs/SaraFem/Templates/PortalService.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.impl.portal.desktop.SaraFem +Exec={AppDir}/Packaging/Launch.sh --portal-service diff --git a/Programs/SaraFem/Templates/SaraFem.desktop b/Programs/SaraFem/Templates/SaraFem.desktop new file mode 100644 index 0000000..0b11492 --- /dev/null +++ b/Programs/SaraFem/Templates/SaraFem.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=SaraFem +Comment=A file manager for the SaraSuite +Exec={AppDir}/Packaging/Launch.sh %f +Icon={AppDir}/assets/Icon.png +Terminal=false +Categories=System;FileManager; +MimeType=inode/directory; +StartupNotify=true diff --git a/Programs/SaraFem/Templates/SaraFem.portal b/Programs/SaraFem/Templates/SaraFem.portal new file mode 100644 index 0000000..ffb0ae5 --- /dev/null +++ b/Programs/SaraFem/Templates/SaraFem.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.SaraFem +Interfaces=org.freedesktop.impl.portal.FileChooser; +UseIn={DesktopName} diff --git a/Programs/SaraSortd/Hooks.py b/Programs/SaraSortd/Hooks.py new file mode 100644 index 0000000..0c3b5a9 --- /dev/null +++ b/Programs/SaraSortd/Hooks.py @@ -0,0 +1,91 @@ +"""Works out what SaraSortd made on its own while running, by reading +GlobalConf.toml the same way SaraSortd.py does (see its Parse/GetConf/Init). + +It never removes the user's files: the input/output/failed folders are only +removed if they're empty once SaraSortd's own files are gone. +""" + +import re +from pathlib import Path + +import tomlkit + +from Manifests import Leftover + + +def _Find(Conf: dict, Key: str): + """SaraSortd's GetConf: a top-level key, or one inside a table/array of tables.""" + if Key in Conf: + return Conf[Key] + for Value in Conf.values(): + if isinstance(Value, dict) and Key in Value: + return Value[Key] + if isinstance(Value, list): + for Item in Value: + if isinstance(Item, dict) and Key in Item: + return Item[Key] + return None + + +def _Resolve(Text: str, Conf: dict) -> str: + """SaraSortd's %Var% substitution (single pass, like its Parse).""" + VarCall = Conf.get("Variables", {}).get("VarCall", "%%") + Pattern = f"{re.escape(VarCall[0])}(.*?){re.escape(VarCall[1])}" + + def Replace(Match: re.Match) -> str: + Value = _Find(Conf, Match[1]) if Match[1] else None + return str(Value) if Value is not None else Match[0] + + return re.sub(Pattern, Replace, Text) + + +def _DirFileName(Conf: dict, NameKey: str, DirPath: Path) -> str: + """e.g. ".@Config" for a folder named Input -> ".InputConfig.toml".""" + Parent = Conf.get("Variables", {}).get("Parent", "@") + Name = Conf.get("Names", {}).get(NameKey, "") + return f"{Name.replace(Parent, DirPath.name)}.toml" if Name else "" + + +def Leftovers(Context: dict) -> list[Leftover]: + ConfPath = Path(Context["ConfigDir"]) / "GlobalConf.toml" + if not ConfPath.exists(): + return [] + try: + Conf = tomlkit.parse(ConfPath.read_text()).unwrap() + except tomlkit.exceptions.TOMLKitError: + return [] + + Paths = Conf.get("DirectoryPaths", {}) + Found: list[Leftover] = [] + + Folders = [] + for Key in ("InputDir", "OutputDir"): + Entries = Paths.get(Key, []) + for Entry in Entries if isinstance(Entries, list) else [Entries]: + Folders.append(Path(_Resolve(str(Entry), Conf))) + + for Folder in Folders: + for NameKey in ("InDirConfName", "OutDirConfName", "DirConfName"): + FileName = _DirFileName(Conf, NameKey, Folder) + if FileName: + Found.append(Leftover(str(Folder / FileName), "Config", Note="this folder's SaraSortd rules")) + HistoryName = _DirFileName(Conf, "FileHistoryName", Folder) + if HistoryName: + Found.append(Leftover(str(Folder / HistoryName), "Runtime", Note="sorting history")) + Found.append(Leftover(str(Folder), "Runtime", "IfEmpty", "only if empty")) + + LogDir = Paths.get("LogDir") + if LogDir: + LogDir = _Resolve(str(LogDir), Conf) + Found.append(Leftover(f"{LogDir}/*.log", "Runtime", Note="log")) + Found.append(Leftover(LogDir, "Runtime", "IfEmpty", "only if empty")) + + FailedDir = Paths.get("FailedDir") + if FailedDir: + Found.append(Leftover(_Resolve(str(FailedDir), Conf), "Runtime", "IfEmpty", "only if empty, it holds files that failed to sort")) + + RootDir = Paths.get("RootDir") + if RootDir and RootDir != "Unset": + Found.append(Leftover(_Resolve(str(RootDir), Conf), "Runtime", "IfEmpty", "only if empty")) + + return Found diff --git a/Programs/SaraSortd/Manifest.toml b/Programs/SaraSortd/Manifest.toml new file mode 100644 index 0000000..332b13a --- /dev/null +++ b/Programs/SaraSortd/Manifest.toml @@ -0,0 +1,51 @@ +Name = "SaraSortd" +Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background as a systemd user service." + +[Version] +File = "SaraSortd.py" +Pattern = '# Version = "([^"]+)"' + +[Source] +Git = "{GitHost}/SaraSortd" +Include = ["SaraSortd.py", "LICENSE", "README.md"] + +[Python] +Requirements = ["toml"] + +[[Questions]] +Id = "RootDir" +Prompt = "Folder SaraSortd sorts in (RootDir)" +Default = "~/Sorting" +Kind = "Path" + +[[Options]] +Id = "Autostart" +Label = "Start SaraSortd now and on every login" +Default = true + +# SaraSortd reads ./GlobalConf.toml and copies ./InputDirConf.toml and +# ./OutputDirConf.toml into its folders, so all three live together in +# ConfigDir and the service runs from there. +[[Configs]] +Source = "GlobalConf.toml" +Target = "{ConfigDir}/GlobalConf.toml" +Set = { "DirectoryPaths.RootDir" = "{RootDir}" } + +[[Configs]] +Source = "InputDirConf.toml" +Target = "{ConfigDir}/InputDirConf.toml" + +[[Configs]] +Source = "OutputDirConf.toml" +Target = "{ConfigDir}/OutputDirConf.toml" + +[[Templates]] +Source = "SaraSortd.service" +Target = "{SystemdUserDir}/SaraSortd.service" + +[[Services]] +Unit = "SaraSortd.service" +Option = "Autostart" + +# Everything SaraSortd makes inside the sorting folders is worked out from +# GlobalConf.toml by Hooks.py. diff --git a/Programs/SaraSortd/Templates/SaraSortd.service b/Programs/SaraSortd/Templates/SaraSortd.service new file mode 100644 index 0000000..6d5c596 --- /dev/null +++ b/Programs/SaraSortd/Templates/SaraSortd.service @@ -0,0 +1,15 @@ +# Written by SaraSuite. Changes here are overwritten on update: +# edit the manifest template in SaraSuite/Programs/SaraSortd/ instead. +[Unit] +Description=SaraSortd file sorting daemon + +[Service] +Type=simple +WorkingDirectory={ConfigDir} +ExecStart={Python} {AppDir}/SaraSortd.py +Environment=PYTHONUNBUFFERED=1 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/README.md b/README.md new file mode 100644 index 0000000..77da349 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# SaraSuite + +Installs, updates and removes Sara programs (SaraSortd, SaraFem, and eventually the SaraView client). +It's one AppImage file: download it, run it, pick a program. + +- **Install**: downloads the program's latest commit from its git repo, copies it into + `~/.local/share/SaraSuite/Programs//` with its own Python venv, places its configs in + `~/.config/SaraSuite//`, and writes its desktop entries, systemd user units, D-Bus + services and so on. +- **Update**: shown when the repo has a newer commit. Program files and integration files are + replaced; configs are *merged*: your edits always win, defaults you never touched follow the + new version, new keys are added (see `ConfigMerge.py`). +- **Remove**: takes out everything the install placed (it's all recorded in a receipt) plus + everything the program made by itself while running. Configs are only removed when + "Remove configs" is ticked. Your own files are never removed: folders that might hold them + are only removed when empty. Files that existed before SaraSuite installed over them are put back. + +## Sources + +Programs come straight from git over HTTPS, no `git` binary needed: the latest commit is read +from `.git/info/refs` and the files from `/archive/.tar.gz`, which GitHub and +Gitea both serve the same way. Repos must be public (no login support yet). + +The host is one setting in `~/.config/SaraSuite/SaraSuite/Config.toml`; manifests refer to it as +`{GitHost}`. It defaults to the Gitea on SpineS (only reachable over Tailscale): + +```toml +GitHost = "http://git.srhtnin.garden/sarah" +``` + +The only thing a program needs from the system is `python3` (for its venv). + +## Running and building + +- AppImage: `Packaging/BuildAppImage.sh` builds `dist/SaraSuite-x86_64.AppImage` + (PyInstaller one-file build + `appimagetool`, like SaraFem's). When run as an AppImage, + the header has an "Add to app menu" button. +- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then + `Packaging/Launch.sh`. +- Without a desktop (e.g. a server), the same file works from a terminal: + + ``` + SaraSuite-x86_64.AppImage list + SaraSuite-x86_64.AppImage install SaraSortd --set RootDir=~/Sorting --option Autostart=on + SaraSuite-x86_64.AppImage update SaraSortd + SaraSuite-x86_64.AppImage remove SaraSortd [--configs] [--keep-runtime] + ``` + +## Where things live + +| Path | What | +| --- | --- | +| `~/.local/share/SaraSuite/Programs//` | installed program (`App/`) and its `.venv/` | +| `~/.local/share/SaraSuite/Receipts/.toml` | everything the install placed | +| `~/.local/share/SaraSuite/Pristine//` | the default configs as last installed (the merge base) | +| `~/.local/share/SaraSuite/Backups//` | files that were moved aside, and configs from before a merge | +| `~/.cache/SaraSuite/Downloads/` | downloaded source, removed after installing | +| `~/.config/SaraSuite//` | the program's configs | + +## Adding a program + +Make `Programs//Manifest.toml`. Templates go in `Programs//Templates/`. +Manifests are bundled into the AppImage, so a new or changed manifest means rebuilding it +(installed programs then show an update, even without a new commit). +Anything the manifest can't describe goes in an optional `Programs//Hooks.py`. + +Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`, +`DataHome`, `CacheHome`, `SystemdUserDir`, `ApplicationsDir`, `DBusServicesDir`, `ProgramDir`, +`AppDir`, `Python` (the venv's python), `ConfigDir`, `DesktopName` (e.g. `COSMIC`), +`Desktop` (e.g. `cosmic`), plus every Question's `Id`. + +```toml +Name = "Example" +Description = "Shown on the program's card." +RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove + +[Version] # optional, shown on the card +File = "Example.py" +Pattern = 'Version = "([^"]+)"' + +[Source] +Git = "{GitHost}/Example" +Branch = "main" # optional, default: the repo's default branch +Include = ["*.py", "assets/**"] # default: everything +Exclude = ["Packaging"] # .git, .venv, build, ... are always excluded + +[Python] +RequirementsFile = "requirements.txt" # or: Requirements = ["toml"] + +[[Questions]] # asked at install time, usable as {RootDir} +Id = "RootDir" +Prompt = "Folder to use" +Default = "~/Somewhere" +Kind = "Path" # expanded to an absolute path + +[[Options]] # checkboxes at install time +Id = "Autostart" +Label = "Start on login" +Default = true + +[[Configs]] # placed once, merged on updates +Source = "Conf.toml" # in the source checkout +Target = "{ConfigDir}/Conf.toml" +Set = { "Paths.RootDir" = "{RootDir}" } + +[[Templates]] # owned by SaraSuite, rewritten on every update +Source = "Example.service" # in Templates/ +Target = "{SystemdUserDir}/Example.service" +Option = "Autostart" # optional: only while the option is ticked +Root = false # true: outside $HOME, written through pkexec +Executable = false + +[[Services]] # systemd --user units, enabled + started +Unit = "Example.service" +Option = "Autostart" + +[[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal +File = "{ConfigHome}/mimeapps.list" +Section = "Default Applications" +Key = "inode/directory" +Value = "Example.desktop" + +[[Leftovers]] # made by the program itself, only touched on removal +Path = "{CacheHome}/Example" # globs allowed +Kind = "Runtime" # or "Config" +Mode = "Remove" # or "IfEmpty" +Note = "thumbnail cache" +``` + +`Hooks.py` can define `Leftovers(Context: dict) -> list[Manifests.Leftover]` for leftovers +that depend on the program's own config (see `Programs/SaraSortd/Hooks.py`). diff --git a/Receipts.py b/Receipts.py new file mode 100644 index 0000000..4ffbf70 --- /dev/null +++ b/Receipts.py @@ -0,0 +1,76 @@ +"""Receipts: the record of exactly what an install placed, so updates know +what they're replacing and removal can take every piece back out.""" + +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import tomlkit + +from Core import ReceiptsDir + + +@dataclass +class PlacedFile: + Path: str + Kind: str # "Template", "Config" or "ConfigNew" (a .new file left beside a config) + Root: bool = False + # Where a file that already existed at Path (not placed by SaraSuite) was + # moved to. It's put back when this program is removed. + Displaced: str = "" + + +@dataclass +class IniRecord: + File: str + Section: str + Key: str + Value: str + HadPrevious: bool = False + Previous: str = "" + + +@dataclass +class Receipt: + Name: str + Version: str = "" + Commit: str = "" + ManifestHash: str = "" + RequirementsHash: str = "" + InstalledAt: str = "" + UpdatedAt: str = "" + # False while an install/update is still running, or if it failed partway. + # The files it lists are still real, so Remove still cleans them up. + Complete: bool = False + Options: dict[str, bool] = field(default_factory=dict) + Answers: dict[str, str] = field(default_factory=dict) + Files: list[PlacedFile] = field(default_factory=list) + IniEdits: list[IniRecord] = field(default_factory=list) + EnabledServices: list[str] = field(default_factory=list) + + @property + def FilePath(self) -> Path: + return ReceiptsDir / f"{self.Name}.toml" + + def FindFile(self, TargetPath: str) -> PlacedFile | None: + return next((File for File in self.Files if File.Path == TargetPath), None) + + def FindIni(self, File: str, Section: str, Key: str) -> IniRecord | None: + return next( + (Edit for Edit in self.IniEdits if (Edit.File, Edit.Section, Edit.Key) == (File, Section, Key)), + None, + ) + + def Save(self) -> None: + ReceiptsDir.mkdir(parents=True, exist_ok=True) + self.FilePath.write_text(tomlkit.dumps(asdict(self))) + + +def LoadReceipt(Name: str) -> Receipt | None: + ReceiptPath = ReceiptsDir / f"{Name}.toml" + if not ReceiptPath.exists(): + return None + Data = tomlkit.parse(ReceiptPath.read_text()).unwrap() + Data["Files"] = [PlacedFile(**Item) for Item in Data.get("Files", [])] + Data["IniEdits"] = [IniRecord(**Item) for Item in Data.get("IniEdits", [])] + Known = set(Receipt.__dataclass_fields__) + return Receipt(**{Key: Value for Key, Value in Data.items() if Key in Known}) diff --git a/Sources.py b/Sources.py new file mode 100644 index 0000000..0f2488c --- /dev/null +++ b/Sources.py @@ -0,0 +1,112 @@ +"""Getting program source from a git host (GitHub now, Gitea later) over +plain HTTPS, standard library only, so the AppImage needs no git binary. + +- The latest commit comes from the smart-HTTP ref advertisement + (.git/info/refs?service=git-upload-pack), which GitHub and Gitea + serve identically, and which is what `git ls-remote` reads too. +- The files come from /archive/.tar.gz, also the same on both. +""" + +import shutil +import tarfile +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import Callable + +from Core import AppName, DownloadsDir + +_Timeout = 20 + + +class SourceError(Exception): + pass + + +def _Get(Url: str) -> bytes: + Request = urllib.request.Request(Url, headers={"User-Agent": f"{AppName} (git/2)"}) + try: + with urllib.request.urlopen(Request, timeout=_Timeout) as Response: + return Response.read() + except urllib.error.HTTPError as Error: + if Error.code in (401, 403, 404): + # Hosts answer 401 rather than 404 for missing repos, so it's + # impossible to tell "private" from "doesn't exist" apart. + raise SourceError(f"{Url.split('.git/info/refs')[0].split('/archive/')[0]} doesn't exist or is private") from Error + raise SourceError(f"Couldn't reach {Url}: {Error}") from Error + except OSError as Error: # URLError and timeouts are OSErrors too + raise SourceError(f"Couldn't reach {Url}: {Error}") from Error + + +def _RepoUrl(GitUrl: str) -> str: + return GitUrl.rstrip("/").removesuffix(".git") + + +def _PktLines(Data: bytes) -> list[bytes]: + """Split git's pkt-line format: 4 hex digits of length (including + themselves), "0000" being a flush packet between sections.""" + Lines, Position = [], 0 + while Position + 4 <= len(Data): + Length = int(Data[Position:Position + 4], 16) + if Length == 0: + Position += 4 + continue + Lines.append(Data[Position + 4:Position + Length].rstrip(b"\n")) + Position += Length + return Lines + + +def LatestCommit(GitUrl: str, Branch: str = "") -> str: + """The commit a branch points at (the repo's default branch if Branch is empty).""" + Data = _Get(f"{_RepoUrl(GitUrl)}.git/info/refs?service=git-upload-pack") + Refs: dict[str, str] = {} + DefaultBranch = "" + for Line in _PktLines(Data): + if Line.startswith(b"#"): + continue + RefPart, _, Capabilities = Line.partition(b"\0") + Sha, _, RefName = RefPart.decode().partition(" ") + if Sha.strip("0"): # an empty repo advertises one all-zero "capabilities^{}" line + Refs[RefName] = Sha + for Capability in Capabilities.decode().split(): + if Capability.startswith("symref=HEAD:"): + DefaultBranch = Capability.removeprefix("symref=HEAD:") + if not Refs: + raise SourceError(f"{_RepoUrl(GitUrl)} is empty (nothing pushed yet)") + Wanted = f"refs/heads/{Branch}" if Branch else (DefaultBranch or "HEAD") + if Wanted not in Refs: + raise SourceError(f"{GitUrl} has no branch {Branch or 'HEAD'}") + return Refs[Wanted] + + +def Download(Name: str, GitUrl: str, Commit: str, Log: Callable[[str], None]) -> Path: + """Extract one commit's files into the download cache (reused if it's + already there) and drop older downloads of the same program.""" + Target = DownloadsDir / f"{Name}-{Commit[:12]}" + if Target.is_dir(): + return Target + + Url = f"{_RepoUrl(GitUrl)}/archive/{Commit}.tar.gz" + Log(f"Downloading {Url}") + DownloadsDir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=DownloadsDir) as Temporary: + Archive = Path(Temporary) / "Source.tar.gz" + Archive.write_bytes(_Get(Url)) + Extracted = Path(Temporary) / "Extracted" + with tarfile.open(Archive) as Tar: + Tar.extractall(Extracted, filter="data") + # Archives wrap everything in one "-/" folder. + Inner = list(Extracted.iterdir()) + Root = Inner[0] if len(Inner) == 1 and Inner[0].is_dir() else Extracted + Root.rename(Target) + + for Old in DownloadsDir.glob(f"{Name}-*"): + if Old != Target: + shutil.rmtree(Old, ignore_errors=True) + return Target + + +def ClearDownloads(Name: str) -> None: + for Old in DownloadsDir.glob(f"{Name}-*"): + shutil.rmtree(Old, ignore_errors=True) diff --git a/assets/Icon.png b/assets/Icon.png new file mode 100644 index 0000000..4084dae Binary files /dev/null and b/assets/Icon.png differ diff --git a/assets/fonts/Fixedsys_Excelsior_Regular.ttf b/assets/fonts/Fixedsys_Excelsior_Regular.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/assets/fonts/Fixedsys_Excelsior_Regular.ttf differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e02737 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flet>=1.0,<2.0 +tomlkit