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