mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 02:33:17 +02:00
macOS support (code only, no build yet)
This commit is contained in:
1 parent
1feb6d4336
commit
ca04dcd4a7
17 files changed
+572
-126
No files matched your search
+91
-41
@@ -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"}
|
||||
|
||||
Reference in new issue
Block a user