macOS support (code only, no build yet)

This commit is contained in:
sarah committed 2026-10-14 13:18:20 +02:00
1 parent 1feb6d4336
commit ca04dcd4a7
17 files changed
+572 -126

No files matched your search

+91 -41
View File
@@ -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/<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:
"""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"}