2 Commits
Author SHA1 Message Date
sarah 873a2d9ba0 SaraSuite.app for macOS, built on Linux; 1.3.0 2026-10-14 13:38:03 +02:00
sarah ca04dcd4a7 macOS support (code only, no build yet) 2026-10-14 13:18:20 +02:00
19 changed files with 807 additions and 133 deletions

No files matched your search

+24 -11
View File
@@ -1,11 +1,13 @@
"""Shared app-wide constants: paths, palette, theme. """Shared app-wide constants: paths, palette, theme.
Paths follow each system's own per-user conventions (no admin needed): Paths follow each system's own per-user conventions (no admin needed):
Linux Windows Linux Windows macOS
~/.config %APPDATA% (settings; roams with the user) ~/.config %APPDATA% ~/Library/Application Support (settings)
~/.local/share %LOCALAPPDATA% (installed programs, SaraSuite's data) ~/.local/share %LOCALAPPDATA% ~/Library/Application Support (programs, SaraSuite's data)
~/.cache %LOCALAPPDATA% (caches, in a Cache/Downloads subfolder) ~/.cache %LOCALAPPDATA% ~/Library/Caches (caches)
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on both. so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on all
three. (On macOS settings and data share one folder, as Mac apps' do: the
programs' config folders and SaraSuite's Programs, Receipts, ... sit side by side.)
The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's), The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's),
and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme
@@ -25,10 +27,16 @@ import tomlkit
AppName = "SaraSuite" AppName = "SaraSuite"
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py). # Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite. # Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
AppVersion = "1.2.5" AppVersion = "1.3.0"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe # 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" IsWindows = sys.platform == "win32"
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform IsMac = sys.platform == "darwin"
IsLinux = not IsWindows and not IsMac
# Matches manifests' Platforms / Platform.
Platform = "Windows" if IsWindows else "macOS" if IsMac else "Linux"
# Bundled in assets/fonts and always registered, so it works as FontFamily # Bundled in assets/fonts and always registered, so it works as FontFamily
# without being installed. Any other FontFamily is looked up among the # without being installed. Any other FontFamily is looked up among the
# system's installed fonts. # system's installed fonts.
@@ -44,6 +52,10 @@ if IsWindows:
# stand-in for a systemd user service). # stand-in for a systemd user service).
StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs"
StartupDir = StartMenuDir / "Startup" StartupDir = StartMenuDir / "Startup"
elif IsMac:
ConfigHome = DataHome = Home / "Library" / "Application Support"
CacheHome = Home / "Library" / "Caches"
StartMenuDir = StartupDir = None
else: else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
@@ -71,8 +83,9 @@ ServicesDir = DataDir / "Services"
# Linux: whether systemd runs the system, and so --user services. Without it # Linux: whether systemd runs the system, and so --user services. Without it
# (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on # (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on
# Windows (Services.py), started at login from AutostartDir. # Windows (Services.py), started at login from AutostartDir. macOS runs them
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None # that way too, started at login by a LaunchAgent (MacSupport.py).
HasSystemd = IsLinux and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
AutostartDir = ConfigHome / "autostart" AutostartDir = ConfigHome / "autostart"
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller # Bundled read-only files (program manifests, fonts). Inside a PyInstaller
@@ -260,7 +273,7 @@ def ChildEnvironment() -> dict[str, str]:
Environment["LD_LIBRARY_PATH"] = Original Environment["LD_LIBRARY_PATH"] = Original
else: else:
Environment.pop("LD_LIBRARY_PATH", None) Environment.pop("LD_LIBRARY_PATH", None)
for Key in ("PYTHONHOME", "PYTHONPATH"): for Key in ("PYTHONHOME", "PYTHONPATH", "SARASUITE_APP"):
Environment.pop(Key, None) Environment.pop(Key, None)
if IsWindows: if IsWindows:
# Python on Windows otherwise reads/writes text in the old ANSI code # Python on Windows otherwise reads/writes text in the old ANSI code
+91 -41
View File
@@ -10,6 +10,7 @@ import datetime
import hashlib import hashlib
import os import os
import re import re
import shlex
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -22,7 +23,7 @@ from typing import Callable, Optional
from ConfigMerge import ApplySet, MergeConfig, MergeResult from ConfigMerge import ApplySet, MergeConfig, MergeResult
import Services import Services
from Core import ( from Core import (
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig, AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
PristineDir, StartMenuDir, StartupDir, PristineDir, StartMenuDir, StartupDir,
) )
from Manifests import Leftover, Program, Render from Manifests import Leftover, Program, Render
@@ -89,6 +90,30 @@ def _BackupName(Target: str) -> str:
return Target.strip("/").replace("/", "__") return Target.strip("/").replace("/", "__")
def _CopyPath(Source: Path, Destination: Path) -> None:
"""A file, or a folder (a macOS .app bundle) with everything in it."""
_RemovePath(Destination)
if Source.is_dir() and not Source.is_symlink():
shutil.copytree(Source, Destination, symlinks=True)
else:
shutil.copy2(Source, Destination)
def _Displace(Program: Program, Target: Path, Kind: str, OldReceipt: Optional["Receipt"]) -> PlacedFile:
"""The receipt entry for a file SaraSuite is about to place. One that's
already there but wasn't placed by SaraSuite (e.g. a hand-made desktop
entry from development) is moved aside, not lost: it comes back when the
program is removed."""
Previous = OldReceipt.FindFile(str(Target)) if OldReceipt is not None else None
Placed = PlacedFile(str(Target), Kind, Displaced=Previous.Displaced if Previous else "")
if Previous is None and (Target.exists() or Target.is_symlink()):
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Target))
Displaced.parent.mkdir(parents=True, exist_ok=True)
_CopyPath(Target, Displaced)
Placed.Displaced = str(Displaced)
return Placed
def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int: def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int:
"""Run Command as root through pkexec, saying what went wrong in words.""" """Run Command as root through pkexec, saying what went wrong in words."""
if shutil.which("pkexec") is None: if shutil.which("pkexec") is None:
@@ -342,14 +367,17 @@ def _RequirementsText(Program: Program) -> str:
def _SystemPython(Program: Program, Log: LogFn) -> str: def _SystemPython(Program: Program, Log: LogFn) -> str:
if not IsWindows: if IsLinux:
Python = shutil.which("python3") Python = shutil.which("python3")
if Python is None: if Python is None:
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)") raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
return Python return Python
import WindowsSupport if IsMac:
import MacSupport as Support
else:
import WindowsSupport as Support
Python = WindowsSupport.FindPython() or WindowsSupport.InstallPython(Log) Python = Support.FindPython() or Support.InstallPython(Log)
if Python is None: if Python is None:
raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/") raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/")
return Python return Python
@@ -380,13 +408,13 @@ def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None:
if not Placed.Displaced or not Path(Placed.Displaced).exists(): if not Placed.Displaced or not Path(Placed.Displaced).exists():
return return
Current = Path(Placed.Path) Current = Path(Placed.Path)
if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes(): if Current.is_file() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
pass # already identical (typical for a root file that was never removed) pass # already identical (typical for a root file that was never removed)
elif Placed.Root: elif Placed.Root:
_WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log) _WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log)
else: else:
shutil.copy2(Placed.Displaced, Placed.Path) _CopyPath(Path(Placed.Displaced), Current)
Path(Placed.Displaced).unlink() _RemovePath(Path(Placed.Displaced))
Log(f"Restored your previous {Placed.Path}") Log(f"Restored your previous {Placed.Path}")
@@ -398,7 +426,7 @@ def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None:
_RemoveRoot(Placed.Path, Log) _RemoveRoot(Placed.Path, Log)
Log(f"Removed {Placed.Path}") Log(f"Removed {Placed.Path}")
else: else:
Path(Placed.Path).unlink(missing_ok=True) _RemovePath(Path(Placed.Path)) # a folder for a macOS app bundle
Log(f"Removed {Placed.Path}") Log(f"Removed {Placed.Path}")
_PutBackDisplaced(Placed, Log) _PutBackDisplaced(Placed, Log)
@@ -417,17 +445,9 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
for Template in Wanted: for Template in Wanted:
Target = Render(Template.Target, Context) Target = Render(Template.Target, Context)
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context) Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None Placed = _Displace(Program, Path(Target), "Template", OldReceipt)
Root = _NeedsRoot(Template, Target) Placed.Root = Root = _NeedsRoot(Template, Target)
Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "") if Placed.Displaced and (OldReceipt is None or OldReceipt.FindFile(Target) is None):
# A file that's already there but wasn't placed by SaraSuite (e.g. a
# hand-made desktop entry from development) is moved aside, not lost.
if Previous is None and Path(Target).exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target)
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Target, Displaced)
Placed.Displaced = str(Displaced)
Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)") Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)")
NewReceipt.Files.append(Placed) NewReceipt.Files.append(Placed)
@@ -513,27 +533,42 @@ def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Pat
and isn't ours is moved aside like a template, and comes back on removal.""" and isn't ours is moved aside like a template, and comes back on removal."""
import WindowsSupport import WindowsSupport
Previous = OldReceipt.FindFile(str(Link)) if OldReceipt is not None else None Placed = _Displace(Program, Link, "Shortcut", OldReceipt)
Placed = PlacedFile(str(Link), "Shortcut", Displaced=Previous.Displaced if Previous else "")
if Previous is None and Link.exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Link))
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Link, Displaced)
Placed.Displaced = str(Displaced)
Icon = WindowsSupport.MakeIcon(IconPng, Program.ProgramDir / f"{Link.stem}.ico") if IconPng and IconPng.is_file() else None Icon = WindowsSupport.MakeIcon(IconPng, Program.ProgramDir / f"{Link.stem}.ico") if IconPng and IconPng.is_file() else None
WindowsSupport.CreateShortcut(Link, Program.PythonW, f"-X utf8 {Arguments}", WorkingDir, Icon, Description) WindowsSupport.CreateShortcut(Link, Program.PythonW, f"-X utf8 {Arguments}", WorkingDir, Icon, Description)
NewReceipt.Files.append(Placed) NewReceipt.Files.append(Placed)
Log(f"Wrote {Link}") Log(f"Wrote {Link}")
def _PlaceAppBundle(Program: Program, Link, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt,
Log: LogFn) -> None:
"""macOS: ~/Applications/<Name>.app, running Script with the program's
Python from the app folder (MacSupport.CreateAppBundle)."""
import MacSupport
Bundle = MacSupport.ApplicationsDir / f"{Link.Name}.app"
Placed = _Displace(Program, Bundle, "Shortcut", OldReceipt)
Command = [str(Program.Python), str(Program.AppDir / Link.Script), *shlex.split(Render(Link.Arguments, Context))]
MacSupport.CreateAppBundle(
Bundle, Link.Name, f"srhtnin.{Link.Name}", Command, Program.AppDir,
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
)
NewReceipt.Files.append(Placed)
Log(f"Wrote {Bundle}")
def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Windows Start Menu shortcuts ([[Shortcuts]]); the Startup ones for """Start Menu shortcuts (Windows) and app bundles (macOS), from
services are placed by _ApplyServices.""" [[Shortcuts]]; the ones starting services at login are placed by
if not IsWindows: _ApplyServices."""
if IsLinux:
return return
for Link in Program.Shortcuts: for Link in Program.Shortcuts:
if not Program.OptionEnabled(Link.Option, NewReceipt.Options): if not Program.OptionEnabled(Link.Option, NewReceipt.Options):
continue continue
if IsMac:
_PlaceAppBundle(Program, Link, Context, OldReceipt, NewReceipt, Log)
continue
Script = Program.AppDir / Link.Script Script = Program.AppDir / Link.Script
Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip() Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip()
_PlaceShortcut( _PlaceShortcut(
@@ -549,22 +584,34 @@ def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optio
login. Recorded like a shortcut, so it goes when the service is turned off login. Recorded like a shortcut, so it goes when the service is turned off
or the program removed (and one that was already there comes back).""" or the program removed (and one that was already there comes back)."""
Entry = AutostartDir / f"{Service.Name}.desktop" Entry = AutostartDir / f"{Service.Name}.desktop"
Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None Placed = _Displace(Program, Entry, "Shortcut", OldReceipt)
Placed = PlacedFile(str(Entry), "Shortcut", Displaced=Previous.Displaced if Previous else "")
if Previous is None and Entry.exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Entry))
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Entry, Displaced)
Placed.Displaced = str(Displaced)
Entry.parent.mkdir(parents=True, exist_ok=True) Entry.parent.mkdir(parents=True, exist_ok=True)
Entry.write_text(Services.AutostartEntry(Service.Name, *Command)) Entry.write_text(Services.AutostartEntry(Service.Name, *Command))
NewReceipt.Files.append(Placed) NewReceipt.Files.append(Placed)
Log(f"Wrote {Entry}") Log(f"Wrote {Entry}")
def _PlaceLaunchAgent(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
NewReceipt: Receipt, Log: LogFn) -> None:
"""macOS: the LaunchAgent that starts a service at login, recorded like
the autostart entry above."""
import MacSupport
Agent = MacSupport.LaunchAgentPath(Service.Name)
Placed = _Displace(Program, Agent, "Shortcut", OldReceipt)
Python, Runner, Script, WorkingDir = Command
Agent.parent.mkdir(parents=True, exist_ok=True)
MacSupport.LogsDir.mkdir(parents=True, exist_ok=True)
Agent.write_bytes(MacSupport.LaunchAgent(
Service.Name, Services.Command(Python, Runner, Service.Name, Script, WorkingDir), WorkingDir,
))
NewReceipt.Files.append(Placed)
Log(f"Wrote {Agent}")
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Services SaraSuite runs itself (Windows; Linux without systemd): at """Services SaraSuite runs itself (Windows; macOS; Linux without systemd):
login from a Startup-folder shortcut / an autostart entry, through at login from a Startup-folder shortcut / a LaunchAgent / an autostart entry, through
ServiceRun.py; enabling also starts it now. An update restarts it, so ServiceRun.py; enabling also starts it now. An update restarts it, so
it runs the new code.""" it runs the new code."""
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set() WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
@@ -579,6 +626,8 @@ def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"', Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log, WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
) )
elif IsMac:
_PlaceLaunchAgent(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
else: else:
_PlaceAutostart(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log) _PlaceAutostart(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
if Service.Id in WasEnabled and not IsWindows: # Windows stopped it before the update if Service.Id in WasEnabled and not IsWindows: # Windows stopped it before the update
@@ -632,8 +681,8 @@ def _StopProcesses(Name: str, Log: LogFn) -> None:
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None: def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
if IsWindows: if not IsLinux:
return # Start Menu shortcuts need no refresh return # Start Menu shortcuts and app bundles need no refresh
Applications = Path(Program.Context()["ApplicationsDir"]) Applications = Path(Program.Context()["ApplicationsDir"])
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists(): if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
_Run(["update-desktop-database", str(Applications)], Log, Check=False) _Run(["update-desktop-database", str(Applications)], Log, Check=False)
@@ -773,6 +822,7 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
if Current is not None: if Current is not None:
ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows
else "background service, stopped and its LaunchAgent removed" if IsMac
else "systemd user service, stopped and disabled" if HasSystemd else "systemd user service, stopped and disabled" if HasSystemd
else "background service, stopped and taken out of autostart") else "background service, stopped and taken out of autostart")
for Unit in Current.EnabledServices: for Unit in Current.EnabledServices:
@@ -832,7 +882,7 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
_RemovePlacedTemplate(Placed, Log) _RemovePlacedTemplate(Placed, Log)
for Record in Current.IniEdits: for Record in Current.IniEdits:
_RevertIni(Record, Log) _RevertIni(Record, Log)
if not IsWindows and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)): if HasSystemd and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)):
_Systemctl(Log, "daemon-reload") _Systemctl(Log, "daemon-reload")
Groups = {"Program"} Groups = {"Program"}
+257
View File
@@ -0,0 +1,257 @@
"""macOS stand-ins for what SaraSuite uses on Linux:
Linux macOS
.desktop entry a small app bundle in ~/Applications (<Name>.app)
systemd --user service a LaunchAgent (~/Library/LaunchAgents) that starts it
at login, through Programs/ServiceRun.py (Services.py)
system python3 a standalone Python installed by uv into SaraSuite's
own folder (no admin, no Homebrew, no Xcode tools)
fc-list the font files' own names
xdg-open open
Only used on macOS, but nothing in here needs macOS to import.
"""
import plistlib
import shlex
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
from pathlib import Path
from typing import Callable, Optional
from Core import CacheHome, ChildEnvironment, DataDir, Home
LogFn = Callable[[str], None]
PythonVersion = "3.14" # what uv installs
# Apple Silicon only. uv's own release file, and so the Python it installs.
UvTarget = "aarch64-apple-darwin"
UvUrl = f"https://github.com/astral-sh/uv/releases/latest/download/uv-{UvTarget}.tar.gz"
ToolsDir = DataDir / "Tools" # uv itself
PythonsDir = DataDir / "Python" # the Pythons it installs (program venvs are built from these)
ApplicationsDir = Home / "Applications"
LaunchAgentsDir = Home / "Library" / "LaunchAgents"
LogsDir = Home / "Library" / "Logs"
# --- Python -------------------------------------------------------------------------------
def _UvEnvironment() -> dict[str, str]:
Environment = ChildEnvironment()
Environment.update(
UV_PYTHON_INSTALL_DIR=str(PythonsDir),
UV_PYTHON_PREFERENCE="only-managed", # never the Xcode or Homebrew one
UV_CACHE_DIR=str(CacheHome / "SaraSuite" / "uv"),
UV_NO_MODIFY_PATH="1",
)
return Environment
def _Uv() -> Path:
return ToolsDir / "uv"
def FindPython() -> Optional[str]:
"""The Python uv installed for SaraSuite earlier, if any."""
if not _Uv().is_file():
return None
try:
Result = subprocess.run(
[str(_Uv()), "python", "find", PythonVersion], capture_output=True, text=True, timeout=60,
env=_UvEnvironment(),
)
except (OSError, subprocess.SubprocessError):
return None
Found = Result.stdout.strip()
return Found if Result.returncode == 0 and Found and Path(Found).is_file() else None
def _Stream(Command: list[str], Log: LogFn) -> int:
Log(f"$ {' '.join(Command)}")
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
errors="replace", env=_UvEnvironment())
for Line in Process.stdout:
if Line.strip():
Log(f" {Line.rstrip()}")
return Process.wait()
def _InstallUv(Log: LogFn) -> bool:
Log(f"Downloading uv ({UvUrl})")
try:
with urllib.request.urlopen(UvUrl, timeout=120) as Response, tempfile.TemporaryDirectory() as Temporary:
Archive = Path(Temporary) / "uv.tar.gz"
Archive.write_bytes(Response.read())
with tarfile.open(Archive) as Tar:
Member = next((Item for Item in Tar.getmembers() if Item.isfile() and Path(Item.name).name == "uv"), None)
if Member is None:
Log("The uv download has no uv in it.")
return False
Tar.extract(Member, Temporary, filter="data")
ToolsDir.mkdir(parents=True, exist_ok=True)
shutil.move(Path(Temporary) / Member.name, _Uv())
_Uv().chmod(0o755)
return True
except (OSError, tarfile.TarError) as Error:
Log(f"Couldn't download uv: {Error}")
return False
def InstallPython(Log: LogFn) -> Optional[str]:
"""Download uv, and with it a standalone Python, into SaraSuite's own
folder. Nothing is installed system-wide and no password is asked."""
Log(f"Python not found; installing Python {PythonVersion} with uv (just for SaraSuite, no admin)")
if not _Uv().is_file() and not _InstallUv(Log):
return None
# --no-bin: no python3.14 command in ~/.local/bin; only SaraSuite uses this one.
if _Stream([str(_Uv()), "python", "install", "--no-bin", PythonVersion], Log) != 0:
return None
return FindPython()
# --- App bundles and icons -------------------------------------------------------------------
def MakeIcns(Png: Path, Icns: Path) -> Optional[Path]:
"""An .icns from one of the Sara icons (32x32 pixel art stored at 10x),
each size a whole multiple of the 32x32 grid (nearest-neighbour), so
the art stays crisp. Needs Pillow; None without it."""
try:
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)]
Icns.parent.mkdir(parents=True, exist_ok=True)
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
return Icns
except Exception:
return None
def CreateAppBundle(
Bundle: Path, Name: str, Identifier: str, Command: list[str], WorkingDir: Path,
IconPng: Optional[Path] = None, Description: str = "",
) -> None:
"""~/Applications/<Name>.app: Launchpad, Spotlight and the Finder list it
like any app. Its executable is a shell script that runs Command from
WorkingDir. Written fresh each time (the old bundle is replaced)."""
if Bundle.exists():
shutil.rmtree(Bundle)
Contents = Bundle / "Contents"
(Contents / "MacOS").mkdir(parents=True)
(Contents / "Resources").mkdir()
Executable = Contents / "MacOS" / Name
Executable.write_text(
"#!/bin/sh\n"
"# Written by SaraSuite; replaced on every update.\n"
f"cd {shlex.quote(str(WorkingDir))} || exit 1\n"
f'exec {" ".join(shlex.quote(Part) for Part in Command)} "$@"\n'
)
Executable.chmod(0o755)
Info = {
"CFBundleName": Name,
"CFBundleDisplayName": Name,
"CFBundleIdentifier": Identifier,
"CFBundleExecutable": Name,
"CFBundlePackageType": "APPL",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleShortVersionString": "1.0",
"LSMinimumSystemVersion": "11.0",
"NSHighResolutionCapable": True,
"NSHumanReadableCopyright": Description,
}
if IconPng is not None and MakeIcns(IconPng, Contents / "Resources" / f"{Name}.icns") is not None:
Info["CFBundleIconFile"] = f"{Name}.icns"
with (Contents / "Info.plist").open("wb") as File:
plistlib.dump(Info, File)
# The Finder and Launchpad cache bundles; tell them this one changed.
Register = Path("/System/Library/Frameworks/CoreServices.framework/Frameworks/"
"LaunchServices.framework/Support/lsregister")
if Register.is_file():
subprocess.run([str(Register), "-f", str(Bundle)], capture_output=True, timeout=30)
# --- Services ----------------------------------------------------------------------------------
def LaunchAgentPath(Name: str) -> Path:
return LaunchAgentsDir / f"srhtnin.{Name}.plist"
def LaunchAgent(Name: str, Command: list[str], WorkingDir: Path) -> bytes:
"""The LaunchAgent that starts a service at login (macOS's equivalent of
the Startup folder / an XDG autostart entry). Only that: ServiceRun.py
keeps it running and logs it, like on the other systems, so SaraSuite
and SaraSort can start and stop it the same way everywhere."""
return plistlib.dumps({
"Label": f"srhtnin.{Name}",
"ProgramArguments": Command,
"WorkingDirectory": str(WorkingDir),
"RunAtLoad": True,
"ProcessType": "Background",
# Only what fails before ServiceRun.py has its own log (e.g. a missing venv).
"StandardErrorPath": str(LogsDir / f"{Name}.launchd.log"),
})
# --- Fonts and files ----------------------------------------------------------------------------
def _FontFamilies(FilePath: Path) -> set[str]:
"""Family names in one font file's name table (every font of a .ttc).
The typographic family (name ID 16) where there is one, else ID 1."""
import struct
Families: set[str] = set()
try:
Data = FilePath.read_bytes()
except OSError:
return Families
Offsets = [0]
if Data[:4] == b"ttcf":
Count = struct.unpack_from(">I", Data, 8)[0]
Offsets = list(struct.unpack_from(f">{Count}I", Data, 12))
for Offset in Offsets:
try:
for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]):
Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16)
if Tag != b"name":
continue
_Format, Count, Strings = struct.unpack_from(">HHH", Data, Start)
Found: dict[int, str] = {}
for Record in range(Count):
PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from(
">6H", Data, Start + 6 + Record * 12)
if NameId not in (1, 16) or NameId in Found:
continue
Raw = Data[Start + Strings + At:Start + Strings + At + Length]
if PlatformId in (0, 3):
Found[NameId] = Raw.decode("utf-16-be", "replace")
elif PlatformId == 1 and Language == 0:
Found[NameId] = Raw.decode("mac-roman", "replace")
Name = (Found.get(16) or Found.get(1) or "").strip()
if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal
Families.add(Name)
except struct.error:
continue
return Families
def FontFamilies() -> set[str]:
"""Installed font families: the system's, the shared and your own Fonts folders."""
Families: set[str] = set()
for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Home / "Library" / "Fonts"):
if Folder.is_dir():
for FilePath in Folder.rglob("*"):
if FilePath.suffix.lower() in (".ttf", ".otf", ".ttc"):
Families |= _FontFamilies(FilePath)
return Families
def OpenPath(Target: Path) -> None:
subprocess.Popen(["open", str(Target)], env=ChildEnvironment(), start_new_session=True)
+6 -5
View File
@@ -14,7 +14,7 @@ import flet as ft
import SelfUpdate import SelfUpdate
from Catalog import UpdateCatalog from Catalog import UpdateCatalog
from Core import ( from Core import (
AppName, AppVersion, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, AppName, AppVersion, IsLinux, IsMac, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette, BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
) )
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
@@ -26,7 +26,8 @@ from Settings import ShowSettingsDialog
_MaxLogLines = 2000 _MaxLogLines = 2000
# When running packaged (AppImage / .exe), SaraSuite can put itself in the # When running packaged (AppImage / .exe), SaraSuite can put itself in the
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. # app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. On
# macOS SaraSuite.app already is an app wherever it's put (e.g. /Applications).
_Packaged = SelfUpdate.RunningAppImage() _Packaged = SelfUpdate.RunningAppImage()
_AppImage = str(_Packaged) if _Packaged else "" _AppImage = str(_Packaged) if _Packaged else ""
if IsWindows: if IsWindows:
@@ -43,7 +44,7 @@ def _KeepFallbackLibraries() -> None:
~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet ~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet
client of every installed program on systems without it.""" client of every installed program on systems without it."""
Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs" Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs"
if IsWindows or not Bundled.is_dir(): if not IsLinux or not Bundled.is_dir():
return return
Kept = DataDir / "Libraries" Kept = DataDir / "Libraries"
try: try:
@@ -286,7 +287,7 @@ def Main(Page: ft.Page) -> None:
MenuButton = ft.OutlinedButton( MenuButton = ft.OutlinedButton(
content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}", content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}",
style=ChipButtonStyle(), style=ChipButtonStyle(),
visible=bool(_AppImage), visible=bool(_AppImage) and not IsMac,
on_click=ToggleMenuEntry, on_click=ToggleMenuEntry,
) )
@@ -355,7 +356,7 @@ if __name__ == "__main__":
sys.exit(Run(sys.argv[1:])) sys.exit(Run(sys.argv[1:]))
SelfUpdate.CleanUpOldExe() SelfUpdate.CleanUpOldExe()
if not IsWindows: if IsLinux:
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the # A Flet client copy with the window app ID srhtnin.SaraSuite, so the
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead # desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py. # of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
+19 -11
View File
@@ -3,8 +3,9 @@ and Hooks.py next to it. See README.md for the full manifest format.
A manifest's Platforms (default ["Linux"]) says where the program runs; each A manifest's Platforms (default ["Linux"]) says where the program runs; each
SaraSuite only lists the programs for its own system. Any list item can also SaraSuite only lists the programs for its own system. Any list item can also
carry Platform = "Linux" or "Windows" to only apply there (e.g. desktop carry Platform = "Linux", "Windows" or "macOS" to only apply there (e.g.
entries on Linux, Start Menu shortcuts on Windows); others are dropped at load.""" desktop entries on Linux, Start Menu shortcuts on Windows, app bundles on
macOS); others are dropped at load."""
import importlib.util import importlib.util
import re import re
@@ -16,7 +17,7 @@ from typing import Optional
import tomlkit import tomlkit
from Core import ( from Core import (
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig, AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported, Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
) )
@@ -86,10 +87,11 @@ class IniEdit:
@dataclass @dataclass
class Service: class Service:
"""A background service. Linux with systemd: a --user Unit (placed by a """A background service. Linux with systemd: a --user Unit (placed by a
Template). Windows, and Linux without systemd (runit, OpenRC, ...): Template). Windows, macOS, and Linux without systemd (runit, OpenRC, ...):
Script (relative to the app folder) run by ServiceRun.py in WorkingDir, Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
at login through a Startup-folder shortcut / an XDG autostart entry at login through a Startup-folder shortcut / a LaunchAgent / an XDG
(Services.py), so a Linux service gives both Unit and Name/Script.""" autostart entry (Services.py), so a Linux service gives both Unit and
Name/Script."""
Unit: str = "" Unit: str = ""
Option: str = "" # enabled + started only while this option is ticked Option: str = "" # enabled + started only while this option is ticked
@@ -109,9 +111,12 @@ class Service:
@dataclass @dataclass
class Shortcut: class Shortcut:
"""Windows: a Start Menu shortcut running Script (relative to the app """A launcher in the system's app list running Script (relative to the
folder) with the program's pythonw.exe, from the app folder, with Icon app folder) with the program's Python, from the app folder, with Icon (a
(a PNG in the app folder, turned into an .ico).""" PNG in the app folder):
- Windows: a Start Menu shortcut (pythonw.exe; the icon becomes an .ico);
- macOS: an app bundle, ~/Applications/<Name>.app (the icon becomes an .icns).
Linux uses a desktop entry Template instead."""
Name: str Name: str
Script: str Script: str
@@ -235,6 +240,9 @@ class Program:
} }
if IsWindows: if IsWindows:
Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir)) Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir))
elif IsMac:
Context.update(ApplicationsDir=str(Home / "Applications"),
LaunchAgentsDir=str(Home / "Library" / "LaunchAgents"))
else: else:
Context.update( Context.update(
SystemdUserDir=str(ConfigHome / "systemd" / "user"), SystemdUserDir=str(ConfigHome / "systemd" / "user"),
@@ -306,7 +314,7 @@ def LoadProgram(Folder: Path) -> Program:
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))], Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))], IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))], Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
RestartUnits=[] if IsWindows else Data.get("RestartUnits", []), RestartUnits=Data.get("RestartUnits", []) if IsLinux else [],
Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))], Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))],
Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))], Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))],
Platforms=Data.get("Platforms", ["Linux"]), Platforms=Data.get("Platforms", ["Linux"]),
@@ -330,7 +338,7 @@ def LoadPrograms() -> list[Program]:
Loaded = _LoadFrom(ManifestsDir()) Loaded = _LoadFrom(ManifestsDir())
except Exception: except Exception:
Loaded = _LoadFrom(BundledManifestsDir) Loaded = _LoadFrom(BundledManifestsDir)
# Only the programs that run on this system (the .exe lists Windows ones). # Only the programs that run on this system (the .exe lists Windows ones, the Mac app macOS ones).
Loaded = [Current for Current in Loaded if Platform in Current.Platforms] Loaded = [Current for Current in Loaded if Platform in Current.Platforms]
Names = {Current.Name for Current in Loaded} Names = {Current.Name for Current in Loaded}
Ordered = [] Ordered = []
+220
View File
@@ -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()
+6 -4
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage (Linux) and SaraSuite.exe """Publish a SaraSuite release: build the AppImage (Linux), SaraSuite.exe
(Windows, built under Wine by BuildExe.sh), create the Gitea release (Windows, built under Wine by BuildExe.sh) and SaraSuite-macOS.zip (Apple
v<AppVersion> (from Core.py) with both attached, and copy them to the Shared Silicon, put together by BuildMacApp.py), create the Gitea release
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py), 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. each to its own platform's file.
Packaging/Release.py # after bumping AppVersion in Core.py Packaging/Release.py # after bumping AppVersion in Core.py
@@ -31,6 +32,7 @@ RepoDir = Path(__file__).resolve().parent.parent
Builds = [ Builds = [
("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"), ("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"),
("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"), ("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"),
("Packaging/BuildMacApp.py", RepoDir / "dist" / "SaraSuite-macOS.zip"),
] ]
SharedDir = Path("/mnt/files/Shared/SaraSuite") SharedDir = Path("/mnt/files/Shared/SaraSuite")
+8 -1
View File
@@ -1,6 +1,6 @@
Name = "SaraFem" Name = "SaraFem"
Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker." Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker."
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
# The portal reads its descriptors and preferences once at startup. # The portal reads its descriptors and preferences once at startup.
RestartUnits = ["xdg-desktop-portal.service"] RestartUnits = ["xdg-desktop-portal.service"]
@@ -77,6 +77,13 @@ Name = "SaraFem"
Script = "Main.py" Script = "Main.py"
Icon = "assets/Icon.png" Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraFem.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraFem"
Script = "Main.py"
Icon = "assets/Icon.png"
[[Leftovers]] [[Leftovers]]
Path = "{CacheHome}/SaraFem" Path = "{CacheHome}/SaraFem"
Kind = "Runtime" Kind = "Runtime"
+8 -1
View File
@@ -1,7 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/. # Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort" Name = "SaraSort"
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates." Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
[Source] [Source]
@@ -31,6 +31,13 @@ Name = "SaraSort"
Script = "Main.py" Script = "Main.py"
Icon = "assets/Icon.png" Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/. # Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
[[Leftovers]] [[Leftovers]]
Path = "{ConfigDir}" Path = "{ConfigDir}"
+13 -5
View File
@@ -1,6 +1,6 @@
Name = "SaraSortd" Name = "SaraSortd"
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background." Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background."
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
[Version] [Version]
File = "SaraSortd.py" File = "SaraSortd.py"
@@ -46,10 +46,11 @@ Platform = "Linux"
Source = "SaraSortd.service" Source = "SaraSortd.service"
Target = "{SystemdUserDir}/SaraSortd.service" Target = "{SystemdUserDir}/SaraSortd.service"
# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from # Windows, macOS, and Linux without systemd (runit, OpenRC, s6, ...): run
# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through # from ConfigDir by ServiceRun.py, which restarts it if it fails, at login
# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's # through a shortcut in the Startup folder / a LaunchAgent / an XDG autostart
# Services.py). On Linux the same service has a Unit for systemd too. # entry (SaraSuite's Services.py). On Linux the same service has a Unit for
# systemd too.
[[Templates]] [[Templates]]
Source = "../../ServiceRun.py" Source = "../../ServiceRun.py"
Target = "{ProgramDir}/ServiceRun.py" Target = "{ProgramDir}/ServiceRun.py"
@@ -69,5 +70,12 @@ Script = "SaraSortd.py"
WorkingDir = "{ConfigDir}" WorkingDir = "{ConfigDir}"
Option = "Autostart" Option = "Autostart"
[[Services]]
Platform = "macOS"
Name = "SaraSortd"
Script = "SaraSortd.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
# Everything SaraSortd makes inside the sorting folders is worked out from # Everything SaraSortd makes inside the sorting folders is worked out from
# GlobalConf.toml by Hooks.py. # GlobalConf.toml by Hooks.py.
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/. # Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/.
Name = "SaraTag" Name = "SaraTag"
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once." Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once."
[Version] [Version]
@@ -40,6 +40,13 @@ Name = "SaraTag"
Script = "Main.py" Script = "Main.py"
Icon = "assets/Icon.png" Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraTag.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraTag"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraTag itself. The Lexicons are your tag definitions: only tick # Made by SaraTag itself. The Lexicons are your tag definitions: only tick
# "remove configs" if you don't need them any more. # "remove configs" if you don't need them any more.
[[Leftovers]] [[Leftovers]]
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/. # Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
Name = "SaraTune" Name = "SaraTune"
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)." Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)."
[Source] [Source]
@@ -30,6 +30,13 @@ Name = "SaraTune"
Script = "Main.py" Script = "Main.py"
Icon = "assets/Icon.png" Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraTune.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraTune"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraTune itself. # Made by SaraTune itself.
[[Leftovers]] [[Leftovers]]
Path = "{ConfigDir}" Path = "{ConfigDir}"
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/. # Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
Name = "SaraView" Name = "SaraView"
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server." Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
[Source] [Source]
@@ -32,6 +32,13 @@ Name = "SaraView"
Script = "Client/Main.py" Script = "Client/Main.py"
Icon = "Client/assets/Icon.png" Icon = "Client/assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraView.app (python Client/Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraView"
Script = "Client/Main.py"
Icon = "Client/assets/Icon.png"
# Made by SaraView itself: Config.toml and Session.toml (your saved login). # Made by SaraView itself: Config.toml and Session.toml (your saved login).
[[Leftovers]] [[Leftovers]]
Path = "{ConfigDir}" Path = "{ConfigDir}"
+17 -8
View File
@@ -1,8 +1,8 @@
"""Runs a SaraSuite program as a background service where there's no systemd """Runs a SaraSuite program as a background service where there's no systemd
--user to do it: on Windows (started windowless with pythonw.exe), and on --user to do it: on Windows (started windowless with pythonw.exe), on macOS,
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...). and on Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG Started at login (Windows: a shortcut in the Startup folder; macOS: a
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort LaunchAgent; Linux: an XDG autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
for SaraSortd) starts it: for SaraSortd) starts it:
python ServiceRun.py <Name> <Script> [<WorkingDir>] python ServiceRun.py <Name> <Script> [<WorkingDir>]
@@ -12,7 +12,8 @@ seconds after it fails (an exception or a non-zero exit), not after a clean
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
anything can tell whether it runs, and stop it, without admin rights. Both anything can tell whether it runs, and stop it, without admin rights. Both
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
on Windows, ~/.local/share/SaraSuite/Services on Linux. on Windows, ~/Library/Application Support/SaraSuite/Services on macOS,
~/.local/share/SaraSuite/Services on Linux.
Installed next to each program that has a service (Programs/<Name>/Manifest.toml). Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
""" """
@@ -20,6 +21,7 @@ Installed next to each program that has a service (Programs/<Name>/Manifest.toml
import os import os
import runpy import runpy
import signal import signal
import subprocess
import sys import sys
import time import time
import traceback import traceback
@@ -27,6 +29,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
IsWindows = sys.platform == "win32" IsWindows = sys.platform == "win32"
IsMac = sys.platform == "darwin"
RestartSeconds = 10 # the unit's RestartSec RestartSeconds = 10 # the unit's RestartSec
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1 MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
Stopping = False Stopping = False
@@ -35,22 +38,28 @@ Stopping = False
def ServicesDir() -> Path: def ServicesDir() -> Path:
if IsWindows: if IsWindows:
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
elif IsMac:
Base = Path.home() / "Library" / "Application Support"
else: else:
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return Base / "SaraSuite" / "Services" return Base / "SaraSuite" / "Services"
def _AlreadyRunning(PidFile: Path) -> bool: def _AlreadyRunning(PidFile: Path) -> bool:
"""Linux: whether the PID file's process is another ServiceRun.py still """Linux and macOS: whether the PID file's process is another
running (not a reused PID). Windows relies on whoever starts it to check.""" ServiceRun.py still running (not a reused PID). Windows relies on whoever
starts it to check."""
if IsWindows: if IsWindows:
return False return False
try: try:
Pid = int(PidFile.read_text().strip()) Pid = int(PidFile.read_text().strip())
if Pid == os.getpid(): if Pid == os.getpid():
return False return False
if IsMac: # no /proc
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
return "ServiceRun.py" in Output
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
except (OSError, ValueError): except (OSError, ValueError, subprocess.SubprocessError):
return False return False
+48 -27
View File
@@ -79,34 +79,44 @@ an option off takes out what it placed and puts back any file it had replaced; t
places it. It reapplies the installed commit, so the program itself isn't updated. The places it. It reapplies the installed commit, so the program itself isn't updated. The
**Update** button only shows up when there's an update (or a broken install to repair). **Update** button only shows up when there's an update (or a broken install to repair).
## Windows ## Windows and macOS
`SaraSuite.exe` is the Windows version. Each build only lists the programs for its own system `SaraSuite.exe` is the Windows version, `SaraSuite-macOS.zip` (SaraSuite.app) the macOS one.
(a manifest's `Platforms`), and does everything the Windows way: Each one only lists the programs for its own system (a manifest's
`Platforms`), and does everything that system's way:
| Linux | Windows | | Linux | Windows | macOS |
| --- | --- | | --- | --- | --- |
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` | | `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` | `~/Library/Application Support/SaraSuite/` |
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` | | `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` | the same folder as the settings, as Mac apps do |
| `~/.cache/...` | `%LOCALAPPDATA%\...` | | `~/.cache/...` | `%LOCALAPPDATA%\...` | `~/Library/Caches/...` |
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) | | desktop entry | Start Menu shortcut (`[[Shortcuts]]`) | `~/Applications/<Name>.app`, a small app bundle (`[[Shortcuts]]`) |
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` | | systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder | `ServiceRun.py`, started at login by a LaunchAgent, `~/Library/LaunchAgents/srhtnin.<Name>.plist` |
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget | a standalone Python that uv installs into SaraSuite's own folder (`Python/`, uv itself in `Tools/`); no admin, Homebrew or Xcode tools |
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) | `SaraSuite-macOS.zip` self-update (unpacked next to `SaraSuite.app`, then swapped in) |
A service run by `ServiceRun.py` restarts 10 seconds after it fails, like the unit's
`Restart=on-failure`, and keeps its output in SaraSuite's `Services/<Name>.log`, its PID next to it
in `<Name>.pid` (that's how SaraSuite and SaraSort tell it runs, and stop it).
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
services like Windows does (`Services.py`): `ServiceRun.py` runs them, started at login by an XDG services the same way (`Services.py`), started at login by an XDG autostart entry,
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the init system is.
init system is. It restarts a service 10 seconds after it fails, like the unit's Units aren't written there, and `RestartUnits` stops the process instead (D-Bus starts
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its `xdg-desktop-portal` again when it's needed). A window manager that doesn't run XDG autostart
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process entries needs to start them itself (e.g. `dex -a` in its startup config).
instead (D-Bus starts `xdg-desktop-portal` again when it's needed). A window manager that doesn't
run XDG autostart entries needs to start them itself (e.g. `dex -a` in its startup config).
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
Programs run on Windows with `pythonw.exe -X utf8` (no console window; UTF-8 mode, since Python Programs run on Windows with `pythonw.exe -X utf8` (no console window; UTF-8 mode, since Python
on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights. on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights on
Linux-only features (SaraFem's file picker portal and default-file-manager option) simply either system. Linux-only features (SaraFem's file picker portal and default-file-manager option)
aren't offered there. simply aren't offered there, and on macOS SaraTune has no media keys (see its README).
macOS is written but not tested on a real Mac yet (Apple Silicon only). Things to know there:
- SaraSortd sorting `~/Downloads`, `~/Documents` or `~/Desktop` needs permission: macOS asks the
first time, or the Python in `~/Library/Application Support/SaraSuite/Python/` can be given
Full Disk Access in System Settings > Privacy & Security.
- A program's Dock icon is the Flet client's, not its own; its app bundle in `~/Applications`
(what Launchpad and Spotlight show) has the right one.
## Running and building ## Running and building
@@ -116,6 +126,13 @@ aren't offered there.
- Windows: `Packaging/BuildExe.sh` builds `dist/SaraSuite.exe` with the Windows Python inside a - 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 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. Windows from Linux directly. The .exe's header has an "Add to Start menu" button.
- 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 - From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
`Packaging/Launch.sh`. `Packaging/Launch.sh`.
- Without a desktop (e.g. a server), the same file works from a terminal: - Without a desktop (e.g. a server), the same file works from a terminal:
@@ -131,7 +148,7 @@ aren't offered there.
## Where things live ## Where things live
On Linux (on Windows, the same layout under `%LOCALAPPDATA%` and `%APPDATA%`; see Windows above): On Linux (on Windows and macOS, the same layout in their own folders; see Windows and macOS above):
| Path | What | | Path | What |
| --- | --- | | --- | --- |
@@ -155,17 +172,18 @@ Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `H
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else `Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else
`/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name), `/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name),
`DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`, `DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`,
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`. `ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`. macOS only:
`ApplicationsDir` (`~/Applications`), `LaunchAgentsDir`.
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
`Platform = "Linux"` or `"Windows"` to only apply on that system; items without one apply on both. `Platform = "Linux"`, `"Windows"` or `"macOS"` to only apply on that system; items without one apply everywhere.
In TOML templates, write paths as literal strings (`'{ConfigDir}'`): Windows paths are full of In TOML templates, write paths as literal strings (`'{ConfigDir}'`): Windows paths are full of
backslashes, which a `"..."` string would read as escapes. backslashes, which a `"..."` string would read as escapes.
```toml ```toml
Name = "Example" Name = "Example"
Description = "Shown on the program's card." Description = "Shown on the program's card."
Platforms = ["Linux", "Windows"] # where it runs (default ["Linux"]) Platforms = ["Linux", "Windows", "macOS"] # where it runs (default ["Linux"])
Parent = "SaraSortd" # optional: listed (indented) under that program Parent = "SaraSortd" # optional: listed (indented) under that program
RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove
@@ -215,7 +233,8 @@ WorkingDir = "{ConfigDir}"
Option = "Autostart" Option = "Autostart"
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut [[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir}) Platform = "Windows" # (macOS: the same with Platform = "macOS", from a LaunchAgent;
# both: add ../../ServiceRun.py as a Template to {ProgramDir})
Name = "Example" Name = "Example"
Script = "Example.py" # relative to the app folder Script = "Example.py" # relative to the app folder
WorkingDir = "{ConfigDir}" WorkingDir = "{ConfigDir}"
@@ -226,6 +245,8 @@ Platform = "Windows"
Name = "Example" Name = "Example"
Script = "Main.py" # relative to the app folder, which is also its working folder Script = "Main.py" # relative to the app folder, which is also its working folder
Icon = "assets/Icon.png" # turned into an .ico Icon = "assets/Icon.png" # turned into an .ico
# macOS: the same with Platform = "macOS" makes ~/Applications/<Name>.app
# (the venv's python Script; the icon becomes an .icns)
[[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal [[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal
File = "{ConfigHome}/mimeapps.list" File = "{ConfigHome}/mimeapps.list"
+44 -6
View File
@@ -1,27 +1,33 @@
"""SaraSuite updating itself from its Gitea releases. """SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage and SaraSuite.exe attached. Here SaraSuite asks Gitea for the latest AppImage and SaraSuite.exe attached (and SaraSuite-macOS.zip, once there's
a Mac build). Here SaraSuite asks Gitea for the latest
release, and if it's newer than AppVersion and SaraSuite runs packaged release, and if it's newer than AppVersion and SaraSuite runs packaged
(AppImage / .exe), downloads its own platform's file and swaps it in, then (AppImage / .exe), downloads its own platform's file and swaps it in, then
restarts into it. The file keeps its path, so its menu entry keeps working. restarts into it. The file keeps its path, so its menu entry keeps working.
- Linux: the download is renamed over the running AppImage (allowed). - Linux: the download is renamed over the running AppImage (allowed).
- Windows: a running .exe can't be overwritten but can be renamed, so the - 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). old one becomes SaraSuite.old.exe (deleted on the next start).
- 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 import os
import shutil
import subprocess import subprocess
import tempfile
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from urllib.parse import urlsplit from urllib.parse import urlsplit
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsWindows, LoadConfig from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
from Sources import GetBytes, GetJson, SourceError from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-x86_64.AppImage" AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
@dataclass @dataclass
@@ -32,9 +38,12 @@ class Release:
def RunningAppImage() -> Optional[Path]: def RunningAppImage() -> Optional[Path]:
"""The packaged SaraSuite file that's running: the AppImage on Linux, the """The packaged SaraSuite file that's running: the AppImage on Linux, the
.exe on Windows. None when running from source.""" .exe on Windows, the SaraSuite.app bundle on macOS. None when running
from source."""
if IsWindows: if IsWindows:
return Path(sys.executable) if IsFrozen else None return Path(sys.executable) if IsFrozen else None
if IsMac:
return Path(MacApp) if MacApp.endswith(".app") else None
Value = os.environ.get("APPIMAGE", "") Value = os.environ.get("APPIMAGE", "")
return Path(Value) if Value else None return Path(Value) if Value else None
@@ -46,6 +55,25 @@ def CleanUpOldExe() -> None:
Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True) Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True)
def _InstallMacBundle(Target: Path, Data: bytes) -> None:
"""Unpack the zipped SaraSuite.app next to the running one (same volume,
so the renames are instant), then swap the two. ditto keeps what a Mac
app needs: permissions, symlinks inside its frameworks, its signature."""
with tempfile.TemporaryDirectory(dir=Target.parent, prefix=".SaraSuite-update-") as Temporary:
Archive = Path(Temporary) / AssetName
Archive.write_bytes(Data)
Unpacked = Path(Temporary) / "Unpacked"
subprocess.run(["ditto", "-x", "-k", str(Archive), str(Unpacked)], check=True, capture_output=True)
New = next(Unpacked.glob("*.app"), None)
if New is None:
raise OSError(f"{AssetName} has no app in it; leaving the current one alone.")
Old = Target.with_name(f"{Target.stem}.old.app")
shutil.rmtree(Old, ignore_errors=True)
Target.rename(Old)
New.rename(Target)
shutil.rmtree(Old, ignore_errors=True)
def _VersionTuple(Version: str) -> tuple[int, ...]: def _VersionTuple(Version: str) -> tuple[int, ...]:
Parts = [] Parts = []
for Part in Version.strip().lstrip("vV").split("."): for Part in Version.strip().lstrip("vV").split("."):
@@ -88,8 +116,15 @@ def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}") Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
Log(f"Downloading {Latest.AssetUrl}") Log(f"Downloading {Latest.AssetUrl}")
Data = GetBytes(Latest.AssetUrl) Data = GetBytes(Latest.AssetUrl)
if not Data.startswith(b"MZ" if IsWindows else b"\x7fELF"): if not Data.startswith(b"MZ" if IsWindows else b"PK" if IsMac else b"\x7fELF"):
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.") raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
if IsMac:
try:
_InstallMacBundle(Target, Data)
except subprocess.CalledProcessError as Error:
raise OSError(f"Couldn't unpack {AssetName}: {Error.stderr.decode(errors='replace').strip()}") from Error
Log(f"== {AppName} updated; it's the new version from the next start")
return Target
Temporary = Target.with_name(Target.name + ".new") Temporary = Target.with_name(Target.name + ".new")
Temporary.write_bytes(Data) Temporary.write_bytes(Data)
if IsWindows: if IsWindows:
@@ -115,7 +150,10 @@ def Restart() -> None:
Environment = ChildEnvironment() Environment = ChildEnvironment()
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"): for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
Environment.pop(Key, None) # the new one sets its own Environment.pop(Key, None) # the new one sets its own
if IsWindows: if IsMac:
# -n: a new instance, even though this one is still running.
subprocess.Popen(["open", "-n", str(Target)], env=Environment, start_new_session=True)
elif IsWindows:
Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours
subprocess.Popen([str(Target)], env=Environment, close_fds=True, subprocess.Popen([str(Target)], env=Environment, close_fds=True,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)) creationflags=getattr(subprocess, "DETACHED_PROCESS", 0))
+9 -2
View File
@@ -1,9 +1,10 @@
"""Background services SaraSuite runs itself, where there's no systemd --user """Background services SaraSuite runs itself, where there's no systemd --user
to do it: Windows, and Linux with another init system (runit on Void, OpenRC, to do it: Windows, macOS, and Linux with another init system (runit on Void, OpenRC,
s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script> s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script>
<WorkingDir>, which keeps it going (restarts it if it fails), logs its output <WorkingDir>, which keeps it going (restarts it if it fails), logs its output
and records its PID, all in ServicesDir. At login it's started by and records its PID, all in ServicesDir. At login it's started by
- Windows: a shortcut in the Startup folder; - Windows: a shortcut in the Startup folder;
- macOS: a LaunchAgent (~/Library/LaunchAgents/srhtnin.<Name>.plist);
- Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which - Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which
desktop sessions start whatever the init system is. desktop sessions start whatever the init system is.
The installer places those (Installer._ApplyStandInServices); this starts, The installer places those (Installer._ApplyStandInServices); this starts,
@@ -18,7 +19,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from Core import ChildEnvironment, IsWindows, ServicesDir from Core import ChildEnvironment, IsMac, IsWindows, ServicesDir
LogFn = Callable[[str], None] LogFn = Callable[[str], None]
@@ -41,6 +42,12 @@ def ServicePid(Name: str) -> Optional[int]:
Pid = int(_PidFile(Name).read_text().strip()) Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError): except (OSError, ValueError):
return None return None
if IsMac: # no /proc: ask ps for its command line
try:
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if "ServiceRun.py" in Output else None
if not IsWindows: if not IsWindows:
try: try:
return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None
+11 -8
View File
@@ -8,8 +8,8 @@ from typing import Callable
import flet as ft import flet as ft
from Core import ( from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsWindows, AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsMac, IsWindows,
LoadSharedSettings, Palette, SharedSettings, ThemesDir, LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ThemesDir,
) )
from Installer import DefaultAnswers, DefaultOptions from Installer import DefaultAnswers, DefaultOptions
from Manifests import Program from Manifests import Program
@@ -32,12 +32,15 @@ def _Themes() -> list[str]:
def _Fonts() -> list[str]: def _Fonts() -> list[str]:
"""Installed font families (fontconfig, or the registry on Windows), with """Installed font families (fontconfig, the registry on Windows, the font
the bundled font first.""" files themselves on macOS), with the bundled font first."""
if IsWindows: if IsWindows or IsMac:
import WindowsSupport if IsMac:
import MacSupport as Support
else:
import WindowsSupport as Support
Families = WindowsSupport.FontFamilies() Families = Support.FontFamilies()
Families.discard(BundledFontFamily) Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower) return [BundledFontFamily] + sorted(Families, key=str.lower)
try: try:
@@ -98,7 +101,7 @@ def ShowSettingsDialog(Page: ft.Page, Config: AppConfig, Programs: list[Program]
Categories: dict[str, ft.Control] = { Categories: dict[str, ft.Control] = {
"Shared": ft.Column( "Shared": ft.Column(
[ [
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."), _Note(f"Shared by every SaraSuite program ({SharedConfigPath})."),
UseSharedSwitch, UseSharedSwitch,
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
SharedTheme, SharedTheme,
+2
View File
@@ -1,2 +1,4 @@
flet>=1.0,<2.0 flet>=1.0,<2.0
tomlkit tomlkit
# macOS: app bundle icons (.icns); the Windows build bundles it for .ico.
pillow; sys_platform == "darwin"