Files
SaraSuite/SelfUpdate.py
T

94 lines
3.5 KiB
Python

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