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