mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 02:33:17 +02:00
SaraSuite.app for macOS, built on Linux; 1.3.0
This commit is contained in:
1 parent
ca04dcd4a7
commit
873a2d9ba0
5 files changed
+246
-18
No files matched your search
@@ -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
|
||||
|
||||
Executable
+220
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user