mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
macOS support (code only, no build yet)
This commit is contained in:
1 parent
1feb6d4336
commit
ca04dcd4a7
17 files changed
+572
-126
No files matched your search
+45
-6
@@ -1,27 +1,32 @@
|
||||
"""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
|
||||
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, IsWindows, LoadConfig
|
||||
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}-x86_64.AppImage"
|
||||
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,9 +37,14 @@ class Release:
|
||||
|
||||
def RunningAppImage() -> Optional[Path]:
|
||||
"""The packaged SaraSuite file that's running: the AppImage on Linux, the
|
||||
.exe on Windows. None when running from source."""
|
||||
.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
|
||||
|
||||
@@ -46,6 +56,25 @@ def CleanUpOldExe() -> 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("."):
|
||||
@@ -88,8 +117,15 @@ def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
|
||||
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"):
|
||||
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:
|
||||
@@ -115,7 +151,10 @@ def Restart() -> None:
|
||||
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:
|
||||
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))
|
||||
|
||||
Reference in new issue
Block a user