mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
99 lines
3.8 KiB
Python
99 lines
3.8 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)
|
|
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"\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)
|