mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
Initial commit
This commit is contained in:
commit
bea0f43947
25 files changed
+2327
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.flet/
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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)"))
|
||||||
@@ -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
@@ -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()
|
||||||
|
]
|
||||||
Executable
+37
@@ -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"
|
||||||
Executable
+6
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 933 B |
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
flet>=1.0,<2.0
|
||||||
|
tomlkit
|
||||||
Reference in new issue
Block a user