"""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)