mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
Background services without systemd (runit, OpenRC, ...)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e7e233951f
commit
f63e708572
8 files changed
+319
-104
No files matched your search
@@ -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.
|
||||
|
||||
+53
-14
@@ -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)
|
||||
if IsWindows:
|
||||
_PlaceShortcut(
|
||||
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}"',
|
||||
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
|
||||
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
|
||||
)
|
||||
WindowsSupport.StartService(Service.Name, Program.PythonW, Runner, Script, WorkingDir, 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:
|
||||
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
@@ -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", []))],
|
||||
|
||||
@@ -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"
|
||||
|
||||
+84
-13
@@ -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:
|
||||
while True:
|
||||
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} starting {Script.name}")
|
||||
try:
|
||||
sys.argv = [str(Script)]
|
||||
sys.path.insert(0, str(Script.parent))
|
||||
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()):
|
||||
|
||||
@@ -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
@@ -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
@@ -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 -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user