diff --git a/Catalog.py b/Catalog.py new file mode 100644 index 0000000..acf0729 --- /dev/null +++ b/Catalog.py @@ -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 diff --git a/Cli.py b/Cli.py index 51169df..5fff397 100644 --- a/Cli.py +++ b/Cli.py @@ -5,12 +5,16 @@ SaraSuite update SaraSuite options [--option Id=on|off ...] (no --option: list them) SaraSuite remove [--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(): diff --git a/Core.py b/Core.py index 923f820..abb0521 100644 --- a/Core.py +++ b/Core.py @@ -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" diff --git a/Main.py b/Main.py index dbc1021..2481a94 100644 --- a/Main.py +++ b/Main.py @@ -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, diff --git a/Manifests.py b/Manifests.py index f2d0edd..c92aa9a 100644 --- a/Manifests.py +++ b/Manifests.py @@ -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: diff --git a/Packaging/Release.py b/Packaging/Release.py new file mode 100755 index 0000000..8850375 --- /dev/null +++ b/Packaging/Release.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Publish a SaraSuite release: build the AppImage, create the Gitea release +v (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() diff --git a/README.md b/README.md index 2193a6e..67cfd5a 100644 --- a/README.md +++ b/README.md @@ -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` 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`: diff --git a/SelfUpdate.py b/SelfUpdate.py new file mode 100644 index 0000000..d2de97b --- /dev/null +++ b/SelfUpdate.py @@ -0,0 +1,93 @@ +"""SaraSuite updating itself from its Gitea releases. + +Packaging/Release.py publishes a release (tag v) 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) diff --git a/Sources.py b/Sources.py index f081a0a..389319f 100644 --- a/Sources.py +++ b/Sources.py @@ -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")