SaraSuite.app for macOS, built on Linux; 1.3.0

This commit is contained in:
sarah committed 2026-10-14 13:38:03 +02:00
1 parent ca04dcd4a7
commit 873a2d9ba0
5 files changed
+246 -18

No files matched your search

+6 -3
View File
@@ -27,8 +27,11 @@ import tomlkit
AppName = "SaraSuite"
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
AppVersion = "1.2.5"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
AppVersion = "1.3.0"
# SaraSuite.app's launcher sets this to the app's path (Packaging/BuildMacApp.py).
MacApp = os.environ.get("SARASUITE_APP", "")
# Running packaged: from the AppImage / .exe (PyInstaller), or SaraSuite.app.
IsFrozen = bool(getattr(sys, "frozen", False)) or bool(MacApp)
IsWindows = sys.platform == "win32"
IsMac = sys.platform == "darwin"
IsLinux = not IsWindows and not IsMac
@@ -270,7 +273,7 @@ def ChildEnvironment() -> dict[str, str]:
Environment["LD_LIBRARY_PATH"] = Original
else:
Environment.pop("LD_LIBRARY_PATH", None)
for Key in ("PYTHONHOME", "PYTHONPATH"):
for Key in ("PYTHONHOME", "PYTHONPATH", "SARASUITE_APP"):
Environment.pop(Key, None)
if IsWindows:
# Python on Windows otherwise reads/writes text in the old ANSI code
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Build dist/SaraSuite-macOS.zip (SaraSuite.app, Apple Silicon) on Linux.
PyInstaller can only build a Mac app on a Mac, so this puts the app together
from parts that need no compiling:
- a standalone Python for macOS (python-build-standalone, the Pythons uv installs);
- SaraSuite's requirements as macOS wheels (pip download for the Mac platform);
- the Flet desktop client for macOS, bundled where flet-desktop looks for it,
so the first start needs no download;
- SaraSuite's own code, assets and Programs/;
- Contents/MacOS/SaraSuite, a shell script that runs Main.py with that Python.
The app isn't signed by an Apple developer account, so the first start needs
System Settings > Privacy & Security > Open Anyway (see the website's
instructions). Its launcher then clears the download quarantine from the
whole app, so the Python and Flet inside it run too.
Packaging/BuildMacApp.py # also run by Packaging/Release.py
"""
import io
import json
import os
import plistlib
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import time
import urllib.request
import zipfile
from pathlib import Path
RepoDir = Path(__file__).resolve().parent.parent
BuildDir = RepoDir / "build" / "MacBuild"
DownloadsDir = BuildDir / "Downloads" # kept between builds
BuildVenv = BuildDir / "Venv" # Pillow, for the .icns
Output = RepoDir / "dist" / "SaraSuite-macOS.zip"
PythonVersion = "3.14"
MacPlatform = "macosx_11_0_arm64"
PythonTarget = "aarch64-apple-darwin"
StandaloneReleases = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
AppIdentifier = "srhtnin.SaraSuite"
UserAgent = "SaraSuite-Build (Python)"
# The launcher won't run from the read-only copy macOS makes of a quarantined
# app that wasn't moved out of Downloads (App Translocation): neither clearing
# the quarantine nor self-updating would work there.
Launcher = '''#!/bin/sh
# SaraSuite.app's launcher (written by Packaging/BuildMacApp.py).
Contents="$(cd "$(dirname "$0")/.." && pwd -P)"
App="$(dirname "$Contents")"
case "$App" in
*/AppTranslocation/*)
/usr/bin/osascript -e 'display alert "Move SaraSuite to Applications first" message "Drag SaraSuite from your Downloads folder into Applications, then open it from there."' >/dev/null 2>&1
exit 1 ;;
esac
# Downloaded apps are quarantined, the Python and Flet inside included. Once
# macOS has let SaraSuite itself open, the rest of it may run too.
/usr/bin/xattr -dr com.apple.quarantine "$App" 2>/dev/null
export SARASUITE_APP="$App"
exec "$Contents/Resources/Python/bin/python3" "$Contents/Resources/SaraSuite/Main.py" "$@"
'''
def Download(Url: str, Name: str) -> Path:
"""Into DownloadsDir, once."""
Target = DownloadsDir / Name
if Target.is_file():
return Target
print(f"Downloading {Url}")
DownloadsDir.mkdir(parents=True, exist_ok=True)
Request = urllib.request.Request(Url, headers={"User-Agent": UserAgent})
with urllib.request.urlopen(Request, timeout=600) as Response:
Partial = Target.with_suffix(".part")
Partial.write_bytes(Response.read())
Partial.replace(Target)
return Target
def StandalonePython() -> Path:
"""The latest python-build-standalone release of PythonVersion for Apple Silicon."""
Request = urllib.request.Request(StandaloneReleases, headers={"User-Agent": UserAgent})
with urllib.request.urlopen(Request, timeout=60) as Response:
Assets = json.load(Response)["assets"]
Pattern = re.compile(rf"cpython-{re.escape(PythonVersion)}\.\d+\+\d+-{PythonTarget}-install_only_stripped\.tar\.gz")
Asset = next((Item for Item in Assets if Pattern.fullmatch(Item["name"])), None)
if Asset is None:
sys.exit(f"BuildMacApp.py: no Python {PythonVersion} for {PythonTarget} in the latest python-build-standalone release")
return Download(Asset["browser_download_url"], Asset["name"])
def MakeIcns(Png: Path, Icns: Path) -> None:
"""Same as MacSupport.MakeIcns: whole multiples of the 32x32 art grid."""
from PIL import Image
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
def CopyCode(Destination: Path) -> None:
Ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
Destination.mkdir(parents=True)
for Source in sorted(RepoDir.glob("*.py")):
shutil.copy2(Source, Destination / Source.name)
shutil.copytree(RepoDir / "assets", Destination / "assets", ignore=Ignore)
shutil.copytree(RepoDir / "Programs", Destination / "Programs", ignore=Ignore)
def Zip(Bundle: Path, Target: Path) -> None:
"""Zip the app keeping what a Mac app needs: execute bits and symlinks
(Python's bin/python3 is one), the way Finder's Archive Utility and
ditto read them back."""
Target.parent.mkdir(parents=True, exist_ok=True)
Partial = Target.with_suffix(".zip.new")
with zipfile.ZipFile(Partial, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as Archive:
for Current in [Bundle, *sorted(Bundle.rglob("*"))]:
Name = Current.relative_to(Bundle.parent).as_posix()
Mode = Current.lstat().st_mode
Stamp = time.localtime(max(Current.lstat().st_mtime, 315532800))[:6] # zip dates start in 1980
if Current.is_symlink():
Info = zipfile.ZipInfo(Name, Stamp)
Info.create_system = 3 # Unix, so the mode below counts
Info.external_attr = (stat.S_IFLNK | 0o755) << 16
Archive.writestr(Info, os.readlink(Current))
elif Current.is_dir():
Info = zipfile.ZipInfo(Name + "/", Stamp)
Info.create_system = 3
Info.external_attr = (stat.S_IFDIR | 0o755) << 16 | 0x10
Archive.writestr(Info, b"")
else:
Info = zipfile.ZipInfo.from_file(Current, Name)
Info.create_system = 3
Info.external_attr = (stat.S_IFREG | (0o755 if Mode & 0o111 else 0o644)) << 16
Info.compress_type = zipfile.ZIP_DEFLATED
Archive.writestr(Info, Current.read_bytes())
Partial.replace(Target)
def Main() -> None:
try:
import PIL # noqa: F401
except ImportError:
# Pillow for the icon: run this script again from a build venv that has it.
if not (BuildVenv / "bin" / "python").exists():
subprocess.run([sys.executable, "-m", "venv", str(BuildVenv)], check=True)
subprocess.run([str(BuildVenv / "bin" / "python"), "-m", "pip", "install", "--quiet", "pillow"], check=True)
os.execv(BuildVenv / "bin" / "python", [str(BuildVenv / "bin" / "python"), __file__, *sys.argv[1:]])
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
Stage = BuildDir / "Stage"
shutil.rmtree(Stage, ignore_errors=True)
Bundle = Stage / "SaraSuite.app"
Contents = Bundle / "Contents"
Resources = Contents / "Resources"
(Contents / "MacOS").mkdir(parents=True)
Resources.mkdir()
# Python, unpacked as Resources/Python.
with tarfile.open(StandalonePython()) as Tar:
Tar.extractall(Resources, filter="tar")
(Resources / "python").rename(Resources / "Python")
SitePackages = next((Resources / "Python" / "lib").glob("python3.*")) / "site-packages"
Minor = SitePackages.parent.name.removeprefix("python")
# SaraSuite's requirements as macOS wheels, straight into that Python.
# (Pillow and flet-desktop are only for macOS, so they're named here: pip
# judges requirements.txt's markers by this Linux machine.)
Requirements = [Line for Line in (RepoDir / "requirements.txt").read_text().splitlines()
if Line.strip() and not Line.startswith("#") and "sys_platform" not in Line]
print("Installing the macOS wheels")
subprocess.run([
sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "--no-compile",
"--target", str(SitePackages), "--platform", MacPlatform, "--python-version", Minor,
"--implementation", "cp", "--abi", f"cp{Minor.replace('.', '')}", "--only-binary=:all:",
*Requirements, "pillow", "flet-desktop",
], check=True)
FletVersion = re.search(r'version = "([^"]+)"', (SitePackages / "flet_desktop" / "version.py").read_text())[1]
Wanted = re.search(r"^Version: (.+)$", next(SitePackages.glob("flet-*.dist-info/METADATA")).read_text(), re.MULTILINE)[1]
if FletVersion != Wanted:
sys.exit(f"BuildMacApp.py: flet {Wanted} but flet-desktop {FletVersion}")
# The Flet client, where flet-desktop looks for a bundled one first.
Client = Download(f"https://github.com/flet-dev/flet/releases/download/v{FletVersion}/flet-macos.tar.gz",
f"flet-macos-{FletVersion}.tar.gz")
(SitePackages / "flet_desktop" / "app").mkdir(exist_ok=True)
shutil.copy2(Client, SitePackages / "flet_desktop" / "app" / "flet-macos.tar.gz")
CopyCode(Resources / "SaraSuite")
MakeIcns(RepoDir / "assets" / "Icon.png", Resources / "SaraSuite.icns")
Executable = Contents / "MacOS" / "SaraSuite"
Executable.write_text(Launcher)
Executable.chmod(0o755)
with (Contents / "Info.plist").open("wb") as File:
plistlib.dump({
"CFBundleName": "SaraSuite",
"CFBundleDisplayName": "SaraSuite",
"CFBundleIdentifier": AppIdentifier,
"CFBundleExecutable": "SaraSuite",
"CFBundleIconFile": "SaraSuite.icns",
"CFBundlePackageType": "APPL",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleShortVersionString": Version,
"CFBundleVersion": Version,
"LSMinimumSystemVersion": "11.0",
"LSArchitecturePriority": ["arm64"],
"NSHighResolutionCapable": True,
"NSHumanReadableCopyright": "Install, update and remove Sara programs",
}, File)
Zip(Bundle, Output)
print(f"Built {Output.relative_to(RepoDir)} ({Output.stat().st_size // 1_000_000} MB)")
if __name__ == "__main__":
Main()
+6 -4
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage (Linux) and SaraSuite.exe
(Windows, built under Wine by BuildExe.sh), create the Gitea release
v<AppVersion> (from Core.py) with both attached, and copy them to the Shared
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
"""Publish a SaraSuite release: build the AppImage (Linux), SaraSuite.exe
(Windows, built under Wine by BuildExe.sh) and SaraSuite-macOS.zip (Apple
Silicon, put together by BuildMacApp.py), create the Gitea release
v<AppVersion> (from Core.py) with all three attached, and copy them to the
Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
each to its own platform's file.
Packaging/Release.py # after bumping AppVersion in Core.py
@@ -31,6 +32,7 @@ RepoDir = Path(__file__).resolve().parent.parent
Builds = [
("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"),
("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"),
("Packaging/BuildMacApp.py", RepoDir / "dist" / "SaraSuite-macOS.zip"),
]
SharedDir = Path("/mnt/files/Shared/SaraSuite")
+9 -5
View File
@@ -81,8 +81,8 @@ places it. It reapplies the installed commit, so the program itself isn't update
## Windows and macOS
`SaraSuite.exe` is the Windows version; on macOS SaraSuite runs from source for now (there's no
Mac build yet, see below). Each one only lists the programs for its own system (a manifest's
`SaraSuite.exe` is the Windows version, `SaraSuite-macOS.zip` (SaraSuite.app) the macOS one.
Each one only lists the programs for its own system (a manifest's
`Platforms`), and does everything that system's way:
| Linux | Windows | macOS |
@@ -126,9 +126,13 @@ macOS is written but not tested on a real Mac yet (Apple Silicon only). Things t
- Windows: `Packaging/BuildExe.sh` builds `dist/SaraSuite.exe` with the Windows Python inside a
Wine prefix (`build/WinBuild/`, set up on its first run), since PyInstaller can't build for
Windows from Linux directly. The .exe's header has an "Add to Start menu" button.
- macOS: there's no build yet (PyInstaller can only build a Mac app on a Mac). Run it from
source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
`.venv/bin/python Main.py` (any Python 3.10+ for SaraSuite itself; the programs get uv's).
- macOS: `Packaging/BuildMacApp.py` puts `dist/SaraSuite-macOS.zip` together on Linux, since
PyInstaller can only build a Mac app on a Mac: a standalone Mac Python
(python-build-standalone), SaraSuite's requirements as macOS wheels, the Flet client for macOS
and SaraSuite's code, run by a shell-script launcher (which sets `SARASUITE_APP`, so SaraSuite
knows it's packaged). It isn't signed with an Apple developer account, so the first start needs
System Settings > Privacy & Security > Open Anyway; the launcher then clears the download
quarantine from the whole app. It has to be moved out of Downloads (into Applications) first.
- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
`Packaging/Launch.sh`.
- Without a desktop (e.g. a server), the same file works from a terminal:
+5 -6
View File
@@ -9,8 +9,9 @@ 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.
- macOS: SaraSuite.app (a folder, Packaging/BuildMacApp.py) 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
@@ -23,7 +24,7 @@ from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlsplit
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
@@ -42,9 +43,7 @@ def RunningAppImage() -> Optional[Path]:
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
return Path(MacApp) if MacApp.endswith(".app") else None
Value = os.environ.get("APPIMAGE", "")
return Path(Value) if Value else None