Files
SaraSuite/SelfUpdate.py
T
2026-10-09 13:59:08 +02:00

125 lines
5.1 KiB
Python

"""SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage and SaraSuite.exe attached. 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).
"""
import os
import subprocess
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, IsWindows, LoadConfig
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}.exe" if IsWindows 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. None when running from source."""
if IsWindows:
return Path(sys.executable) if IsFrozen 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 _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"\x7fELF"):
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
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 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)