macOS support (code only, no build yet)

This commit is contained in:
sarah committed 2026-10-14 13:18:20 +02:00
1 parent 1feb6d4336
commit ca04dcd4a7
17 files changed
+572 -126

No files matched your search

+45 -6
View File
@@ -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))