Files
SaraSuite/Packaging/BuildMacApp.py
T

221 lines
10 KiB
Python
Executable File

#!/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()