diff --git a/Core.py b/Core.py index a4325c7..7b24eec 100644 --- a/Core.py +++ b/Core.py @@ -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 diff --git a/Packaging/BuildMacApp.py b/Packaging/BuildMacApp.py new file mode 100755 index 0000000..3c6ff1b --- /dev/null +++ b/Packaging/BuildMacApp.py @@ -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() diff --git a/Packaging/Release.py b/Packaging/Release.py index 05d1314..16b9145 100755 --- a/Packaging/Release.py +++ b/Packaging/Release.py @@ -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 (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 (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") diff --git a/README.md b/README.md index 620c1dc..f9ad8dc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/SelfUpdate.py b/SelfUpdate.py index dc0f753..b6c160a 100644 --- a/SelfUpdate.py +++ b/SelfUpdate.py @@ -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