"""SaraSuite updating itself from its Gitea releases. Packaging/Release.py publishes a release (tag v) with the AppImage and SaraSuite.exe attached (and SaraSuite-macOS.zip, once there's a Mac build). Here SaraSuite asks Gitea for the latest release, and if it's newer than AppVersion and SaraSuite runs packaged (AppImage / .exe), downloads its own platform's file and swaps it in, then restarts into it. The file keeps its path, so its menu entry keeps working. - Linux: the download is renamed over the running AppImage (allowed). - Windows: a running .exe can't be overwritten but can be renamed, so the old one becomes SaraSuite.old.exe (deleted on the next start). - macOS: SaraSuite.app (a folder) comes zipped; it's unpacked next to the running one, which is renamed aside and deleted once the new one is in place. """ import os import shutil import subprocess import tempfile import sys from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional from urllib.parse import urlsplit from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig from Sources import GetBytes, GetJson, SourceError AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage" @dataclass class Release: Version: str AssetUrl: str # empty if the release has no AppImage attached def RunningAppImage() -> Optional[Path]: """The packaged SaraSuite file that's running: the AppImage on Linux, the .exe on Windows, the SaraSuite.app bundle on macOS. None when running from source.""" if IsWindows: return Path(sys.executable) if IsFrozen else None if IsMac: # .../SaraSuite.app/Contents/MacOS/SaraSuite Bundle = Path(sys.executable).resolve().parents[2] return Bundle if IsFrozen and Bundle.suffix == ".app" else None Value = os.environ.get("APPIMAGE", "") return Path(Value) if Value else None def CleanUpOldExe() -> None: """Windows: delete the .exe a previous update renamed aside.""" Running = RunningAppImage() if IsWindows and Running is not None: Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True) def _InstallMacBundle(Target: Path, Data: bytes) -> None: """Unpack the zipped SaraSuite.app next to the running one (same volume, so the renames are instant), then swap the two. ditto keeps what a Mac app needs: permissions, symlinks inside its frameworks, its signature.""" with tempfile.TemporaryDirectory(dir=Target.parent, prefix=".SaraSuite-update-") as Temporary: Archive = Path(Temporary) / AssetName Archive.write_bytes(Data) Unpacked = Path(Temporary) / "Unpacked" subprocess.run(["ditto", "-x", "-k", str(Archive), str(Unpacked)], check=True, capture_output=True) New = next(Unpacked.glob("*.app"), None) if New is None: raise OSError(f"{AssetName} has no app in it; leaving the current one alone.") Old = Target.with_name(f"{Target.stem}.old.app") shutil.rmtree(Old, ignore_errors=True) Target.rename(Old) New.rename(Target) shutil.rmtree(Old, ignore_errors=True) 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) AssetUrl = Asset.get("browser_download_url", "") if Asset else "" # Gitea can hand out http:// download links; never fetch an executable # over plain HTTP when the git host itself is HTTPS. if Parts.scheme == "https" and AssetUrl.startswith("http://"): AssetUrl = "https://" + AssetUrl.removeprefix("http://") return Release(Data.get("tag_name", ""), AssetUrl) 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"MZ" if IsWindows else b"PK" if IsMac else b"\x7fELF"): raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.") if IsMac: try: _InstallMacBundle(Target, Data) except subprocess.CalledProcessError as Error: raise OSError(f"Couldn't unpack {AssetName}: {Error.stderr.decode(errors='replace').strip()}") from Error Log(f"== {AppName} updated; it's the new version from the next start") return Target Temporary = Target.with_name(Target.name + ".new") Temporary.write_bytes(Data) if IsWindows: # A running .exe can't be replaced, but it can be renamed out of the way. Old = Target.with_name(f"{Target.stem}.old.exe") Old.unlink(missing_ok=True) Target.rename(Old) Temporary.rename(Target) else: # Same folder, so the rename is atomic; renaming over a running AppImage # is fine (overwriting it in place isn't: "Text file busy"). 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", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"): Environment.pop(Key, None) # the new one sets its own if IsMac: # -n: a new instance, even though this one is still running. subprocess.Popen(["open", "-n", str(Target)], env=Environment, start_new_session=True) elif IsWindows: Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours subprocess.Popen([str(Target)], env=Environment, close_fds=True, creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)) else: subprocess.Popen([str(Target)], env=Environment, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)