Background services without systemd (runit, OpenRC, ...)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 17:51:30 +02:00
1 parent e7e233951f
commit f63e708572
8 files changed
+325 -110

No files matched your search

+9 -1
View File
@@ -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: <Name>.pid of each running background service
# <Name>.pid and <Name>.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.
+57 -18
View File
@@ -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)
+18 -8
View File
@@ -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", []))],
+12 -8
View File
@@ -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"
+86 -15
View File
@@ -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 <Name> <Script> (in the service's working folder)
python ServiceRun.py <Name> <Script> [<WorkingDir>]
It records its PID in %LOCALAPPDATA%\\SaraSuite\\Services\\<Name>.pid, so
anything can tell whether the service runs, and stop it (taskkill), without
a Windows service and without admin rights. Installed next to each program
that has a Windows service (Programs/<Name>/Manifest.toml, [[Services]]).
Like the systemd unit it stands in for: one copy at a time; restarted 10
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
anything can tell whether it runs, and stop it, without admin rights. Both
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
on Windows, ~/.local/share/SaraSuite/Services on Linux.
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
"""
import os
import runpy
import signal
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
IsWindows = sys.platform == "win32"
RestartSeconds = 10 # the unit's RestartSec
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
Stopping = False
def ServicesDir() -> Path:
if IsWindows:
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
else:
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return Base / "SaraSuite" / "Services"
def _AlreadyRunning(PidFile: Path) -> bool:
"""Linux: whether the PID file's process is another ServiceRun.py still
running (not a reused PID). Windows relies on whoever starts it to check."""
if IsWindows:
return False
try:
Pid = int(PidFile.read_text().strip())
if Pid == os.getpid():
return False
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
except (OSError, ValueError):
return False
def _Stop(Signal, Frame) -> None:
global Stopping
Stopping = True
raise SystemExit(0)
def Main() -> None:
Name, Script = sys.argv[1], Path(sys.argv[2])
LocalAppData = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
PidFile = LocalAppData / "SaraSuite" / "Services" / f"{Name}.pid"
PidFile.parent.mkdir(parents=True, exist_ok=True)
if len(sys.argv) > 3:
Folder = Path(sys.argv[3])
Folder.mkdir(parents=True, exist_ok=True)
os.chdir(Folder)
Folder = ServicesDir()
Folder.mkdir(parents=True, exist_ok=True)
# Not an f-string: SaraSuite fills in braced names like that one when it places this file.
PidFile = Folder / (Name + ".pid")
if _AlreadyRunning(PidFile):
return
LogFile = Folder / (Name + ".log")
if LogFile.exists() and LogFile.stat().st_size > MaxLogBytes:
LogFile.replace(LogFile.with_suffix(".log.1"))
sys.stdout = sys.stderr = open(LogFile, "a", buffering=1, encoding="utf-8", errors="replace")
PidFile.write_text(str(os.getpid()))
if not IsWindows:
signal.signal(signal.SIGTERM, _Stop) # a stop: finish cleanly, don't restart
sys.path.insert(0, str(Script.parent))
try:
sys.argv = [str(Script)]
sys.path.insert(0, str(Script.parent))
runpy.run_path(str(Script), run_name="__main__")
while True:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} starting {Script.name}")
try:
sys.argv = [str(Script)]
runpy.run_path(str(Script), run_name="__main__")
break
except SystemExit as Exit:
if Stopping or Exit.code in (None, 0):
break
print(f"== {Script.name} exited with {Exit.code}")
except Exception:
traceback.print_exc()
print(f"== restarting in {RestartSeconds} seconds")
time.sleep(RestartSeconds)
finally:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} stopped")
# Only remove it if it's still ours (a newer start may have replaced it).
try:
if PidFile.read_text().strip() == str(os.getpid()):
+18 -5
View File
@@ -6,7 +6,8 @@ It's one AppImage file: download it, run it, pick a program.
- **Install**: downloads the program's latest commit from its git repo, copies it into
`~/.local/share/SaraSuite/Programs/<Name>/` with its own Python venv, places its configs in
`~/.config/SaraSuite/<Name>/`, and writes its desktop entries, systemd user units, D-Bus
services and so on.
services and so on. Without systemd (runit on Void, OpenRC, s6, dinit, ...), background
services run the way they do on Windows: see the table below.
- **Update**: shown when the repo has a newer commit. Program files and integration files are
replaced; configs are *merged*: your edits always win, defaults you never touched follow the
new version, new keys are added (see `ConfigMerge.py`).
@@ -90,6 +91,15 @@ places it. It reapplies the installed commit, so the program itself isn't update
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
| desktop entry | Start Menu shortcut (`[[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` |
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
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the
init system is. It restarts a service 10 seconds after it fails, like the unit's
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process
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) |
@@ -193,13 +203,16 @@ Option = "Autostart" # optional: only while the option is ticked
Root = false # true: outside $HOME, written through pkexec
Executable = false
[[Services]] # Linux: systemd --user units, enabled + started
Platform = "Linux"
Unit = "Example.service"
[[Services]] # Linux: a systemd --user unit, enabled + started; without
Platform = "Linux" # systemd, Name/Script/WorkingDir run by ServiceRun.py instead,
Unit = "Example.service" # started at login by an autostart entry
Name = "Example"
Script = "Example.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
Platform = "Windows" # (also add ../../ServiceRun.py as a Template to {ProgramDir})
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir})
Name = "Example"
Script = "Example.py" # relative to the app folder
WorkingDir = "{ConfigDir}"
+123
View File
@@ -0,0 +1,123 @@
"""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,
s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script>
<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
- Windows: a shortcut in the Startup folder;
- Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which
desktop sessions start whatever the init system is.
The installer places those (Installer._ApplyStandInServices); this starts,
stops and checks the services. SaraSort's Service.py does the same for SaraSortd.
"""
import os
import re
import signal
import subprocess
import time
from pathlib import Path
from typing import Callable, Optional
from Core import ChildEnvironment, IsWindows, ServicesDir
LogFn = Callable[[str], None]
# Windows: child processes of a windowed program would each flash a console window.
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
def _PidFile(Name: str) -> Path:
return ServicesDir / f"{Name}.pid"
def LogFile(Name: str) -> Path:
return ServicesDir / f"{Name}.log"
def ServicePid(Name: str) -> Optional[int]:
"""The PID ServiceRun.py recorded, if that process is still it."""
try:
Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError):
return None
if not IsWindows:
try:
return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None
except OSError:
return None
try:
Output = subprocess.run(
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if re.search(rf'"{Pid}"', Output) else None
def Command(Python: Path, Runner: Path, Name: str, Script: Path, WorkingDir: Path) -> list[str]:
"""What runs the service (Python: pythonw.exe on Windows)."""
return [str(Python), str(Runner), Name, str(Script), str(WorkingDir)]
def Start(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
"""Start a service now (it also starts at every login, the same way)."""
if ServicePid(Name) is not None:
Log(f"{Name} is already running")
return
WorkingDir.mkdir(parents=True, exist_ok=True)
Extra = {"creationflags": _Detached | NoWindow} if IsWindows else {"start_new_session": True}
subprocess.Popen(
Command(Python, Runner, Name, Script, WorkingDir), cwd=str(WorkingDir), env=ChildEnvironment(),
close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
)
Log(f"Started {Name}")
def Stop(Name: str, Log: LogFn) -> None:
Pid = ServicePid(Name)
if Pid is not None:
if IsWindows:
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
else:
# ServiceRun.py finishes cleanly on SIGTERM; one that hangs is killed.
os.kill(Pid, signal.SIGTERM)
for _ in range(50):
if ServicePid(Name) is None:
break
time.sleep(0.2)
else:
os.kill(Pid, signal.SIGKILL)
Log(f"Stopped {Name}")
_PidFile(Name).unlink(missing_ok=True)
def Restart(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
Stop(Name, Log)
Start(Name, Python, Runner, Script, WorkingDir, Log)
def _ExecArgument(Text: str) -> str:
"""One argument for a desktop entry's Exec key (the spec's quoting)."""
Text = Text.replace("%", "%%")
if re.fullmatch(r"[\w@%+=:,./-]+", Text):
return Text
return '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
def AutostartEntry(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path) -> str:
"""Linux: the XDG autostart entry that starts the service at login."""
Exec = " ".join(_ExecArgument(Part) for Part in Command(Python, Runner, Name, Script, WorkingDir))
return (
"# Written by SaraSuite: starts this background service at login on systems\n"
"# without systemd (runit, OpenRC, s6, ...). Removed with the program.\n"
"[Desktop Entry]\n"
"Type=Application\n"
f"Name={Name}\n"
f"Comment={Name} (background service, run by SaraSuite)\n"
f"Exec={Exec}\n"
"Terminal=false\n"
"NoDisplay=true\n"
"X-GNOME-Autostart-enabled=true\n"
)
+2 -55
View File
@@ -3,7 +3,7 @@
Linux Windows
.desktop entry Start Menu shortcut (.lnk)
systemd --user service shortcut in the Startup folder, started and stopped
through Programs/ServiceRun.py (which records its PID)
through Programs/ServiceRun.py (Services.py)
system python3 the py launcher / python.exe, else winget installs it
fc-list the Fonts registry key
@@ -17,13 +17,12 @@ import subprocess
from pathlib import Path
from typing import Callable, Optional
from Core import DataHome, ServicesDir
from Core import DataHome
LogFn = Callable[[str], None]
# Child processes of a windowed program would each flash a console window.
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
PythonVersion = "3.14" # what winget installs when there's no Python yet
_WingetId = f"Python.Python.{PythonVersion}"
@@ -115,58 +114,6 @@ def InstallPython(Log: LogFn) -> Optional[str]:
return FindPython()
# --- Background services (SaraSortd) ---------------------------------------------------
def _PidFile(Name: str) -> Path:
return ServicesDir / f"{Name}.pid"
def ServicePid(Name: str) -> Optional[int]:
"""The PID ServiceRun.py recorded, if that process is still alive."""
try:
Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError):
return None
try:
Output = subprocess.run(
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if re.search(rf'"{Pid}"', Output) else None
def StartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
"""Start a background service now (it's also started at every login by
its Startup shortcut, which runs the same command)."""
if ServicePid(Name) is not None:
Log(f"{Name} is already running")
return
WorkingDir.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[str(PythonW), str(Runner), Name, str(Script)], cwd=str(WorkingDir),
creationflags=_Detached | NoWindow, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
Log(f"Started {Name}")
def StopService(Name: str, Log: LogFn) -> None:
Pid = ServicePid(Name)
if Pid is not None:
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
Log(f"Stopped {Name}")
_PidFile(Name).unlink(missing_ok=True)
def RestartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
if ServicePid(Name) is not None:
StopService(Name, Log)
StartService(Name, PythonW, Runner, Script, WorkingDir, Log)
# --- Fonts -----------------------------------------------------------------------------------