Self-updates and program list from the repo

This commit is contained in:
sarah committed 2026-10-09 10:38:21 +02:00
1 parent d9da8438c2
commit e4272bd606
9 files changed
+416 -15

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
"""The program catalog: the Programs/ folder (manifests, templates, icons),
fetched from the SaraSuite repo itself, so adding a program or fixing an
install recipe only takes a push, not a new AppImage.
The latest commit of the SaraSuite repo is checked the same way programs
are (Sources.py). When it changed, its Programs/ folder is downloaded into
~/.cache/SaraSuite/Catalog/. LoadPrograms() then reads that, falling back
to the AppImage's bundled copy when there's no catalog yet, when it can't be
loaded, or when running from source: there, the local Programs/ folder
always wins, so unpushed manifest edits work while developing.
"""
import shutil
from pathlib import Path
from typing import Callable, Optional
from Core import BundledManifestsDir, CacheHome, IsFrozen, LoadConfig
from Sources import ClearDownloads, Download, LatestCommit, SourceError
CatalogDir = CacheHome / "SaraSuite" / "Catalog"
_CommitFile = CatalogDir / "Commit"
_DownloadName = "SaraSuiteCatalog"
def ManifestsDir() -> Path:
if IsFrozen and (CatalogDir / "Programs").is_dir():
return CatalogDir / "Programs"
return BundledManifestsDir
def CatalogCommit() -> str:
return _CommitFile.read_text().strip() if _CommitFile.is_file() else ""
def UpdateCatalog(Log: Optional[Callable[[str], None]] = None) -> bool:
"""Fetch the SaraSuite repo's Programs/ if it has a new commit. Returns
True when the catalog changed (the program list should be reloaded).
Does nothing when running from source, and quietly keeps the current
catalog when the git host can't be reached."""
if not IsFrozen:
return False
Log = Log or (lambda Line: None)
Url = f"{LoadConfig().GitHost.rstrip('/')}/SaraSuite"
try:
Commit = LatestCommit(Url)
except SourceError:
return False
if Commit == CatalogCommit():
return False
try:
Source = Download(_DownloadName, Url, Commit, Log) / "Programs"
if not Source.is_dir():
return False
Staging = CatalogDir.with_name("Catalog.new")
shutil.rmtree(Staging, ignore_errors=True)
shutil.copytree(Source, Staging / "Programs", ignore=shutil.ignore_patterns("__pycache__"))
(Staging / "Commit").write_text(Commit)
shutil.rmtree(CatalogDir, ignore_errors=True)
Staging.rename(CatalogDir)
except (SourceError, OSError) as Error:
Log(f"Couldn't update the program list: {Error}")
return False
finally:
ClearDownloads(_DownloadName)
Log(f"Program list updated to SaraSuite commit {Commit[:7]}")
return True
+15
View File
@@ -5,12 +5,16 @@
SaraSuite update <Name>
SaraSuite options <Name> [--option Id=on|off ...] (no --option: list them)
SaraSuite remove <Name> [--configs] [--keep-runtime]
SaraSuite self-update (AppImage only)
Install starts from your defaults (Settings, or the manifest's) unless set.
"""
import argparse
import SelfUpdate
from Catalog import UpdateCatalog
from Core import AppVersion
from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove
from Manifests import LoadPrograms
@@ -45,8 +49,19 @@ def Run(Arguments: list[str]) -> int:
RemoveCommand.add_argument("Name")
RemoveCommand.add_argument("--configs", action="store_true")
RemoveCommand.add_argument("--keep-runtime", action="store_true")
Commands.add_parser("self-update")
Args = Parser.parse_args(Arguments)
if Args.Command == "self-update":
Latest = SelfUpdate.LatestRelease()
if Latest is None or not SelfUpdate.IsNewer(Latest.Version):
print(f"SaraSuite {AppVersion} is the latest.")
return 0
SelfUpdate.Install(Latest, print)
return 0
UpdateCatalog(print) # the program list from the SaraSuite repo (AppImage only)
Programs = {Program.Name.lower(): Program for Program in LoadPrograms()}
if Args.Command == "list":
for Program in Programs.values():
+5 -1
View File
@@ -14,6 +14,10 @@ import flet as ft
import tomlkit
AppName = "SaraSuite"
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
AppVersion = "1.1.0"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage
# Bundled in assets/fonts and always registered, so it works as FontFamily
# without being installed. Any other FontFamily is looked up among the
# system's installed fonts.
@@ -45,7 +49,7 @@ DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archive
# 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"
BundledManifestsDir = BundleDir / "Programs" # the AppImage's own copy; see Catalog.py
AssetsDir = BundleDir / "assets"
+70 -7
View File
@@ -10,9 +10,11 @@ from typing import Callable
import flet as ft
import SelfUpdate
from Catalog import UpdateCatalog
from Core import (
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
DataHome, EffectiveSettings, LoadConfig, ManifestsDir, Palette,
AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
)
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion
@@ -31,7 +33,7 @@ _MenuIcon = DataDir / "SaraSuite.png"
def _LoadFletIdentity():
# Lives in Programs/ (bundled as data) because installed programs get a
# copy of the same file next to them.
Spec = importlib.util.spec_from_file_location("FletIdentity", ManifestsDir / "FletIdentity.py")
Spec = importlib.util.spec_from_file_location("FletIdentity", BundledManifestsDir / "FletIdentity.py")
Module = importlib.util.module_from_spec(Spec)
Spec.loader.exec_module(Module)
return Module
@@ -85,9 +87,12 @@ def Main(Page: ft.Page) -> None:
del LogView.controls[: len(LogView.controls) - _MaxLogLines]
LogView.update()
# Both lists are changed in place when the program list is updated from
# the SaraSuite repo (Catalog.py), so everything holding them stays valid.
Programs = LoadPrograms()
Cards: list[ProgramCard] = []
State = {"Busy": False}
CardList = ft.ListView(Cards, expand=True, spacing=12, padding=16)
State = {"Busy": False, "Release": None}
def RefreshCard(Card: ProgramCard) -> None:
try:
@@ -99,17 +104,70 @@ def Main(Page: ft.Page) -> None:
Card.ShowStatus(Current, Leftovers, State["Busy"])
Card.update()
def BuildCards() -> None:
Cards[:] = [ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions) for Program in Programs]
CardList.controls = Cards
def RefreshAll() -> None:
# A new program list from the SaraSuite repo first, then every
# program's status, then whether there's a newer SaraSuite.
if UpdateCatalog(Log):
Programs[:] = LoadPrograms()
BuildCards()
CardList.update()
for Card in Cards:
RefreshCard(Card)
CheckSelfUpdate()
def CanSelfUpdate() -> bool:
Latest = State["Release"]
return Latest is not None and bool(Latest.AssetUrl) and SelfUpdate.RunningAppImage() is not None
def CheckSelfUpdate() -> None:
Latest = SelfUpdate.LatestRelease()
State["Release"] = Latest if Latest is not None and SelfUpdate.IsNewer(Latest.Version) else None
if State["Release"] is None:
SelfUpdateButton.visible = False
else:
Version = State["Release"].Version.lstrip("v")
# Only an AppImage can replace itself; from source, just say so.
SelfUpdateButton.visible = True
SelfUpdateButton.disabled = not CanSelfUpdate()
SelfUpdateButton.content = f"Update SaraSuite to {Version}"
SelfUpdateButton.tooltip = None if not SelfUpdateButton.disabled else "Running from source: pull and rebuild instead"
SelfUpdateButton.update()
def OnSelfUpdate(e) -> None:
Latest = State["Release"]
if Latest is None:
return
def Work() -> None:
SelfUpdate.Install(Latest, Log)
Page.show_dialog(ft.AlertDialog(
title="SaraSuite updated",
content=ft.Text(f"SaraSuite {Latest.Version.lstrip('v')} is installed. Restart now to use it?"),
actions=[
ft.OutlinedButton(content="Later", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()),
ft.OutlinedButton(content="Restart", style=ChipButtonStyle(), on_click=lambda e: Restart()),
],
))
def Restart() -> None:
SelfUpdate.Restart()
Page.run_task(Page.window.destroy)
RunOperation(None, Work)
def SetBusy(Busy: bool, Working: ProgramCard | None = None) -> None:
State["Busy"] = Busy
RefreshButton.disabled = Busy
SelfUpdateButton.disabled = Busy or not CanSelfUpdate()
for Card in Cards:
Card.SetBusy(Busy, Card is Working)
Card.update()
RefreshButton.update()
SelfUpdateButton.update()
def RunOperation(Card: ProgramCard | None, Work: Callable[[], None]) -> None:
"""Run Work in a background thread with every card locked, then
@@ -180,8 +238,7 @@ def Main(Page: ft.Page) -> None:
),
)
for Program in Programs:
Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions))
BuildCards()
def ToggleMenuEntry(e) -> None:
_SetMenuEntry(not _MenuEntry.exists())
@@ -195,6 +252,10 @@ def Main(Page: ft.Page) -> None:
on_click=ToggleMenuEntry,
)
SelfUpdateButton = ft.OutlinedButton(
content="Update SaraSuite", style=ChipButtonStyle(), visible=False, on_click=OnSelfUpdate,
)
RefreshButton = ft.IconButton(
icon=ft.Icons.REFRESH,
icon_color=Palette.Mauve,
@@ -217,7 +278,9 @@ def Main(Page: ft.Page) -> None:
ft.Container(
content=ft.Row([
ft.Text(AppName, size=24, color=Palette.Mauve),
ft.Text(AppVersion, size=12, color=Palette.Overlay0),
ft.Container(expand=True),
SelfUpdateButton,
MenuButton,
RefreshButton,
SettingsButton,
@@ -226,7 +289,7 @@ def Main(Page: ft.Page) -> None:
bgcolor=Palette.Mantle,
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
),
ft.ListView(Cards, expand=True, spacing=12, padding=16),
CardList,
ft.Container(
content=LogView,
height=200,
+15 -7
View File
@@ -11,7 +11,7 @@ from typing import Optional
import tomlkit
from Core import (
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ManifestsDir, ProgramsDir,
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir,
SharedConfigDir,
)
@@ -230,14 +230,22 @@ def LoadProgram(Folder: Path) -> Program:
)
def _LoadFrom(Folder: Path) -> list[Program]:
return [LoadProgram(Child) for Child in sorted(Folder.iterdir()) if (Child / "Manifest.toml").exists()]
def LoadPrograms() -> list[Program]:
"""Alphabetical, with each program's children (Parent = its Name) right
after it. A child whose parent isn't there keeps its own place."""
Loaded = [
LoadProgram(Folder)
for Folder in sorted(ManifestsDir.iterdir())
if (Folder / "Manifest.toml").exists()
]
after it. A child whose parent isn't there keeps its own place.
Read from the catalog fetched from the SaraSuite repo (Catalog.py) when
there is one, falling back to the bundled copy if that can't be loaded
(e.g. it uses a manifest feature this SaraSuite doesn't know yet)."""
from Catalog import BundledManifestsDir, ManifestsDir
try:
Loaded = _LoadFrom(ManifestsDir())
except Exception:
Loaded = _LoadFrom(BundledManifestsDir)
Names = {Current.Name for Current in Loaded}
Ordered = []
for Current in Loaded:
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage, create the Gitea release
v<AppVersion> (from Core.py) with it attached, and copy it to the Shared
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py).
Packaging/Release.py # after bumping AppVersion in Core.py
Packaging/Release.py --notes "What changed"
Everything must be committed and pushed first, so the release's tag points
at code that's actually on Gitea. The Gitea token comes from git's own
credential store (the same one `git push` uses), so there's nothing to set up.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from urllib.parse import urlsplit
RepoDir = Path(__file__).resolve().parent.parent
AppImage = RepoDir / "dist" / "SaraSuite-x86_64.AppImage"
SharedDir = Path("/mnt/files/Shared/SaraSuite")
def Git(*Args: str) -> str:
return subprocess.run(["git", *Args], cwd=RepoDir, capture_output=True, text=True, check=True).stdout.strip()
def Fail(Message: str) -> None:
sys.exit(f"Release.py: {Message}")
def Token(Host: str) -> str:
Result = subprocess.run(
["git", "credential", "fill"], input=f"protocol=https\nhost={Host}\n\n",
capture_output=True, text=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
Found = re.search(r"^password=(.+)$", Result.stdout, re.MULTILINE)
if not Found:
Fail(f"no Gitea token for {Host} in git's credential store; push once so git saves it")
return Found[1]
def Api(Method: str, Url: str, TokenValue: str, Body: bytes | None = None, ContentType: str = "application/json"):
Request = urllib.request.Request(Url, data=Body, method=Method, headers={
"Authorization": f"token {TokenValue}", "Content-Type": ContentType, "Accept": "application/json",
})
try:
with urllib.request.urlopen(Request, timeout=300) as Response:
return json.loads(Response.read() or b"null")
except urllib.error.HTTPError as Error:
if Error.code == 404:
return None
Fail(f"{Method} {Url} failed: {Error.code} {Error.read().decode(errors='replace')[:300]}")
def Main() -> None:
Parser = argparse.ArgumentParser(description="Publish a SaraSuite release on Gitea.")
Parser.add_argument("--notes", default="", help="release notes (default: the commits since the last release)")
Args = Parser.parse_args()
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
Tag = f"v{Version}"
# origin is e.g. https://git.srhtnin.garden/sarah/SaraSuite.git
Origin = urlsplit(Git("remote", "get-url", "origin"))
Owner, Repo = Origin.path.strip("/").removesuffix(".git").split("/")[-2:]
ApiBase = f"{Origin.scheme}://{Origin.netloc}/api/v1/repos/{Owner}/{Repo}"
if Git("status", "--porcelain"):
Fail("there are uncommitted changes; commit (and push) first")
Branch = Git("rev-parse", "--abbrev-ref", "HEAD")
Git("fetch", "--quiet", "--tags", "origin")
Head = Git("rev-parse", "HEAD")
if Head != Git("rev-parse", f"origin/{Branch}"):
Fail(f"{Branch} isn't pushed (or is behind origin); push first")
TokenValue = Token(Origin.netloc)
if Api("GET", f"{ApiBase}/releases/tags/{Tag}", TokenValue) is not None:
Fail(f"{Tag} is already released; bump AppVersion in Core.py first")
Notes = Args.notes
if not Notes:
Previous = Git("tag", "--list", "v*", "--sort=-v:refname").splitlines()
Range = f"{Previous[0]}..HEAD" if Previous else "HEAD"
Notes = Git("log", "--format=- %s", Range) or "- First release with self-updates."
print(f"== Building SaraSuite {Version}")
subprocess.run(["Packaging/BuildAppImage.sh"], cwd=RepoDir, check=True,
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
print(f"== Creating release {Tag}")
Created = Api("POST", f"{ApiBase}/releases", TokenValue, json.dumps({
"tag_name": Tag, "target_commitish": Head, "name": f"SaraSuite {Version}", "body": Notes,
}).encode())
print(f"== Uploading {AppImage.name} ({AppImage.stat().st_size // 1_000_000} MB)")
Boundary = uuid.uuid4().hex
Body = (
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{AppImage.name}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + AppImage.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={AppImage.name}", TokenValue, Body,
f"multipart/form-data; boundary={Boundary}")
if SharedDir.is_dir():
shutil.copy2(AppImage, SharedDir / AppImage.name)
print(f"== Copied to {SharedDir}")
Git("fetch", "--quiet", "--tags", "origin") # the new tag, for the next release's notes
print(f"== Released {Tag}: {Origin.scheme}://{Origin.netloc}/{Owner}/{Repo}/releases/tag/{Tag}")
if __name__ == "__main__":
Main()
+17
View File
@@ -30,6 +30,23 @@ GitHost = "https://git.srhtnin.garden/sarah"
The only thing a program needs from the system is `python3` (for its venv).
## Updates
- **Programs** update from their own repos (see Sources above).
- **The program list** (`Programs/`: manifests, templates, icons) is fetched from this repo
whenever it has a new commit, so adding a program or fixing a recipe only takes a push. It's
cached in `~/.cache/SaraSuite/Catalog/`; the AppImage's bundled copy is the fallback. Running
from source always uses the local `Programs/`.
- **SaraSuite itself** checks this repo's latest Gitea release. When it's newer than its own
`AppVersion`, an "Update SaraSuite" button downloads the new AppImage, swaps it in place (same
path, so the app menu entry keeps working) and restarts. From a terminal:
`SaraSuite-x86_64.AppImage self-update`.
To publish a new SaraSuite: bump `AppVersion` in `Core.py`, commit and push, then run
`Packaging/Release.py` (optionally `--notes "..."`). It builds the AppImage, creates the release
`v<AppVersion>` with the AppImage attached, and copies it to the Shared folder. The Gitea token
comes from git's credential store, the same one `git push` uses.
## Shared settings
Settings every SaraSuite program shares live in `~/.config/SaraSuite/Config.toml`:
+93
View File
@@ -0,0 +1,93 @@
"""SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage attached. Here SaraSuite asks Gitea for the latest release, and if
it's newer than AppVersion and SaraSuite runs as an AppImage, downloads it
next to the running one and renames it into place (allowed while it's
running), then restarts into it. The AppImage keeps its path, so its app
menu entry keeps working.
"""
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlsplit
from Core import AppName, AppVersion, ChildEnvironment, LoadConfig
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}-x86_64.AppImage"
@dataclass
class Release:
Version: str
AssetUrl: str # empty if the release has no AppImage attached
def RunningAppImage() -> Optional[Path]:
Value = os.environ.get("APPIMAGE", "")
return Path(Value) if Value else None
def _VersionTuple(Version: str) -> tuple[int, ...]:
Parts = []
for Part in Version.strip().lstrip("vV").split("."):
Digits = "".join(Char for Char in Part if Char.isdigit())
Parts.append(int(Digits) if Digits else 0)
return tuple(Parts)
def IsNewer(Version: str) -> bool:
return _VersionTuple(Version) > _VersionTuple(AppVersion)
def LatestRelease() -> Optional[Release]:
"""The latest published release, or None if there's none or the git host
can't be reached. GitHost looks like https://host/owner; the API lives at
https://host/api/v1."""
Parts = urlsplit(LoadConfig().GitHost.rstrip("/"))
Owner = Parts.path.strip("/").split("/")[-1]
Url = f"{Parts.scheme}://{Parts.netloc}/api/v1/repos/{Owner}/{AppName}/releases/latest"
try:
Data = GetJson(Url)
except SourceError:
return None
Asset = next((Item for Item in Data.get("assets", []) if Item.get("name") == AssetName), None)
return Release(Data.get("tag_name", ""), Asset.get("browser_download_url", "") if Asset else "")
def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
"""Replace the running AppImage with Latest's. Raises OSError/SourceError."""
Target = RunningAppImage()
if Target is None:
raise OSError("SaraSuite isn't running as an AppImage, so it can't replace itself.")
if not Latest.AssetUrl:
raise OSError(f"Release {Latest.Version} has no {AssetName} attached.")
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
Log(f"Downloading {Latest.AssetUrl}")
Data = GetBytes(Latest.AssetUrl)
if not Data.startswith(b"\x7fELF"):
raise OSError("The download isn't an AppImage; leaving the current one alone.")
# Same folder, so the rename is atomic; renaming over a running AppImage
# is fine (overwriting it in place isn't: "Text file busy").
Temporary = Target.with_name(Target.name + ".new")
Temporary.write_bytes(Data)
Temporary.chmod(0o755)
Temporary.replace(Target)
Log(f"== {AppName} updated; it's the new version from the next start")
return Target
def Restart() -> None:
"""Start the (new) AppImage; the caller then closes this one."""
Target = RunningAppImage()
if Target is None:
return
Environment = ChildEnvironment()
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): # the new AppImage sets its own
Environment.pop(Key, None)
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+14
View File
@@ -40,6 +40,20 @@ def _Get(Url: str) -> bytes:
raise SourceError(f"Couldn't reach {Url}: {Error}") from Error
def GetBytes(Url: str) -> bytes:
return _Get(Url)
def GetJson(Url: str):
"""A JSON API response (e.g. Gitea's latest release), or SourceError."""
import json
try:
return json.loads(_Get(Url))
except ValueError as Error:
raise SourceError(f"{Url} didn't return JSON: {Error}") from Error
def _RepoUrl(GitUrl: str) -> str:
return GitUrl.rstrip("/").removesuffix(".git")