From f63e708572231ac9f030bcbfa6653dac76c1512f Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 17:51:30 +0200 Subject: [PATCH] Background services without systemd (runit, OpenRC, ...) Co-Authored-By: Claude Opus 5.5 --- Core.py | 10 ++- Installer.py | 75 ++++++++++++++----- Manifests.py | 26 +++++-- Programs/SaraSortd/Manifest.toml | 20 +++-- Programs/ServiceRun.py | 101 +++++++++++++++++++++---- README.md | 23 ++++-- Services.py | 123 +++++++++++++++++++++++++++++++ WindowsSupport.py | 57 +------------- 8 files changed, 325 insertions(+), 110 deletions(-) create mode 100644 Services.py diff --git a/Core.py b/Core.py index 60e1822..b0c850a 100644 --- a/Core.py +++ b/Core.py @@ -13,6 +13,7 @@ picked for one Sara program looks the same in all of them. """ import os +import shutil import sys from dataclasses import asdict, dataclass, field from pathlib import Path @@ -64,7 +65,14 @@ ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact remova PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base) BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives -ServicesDir = DataDir / "Services" # Windows: .pid of each running background service +# .pid and .log of each service SaraSuite runs itself (Services.py). +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 +AutostartDir = ConfigHome / "autostart" # Bundled read-only files (program manifests, fonts). Inside a PyInstaller # build they're unpacked to sys._MEIPASS rather than next to the code. diff --git a/Installer.py b/Installer.py index 5c83b6e..fb23a53 100644 --- a/Installer.py +++ b/Installer.py @@ -19,7 +19,11 @@ from pathlib import Path, PurePosixPath from typing import Callable, Optional from ConfigMerge import ApplySet, MergeConfig, MergeResult -from Core import BackupsDir, ChildEnvironment, DownloadsDir, IsWindows, LoadConfig, PristineDir, StartMenuDir, StartupDir +import Services +from Core import ( + AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, IsWindows, LoadConfig, PristineDir, + StartMenuDir, StartupDir, +) from Manifests import Leftover, Program, Render from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt from Sources import ClearDownloads, Download, LatestCommit, SourceError @@ -55,6 +59,8 @@ def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int: + if not HasSystemd: + return 0 # services run through Services.py instead return _Run(["systemctl", "--user", *Args], Log, Check=Check) @@ -499,11 +505,30 @@ def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receip ) -def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None: - """A Startup-folder shortcut runs the service at login (through - ServiceRun.py, which records its PID); enabling also starts it now.""" - import WindowsSupport +def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt], + NewReceipt: Receipt, Log: LogFn) -> None: + """Linux without systemd: the XDG autostart entry that starts a service at + 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) + 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 _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 + 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() Runner = Program.ProgramDir / "ServiceRun.py" for Service in Program.Services: @@ -511,13 +536,19 @@ def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[ WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir if Program.OptionEnabled(Service.Option, NewReceipt.Options): NewReceipt.EnabledServices.append(Service.Id) - _PlaceShortcut( - Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}"', - WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log, - ) - WindowsSupport.StartService(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log) + if IsWindows: + _PlaceShortcut( + Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"', + WorkingDir, None, f"{Service.Name} (background service)", 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 + Services.Restart(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log) + else: + Services.Start(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log) elif Service.Id in WasEnabled: - WindowsSupport.StopService(Service.Name, Log) + Services.Stop(Service.Name, Log) def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None: @@ -525,17 +556,15 @@ def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogF its files or venv are replaced or removed.""" if not IsWindows or Receipt is None: return - import WindowsSupport - for Name in Receipt.EnabledServices: - WindowsSupport.StopService(Name, Log) + Services.Stop(Name, Log) def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn, Context: Optional[dict] = None) -> None: if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices): return - if IsWindows: - _ApplyWindowsServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log) + if IsWindows or not HasSystemd: + _ApplyStandInServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log) return _Systemctl(Log, "daemon-reload") WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set() @@ -557,7 +586,12 @@ def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None: if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists(): _Run(["update-desktop-database", str(Applications)], Log, Check=False) for Unit in Program.RestartUnits: - _Systemctl(Log, "try-restart", Unit) + if HasSystemd: + _Systemctl(Log, "try-restart", Unit) + else: + # No systemd to restart it: stop it, and D-Bus starts it again + # when it's next needed (xdg-desktop-portal is D-Bus activated). + _Run(["pkill", "-x", "-u", str(os.getuid()), Unit.removesuffix(".service")], Log, Check=False) def Apply( @@ -686,7 +720,9 @@ def PlanRemoval(Program: Program) -> RemovalPlan: Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source")) if Current is not None: - ServiceNote = "background service, stopped and taken out of Startup" if IsWindows else "systemd user service, stopped and disabled" + ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows + else "systemd user service, stopped and disabled" if HasSystemd + else "background service, stopped and taken out of autostart") for Unit in Current.EnabledServices: Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote)) for Placed in Current.Files: @@ -733,6 +769,9 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF if Current is not None: if IsWindows: _StopWindowsServices(Program, Current, Log) + elif not HasSystemd: + for Name in Current.EnabledServices: + Services.Stop(Name, Log) else: for Unit in Current.EnabledServices: _Systemctl(Log, "disable", "--now", Unit) diff --git a/Manifests.py b/Manifests.py index dbca4f7..031c20b 100644 --- a/Manifests.py +++ b/Manifests.py @@ -16,8 +16,8 @@ from typing import Optional import tomlkit from Core import ( - CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, IsWindows, LoadConfig, Platform, - ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, + AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig, + Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, ) # Never worth copying out of a source checkout, whatever Include says. @@ -83,19 +83,26 @@ class IniEdit: @dataclass class Service: - """A background service. Linux: a systemd --user Unit (placed by a - Template). Windows: Script (relative to the app folder) run windowless by - ServiceRun.py in WorkingDir, at login through a Startup-folder shortcut.""" + """A background service. Linux with systemd: a --user Unit (placed by a + Template). Windows, 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.""" Unit: str = "" Option: str = "" # enabled + started only while this option is ticked - Name: str = "" # Windows: the service's name (PID file, Startup shortcut) + Name: str = "" # run by SaraSuite: the service's name (PID file, log, autostart) Script: str = "" WorkingDir: str = "" + @property + def RunBySaraSuite(self) -> bool: + return IsWindows or not HasSystemd + @property def Id(self) -> str: - return self.Unit or self.Name + """What the receipt records: the unit under systemd, else the name.""" + return (self.Name or self.Unit) if self.RunBySaraSuite else (self.Unit or self.Name) @dataclass @@ -229,6 +236,7 @@ class Program: else: Context.update( SystemdUserDir=str(ConfigHome / "systemd" / "user"), + AutostartDir=str(AutostartDir), ApplicationsDir=str(DataHome / "applications"), DBusServicesDir=str(DataHome / "dbus-1" / "services"), ) @@ -282,7 +290,9 @@ def LoadProgram(Folder: Path) -> Program: RequirementsFile=Python.get("RequirementsFile", ""), Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))], Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))], - Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))], + # Without systemd, its unit files would just sit there unused. + Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", [])) + if HasSystemd or "{SystemdUserDir}" not in Item.get("Target", "")], 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", []))], diff --git a/Programs/SaraSortd/Manifest.toml b/Programs/SaraSortd/Manifest.toml index 505abc3..e5844fa 100644 --- a/Programs/SaraSortd/Manifest.toml +++ b/Programs/SaraSortd/Manifest.toml @@ -40,24 +40,28 @@ Target = "{ConfigDir}/InputDirConf.toml" Source = "OutputDirConf.toml" Target = "{ConfigDir}/OutputDirConf.toml" -# Linux: a systemd user service. +# Linux with systemd: a systemd user service. [[Templates]] 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. +[[Templates]] +Source = "../../ServiceRun.py" +Target = "{ProgramDir}/ServiceRun.py" + [[Services]] Platform = "Linux" Unit = "SaraSortd.service" +Name = "SaraSortd" +Script = "SaraSortd.py" +WorkingDir = "{ConfigDir}" Option = "Autostart" -# Windows: run windowless from ConfigDir by ServiceRun.py, at login through a -# shortcut in the Startup folder (see SaraSuite's WindowsSupport.py). -[[Templates]] -Platform = "Windows" -Source = "../../ServiceRun.py" -Target = "{ProgramDir}/ServiceRun.py" - [[Services]] Platform = "Windows" Name = "SaraSortd" diff --git a/Programs/ServiceRun.py b/Programs/ServiceRun.py index 7fd1357..bea540f 100644 --- a/Programs/ServiceRun.py +++ b/Programs/ServiceRun.py @@ -1,33 +1,104 @@ -"""Runs a SaraSuite program as a background service on Windows, the stand-in -for a systemd --user service. Started windowless with pythonw.exe, both at -login (by the program's shortcut in the Startup folder) and when SaraSuite -or the program's own GUI (e.g. SaraSort for SaraSortd) starts it: +"""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 +for SaraSortd) starts it: - pythonw.exe ServiceRun.py