From ca04dcd4a748044da89bd7c9de5e76a4ed5df6e6 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Wed, 14 Oct 2026 13:18:20 +0200 Subject: [PATCH] macOS support (code only, no build yet) --- Core.py | 26 +++- Installer.py | 132 +++++++++++----- MacSupport.py | 257 +++++++++++++++++++++++++++++++ Main.py | 11 +- Manifests.py | 30 ++-- Programs/SaraFem/Manifest.toml | 9 +- Programs/SaraSort/Manifest.toml | 9 +- Programs/SaraSortd/Manifest.toml | 18 ++- Programs/SaraTag/Manifest.toml | 9 +- Programs/SaraTune/Manifest.toml | 9 +- Programs/SaraView/Manifest.toml | 9 +- Programs/ServiceRun.py | 25 ++- README.md | 71 +++++---- SelfUpdate.py | 51 +++++- Services.py | 11 +- Settings.py | 19 ++- requirements.txt | 2 + 17 files changed, 572 insertions(+), 126 deletions(-) create mode 100644 MacSupport.py diff --git a/Core.py b/Core.py index e10e4b3..a4325c7 100644 --- a/Core.py +++ b/Core.py @@ -1,11 +1,13 @@ """Shared app-wide constants: paths, palette, theme. Paths follow each system's own per-user conventions (no admin needed): - Linux Windows - ~/.config %APPDATA% (settings; roams with the user) - ~/.local/share %LOCALAPPDATA% (installed programs, SaraSuite's data) - ~/.cache %LOCALAPPDATA% (caches, in a Cache/Downloads subfolder) -so the SaraSuite layout below them (SaraSuite//...) is the same on both. + Linux Windows macOS + ~/.config %APPDATA% ~/Library/Application Support (settings) + ~/.local/share %LOCALAPPDATA% ~/Library/Application Support (programs, SaraSuite's data) + ~/.cache %LOCALAPPDATA% ~/Library/Caches (caches) +so the SaraSuite layout below them (SaraSuite//...) 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), and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme @@ -28,7 +30,10 @@ AppName = "SaraSuite" AppVersion = "1.2.5" IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe 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 # without being installed. Any other FontFamily is looked up among the # system's installed fonts. @@ -44,6 +49,10 @@ if IsWindows: # stand-in for a systemd user service). StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" StartupDir = StartMenuDir / "Startup" +elif IsMac: + ConfigHome = DataHome = Home / "Library" / "Application Support" + CacheHome = Home / "Library" / "Caches" + StartMenuDir = StartupDir = None else: ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") @@ -71,8 +80,9 @@ ServicesDir = DataDir / "Services" # 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 -# Windows (Services.py), started at login from AutostartDir. -HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None +# Windows (Services.py), started at login from AutostartDir. macOS runs them +# 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" # Bundled read-only files (program manifests, fonts). Inside a PyInstaller diff --git a/Installer.py b/Installer.py index 52612d8..78b1393 100644 --- a/Installer.py +++ b/Installer.py @@ -10,6 +10,7 @@ import datetime import hashlib import os import re +import shlex import shutil import signal import subprocess @@ -22,7 +23,7 @@ from typing import Callable, Optional from ConfigMerge import ApplySet, MergeConfig, MergeResult import Services from Core import ( - AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig, + AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig, PristineDir, StartMenuDir, StartupDir, ) from Manifests import Leftover, Program, Render @@ -89,6 +90,30 @@ def _BackupName(Target: str) -> str: 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: """Run Command as root through pkexec, saying what went wrong in words.""" if shutil.which("pkexec") is None: @@ -342,14 +367,17 @@ def _RequirementsText(Program: Program) -> str: def _SystemPython(Program: Program, Log: LogFn) -> str: - if not IsWindows: + if IsLinux: Python = shutil.which("python3") if Python is None: raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)") 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: raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/") return Python @@ -380,13 +408,13 @@ def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None: if not Placed.Displaced or not Path(Placed.Displaced).exists(): return 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) elif Placed.Root: _WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log) else: - shutil.copy2(Placed.Displaced, Placed.Path) - Path(Placed.Displaced).unlink() + _CopyPath(Path(Placed.Displaced), Current) + _RemovePath(Path(Placed.Displaced)) Log(f"Restored your previous {Placed.Path}") @@ -398,7 +426,7 @@ def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None: _RemoveRoot(Placed.Path, Log) Log(f"Removed {Placed.Path}") else: - Path(Placed.Path).unlink(missing_ok=True) + _RemovePath(Path(Placed.Path)) # a folder for a macOS app bundle Log(f"Removed {Placed.Path}") _PutBackDisplaced(Placed, Log) @@ -417,17 +445,9 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip for Template in Wanted: Target = Render(Template.Target, Context) Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context) - Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None - Root = _NeedsRoot(Template, Target) - Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "") - - # 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) + Placed = _Displace(Program, Path(Target), "Template", OldReceipt) + Placed.Root = Root = _NeedsRoot(Template, Target) + if Placed.Displaced and (OldReceipt is None or OldReceipt.FindFile(Target) is None): Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)") 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.""" import WindowsSupport - Previous = OldReceipt.FindFile(str(Link)) if OldReceipt is not None else None - 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) + Placed = _Displace(Program, Link, "Shortcut", OldReceipt) 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) NewReceipt.Files.append(Placed) Log(f"Wrote {Link}") +def _PlaceAppBundle(Program: Program, Link, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, + Log: LogFn) -> None: + """macOS: ~/Applications/.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: - """Windows Start Menu shortcuts ([[Shortcuts]]); the Startup ones for - services are placed by _ApplyServices.""" - if not IsWindows: + """Start Menu shortcuts (Windows) and app bundles (macOS), from + [[Shortcuts]]; the ones starting services at login are placed by + _ApplyServices.""" + if IsLinux: return for Link in Program.Shortcuts: if not Program.OptionEnabled(Link.Option, NewReceipt.Options): continue + if IsMac: + _PlaceAppBundle(Program, Link, Context, OldReceipt, NewReceipt, Log) + continue Script = Program.AppDir / Link.Script Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip() _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 or the program removed (and one that was already there comes back).""" Entry = AutostartDir / f"{Service.Name}.desktop" - Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None - 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) + Placed = _Displace(Program, Entry, "Shortcut", OldReceipt) Entry.parent.mkdir(parents=True, exist_ok=True) Entry.write_text(Services.AutostartEntry(Service.Name, *Command)) NewReceipt.Files.append(Placed) 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: - """Services SaraSuite runs itself (Windows; Linux without systemd): at - login from a Startup-folder shortcut / an autostart entry, through + """Services SaraSuite runs itself (Windows; macOS; Linux without systemd): + 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 it runs the new code.""" 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}"', WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log, ) + elif IsMac: + _PlaceLaunchAgent(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log) else: _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 @@ -632,8 +681,8 @@ def _StopProcesses(Name: str, Log: LogFn) -> None: def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None: - if IsWindows: - return # Start Menu shortcuts need no refresh + if not IsLinux: + return # Start Menu shortcuts and app bundles need no refresh Applications = Path(Program.Context()["ApplicationsDir"]) if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists(): _Run(["update-desktop-database", str(Applications)], Log, Check=False) @@ -773,6 +822,7 @@ def PlanRemoval(Program: Program) -> RemovalPlan: if Current is not None: 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 "background service, stopped and taken out of autostart") for Unit in Current.EnabledServices: @@ -832,7 +882,7 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF _RemovePlacedTemplate(Placed, Log) for Record in Current.IniEdits: _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") Groups = {"Program"} diff --git a/MacSupport.py b/MacSupport.py new file mode 100644 index 0000000..96cd2cf --- /dev/null +++ b/MacSupport.py @@ -0,0 +1,257 @@ +"""macOS stand-ins for what SaraSuite uses on Linux: + + Linux macOS + .desktop entry a small app bundle in ~/Applications (.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/.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) diff --git a/Main.py b/Main.py index 27106c0..33adabe 100644 --- a/Main.py +++ b/Main.py @@ -14,7 +14,7 @@ import flet as ft import SelfUpdate from Catalog import UpdateCatalog 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, ) from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog @@ -26,7 +26,8 @@ from Settings import ShowSettingsDialog _MaxLogLines = 2000 # 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() _AppImage = str(_Packaged) if _Packaged else "" if IsWindows: @@ -43,7 +44,7 @@ def _KeepFallbackLibraries() -> None: ~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet client of every installed program on systems without it.""" Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs" - if IsWindows or not Bundled.is_dir(): + if not IsLinux or not Bundled.is_dir(): return Kept = DataDir / "Libraries" try: @@ -286,7 +287,7 @@ def Main(Page: ft.Page) -> None: MenuButton = ft.OutlinedButton( content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}", style=ChipButtonStyle(), - visible=bool(_AppImage), + visible=bool(_AppImage) and not IsMac, on_click=ToggleMenuEntry, ) @@ -355,7 +356,7 @@ if __name__ == "__main__": sys.exit(Run(sys.argv[1:])) SelfUpdate.CleanUpOldExe() - if not IsWindows: + if IsLinux: # A Flet client copy with the window app ID srhtnin.SaraSuite, so the # desktop matches the window to SaraSuite.desktop (StartupWMClass) instead # of showing it as "com.appveyor.flet". See Programs/FletIdentity.py. diff --git a/Manifests.py b/Manifests.py index 433a008..ece3c78 100644 --- a/Manifests.py +++ b/Manifests.py @@ -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 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 -entries on Linux, Start Menu shortcuts on Windows); others are dropped at load.""" +carry Platform = "Linux", "Windows" or "macOS" to only apply there (e.g. +desktop entries on Linux, Start Menu shortcuts on Windows, app bundles on +macOS); others are dropped at load.""" import importlib.util import re @@ -16,7 +17,7 @@ from typing import Optional import tomlkit 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, ) @@ -86,10 +87,11 @@ class IniEdit: @dataclass class Service: """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, - at login through a Startup-folder shortcut / an XDG autostart entry - (Services.py), so a Linux service gives both Unit and Name/Script.""" + at login through a Startup-folder shortcut / a LaunchAgent / an XDG + autostart entry (Services.py), so a Linux service gives both Unit and + Name/Script.""" Unit: str = "" Option: str = "" # enabled + started only while this option is ticked @@ -109,9 +111,12 @@ class Service: @dataclass class Shortcut: - """Windows: a Start Menu shortcut running Script (relative to the app - folder) with the program's pythonw.exe, from the app folder, with Icon - (a PNG in the app folder, turned into an .ico).""" + """A launcher in the system's app list running Script (relative to the + app folder) with the program's Python, from the app folder, with Icon (a + PNG in the app folder): + - Windows: a Start Menu shortcut (pythonw.exe; the icon becomes an .ico); + - macOS: an app bundle, ~/Applications/.app (the icon becomes an .icns). + Linux uses a desktop entry Template instead.""" Name: str Script: str @@ -235,6 +240,9 @@ class Program: } if IsWindows: Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir)) + elif IsMac: + Context.update(ApplicationsDir=str(Home / "Applications"), + LaunchAgentsDir=str(Home / "Library" / "LaunchAgents")) else: Context.update( SystemdUserDir=str(ConfigHome / "systemd" / "user"), @@ -306,7 +314,7 @@ def LoadProgram(Folder: Path) -> Program: Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))], IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))], 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", []))], Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))], Platforms=Data.get("Platforms", ["Linux"]), @@ -330,7 +338,7 @@ def LoadPrograms() -> list[Program]: Loaded = _LoadFrom(ManifestsDir()) except Exception: 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] Names = {Current.Name for Current in Loaded} Ordered = [] diff --git a/Programs/SaraFem/Manifest.toml b/Programs/SaraFem/Manifest.toml index d3d531f..be4bac5 100644 --- a/Programs/SaraFem/Manifest.toml +++ b/Programs/SaraFem/Manifest.toml @@ -1,6 +1,6 @@ Name = "SaraFem" 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. RestartUnits = ["xdg-desktop-portal.service"] @@ -77,6 +77,13 @@ Name = "SaraFem" Script = "Main.py" 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]] Path = "{CacheHome}/SaraFem" Kind = "Runtime" diff --git a/Programs/SaraSort/Manifest.toml b/Programs/SaraSort/Manifest.toml index a481581..3fb0bef 100644 --- a/Programs/SaraSort/Manifest.toml +++ b/Programs/SaraSort/Manifest.toml @@ -1,7 +1,7 @@ # Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/. Name = "SaraSort" 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." [Source] @@ -31,6 +31,13 @@ Name = "SaraSort" Script = "Main.py" 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/. [[Leftovers]] Path = "{ConfigDir}" diff --git a/Programs/SaraSortd/Manifest.toml b/Programs/SaraSortd/Manifest.toml index e5844fa..3f66a14 100644 --- a/Programs/SaraSortd/Manifest.toml +++ b/Programs/SaraSortd/Manifest.toml @@ -1,6 +1,6 @@ Name = "SaraSortd" 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] File = "SaraSortd.py" @@ -46,10 +46,11 @@ Platform = "Linux" Source = "SaraSortd.service" Target = "{SystemdUserDir}/SaraSortd.service" -# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from -# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through -# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's -# Services.py). On Linux the same service has a Unit for systemd too. +# Windows, macOS, and Linux without systemd (runit, OpenRC, s6, ...): run +# from ConfigDir by ServiceRun.py, which restarts it if it fails, at login +# through a shortcut in the Startup folder / a LaunchAgent / an XDG autostart +# entry (SaraSuite's Services.py). On Linux the same service has a Unit for +# systemd too. [[Templates]] Source = "../../ServiceRun.py" Target = "{ProgramDir}/ServiceRun.py" @@ -69,5 +70,12 @@ Script = "SaraSortd.py" WorkingDir = "{ConfigDir}" 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 # GlobalConf.toml by Hooks.py. diff --git a/Programs/SaraTag/Manifest.toml b/Programs/SaraTag/Manifest.toml index 6b8fb54..77dc037 100644 --- a/Programs/SaraTag/Manifest.toml +++ b/Programs/SaraTag/Manifest.toml @@ -1,6 +1,6 @@ # Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/. 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." [Version] @@ -40,6 +40,13 @@ Name = "SaraTag" Script = "Main.py" 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 # "remove configs" if you don't need them any more. [[Leftovers]] diff --git a/Programs/SaraTune/Manifest.toml b/Programs/SaraTune/Manifest.toml index b453311..25f04b7 100644 --- a/Programs/SaraTune/Manifest.toml +++ b/Programs/SaraTune/Manifest.toml @@ -1,6 +1,6 @@ # Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/. 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)." [Source] @@ -30,6 +30,13 @@ Name = "SaraTune" Script = "Main.py" 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. [[Leftovers]] Path = "{ConfigDir}" diff --git a/Programs/SaraView/Manifest.toml b/Programs/SaraView/Manifest.toml index 2053d9a..ae4fd76 100644 --- a/Programs/SaraView/Manifest.toml +++ b/Programs/SaraView/Manifest.toml @@ -1,6 +1,6 @@ # Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/. 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." [Source] @@ -32,6 +32,13 @@ Name = "SaraView" Script = "Client/Main.py" 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). [[Leftovers]] Path = "{ConfigDir}" diff --git a/Programs/ServiceRun.py b/Programs/ServiceRun.py index bea540f..0be9ea4 100644 --- a/Programs/ServiceRun.py +++ b/Programs/ServiceRun.py @@ -1,8 +1,8 @@ """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 -Linux with another init system (runit on Void, OpenRC, s6, dinit, ...). -Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG -autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort +--user to do it: on Windows (started windowless with pythonw.exe), on macOS, +and on Linux with another init system (runit on Void, OpenRC, s6, dinit, ...). +Started at login (Windows: a shortcut in the Startup folder; macOS: a +LaunchAgent; Linux: an XDG autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort for SaraSortd) starts it: python ServiceRun.py