mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
221 lines
10 KiB
Python
Executable File
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()
|