Initial commit

This commit is contained in:
sarah committed 2026-09-24 22:04:04 +02:00
commit bea0f43947
25 files changed
+2327

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
build/
dist/
.flet/
+70
View File
@@ -0,0 +1,70 @@
"""Command-line mode, for machines without a desktop (e.g. a Fedora Server box):
SaraSuite list
SaraSuite install <Name> [--set Id=Value ...] [--option Id=on|off ...]
SaraSuite update <Name>
SaraSuite remove <Name> [--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
+126
View File
@@ -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)
+182
View File
@@ -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/<ThemeName>.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 <desktop>-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),
)
+143
View File
@@ -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,
))
+641
View File
@@ -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)"))
+211
View File
@@ -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))
+220
View File
@@ -0,0 +1,220 @@
"""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()
]
+37
View File
@@ -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"
+6
View File
@@ -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 "$@"
+119
View File
@@ -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
+67
View File
@@ -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"
+4
View File
@@ -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 "$@"
@@ -0,0 +1,3 @@
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.SaraFem
Exec={AppDir}/Packaging/Launch.sh --portal-service
@@ -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
@@ -0,0 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.SaraFem
Interfaces=org.freedesktop.impl.portal.FileChooser;
UseIn={DesktopName}
+91
View File
@@ -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
+51
View File
@@ -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.
@@ -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
+131
View File
@@ -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/<Name>/` with its own Python venv, places its configs in
`~/.config/SaraSuite/<Name>/`, 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 `<repo>.git/info/refs` and the files from `<repo>/archive/<commit>.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/<Name>/` | installed program (`App/`) and its `.venv/` |
| `~/.local/share/SaraSuite/Receipts/<Name>.toml` | everything the install placed |
| `~/.local/share/SaraSuite/Pristine/<Name>/` | the default configs as last installed (the merge base) |
| `~/.local/share/SaraSuite/Backups/<Name>/` | files that were moved aside, and configs from before a merge |
| `~/.cache/SaraSuite/Downloads/` | downloaded source, removed after installing |
| `~/.config/SaraSuite/<Name>/` | the program's configs |
## Adding a program
Make `Programs/<Name>/Manifest.toml`. Templates go in `Programs/<Name>/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/<Name>/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`).
+76
View File
@@ -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})
+112
View File
@@ -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
(<repo>.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 <repo>/archive/<commit>.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 "<repo>-<commit>/" 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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
flet>=1.0,<2.0
tomlkit