SaraSortd's service 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 826216cd03
commit b5d2fe0f1c
5 files changed
+133 -60

No files matched your search

+8
View File
@@ -6,6 +6,7 @@ one Sara program looks the same in all of them.
""" """
import os import os
import shutil
import sys import sys
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
@@ -31,6 +32,13 @@ else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
# Linux: whether systemd runs the system (and so SaraSortd's --user unit),
# the same check as SaraSuite's. Without it (runit on Void, OpenRC, ...),
# SaraSuite runs SaraSortd itself, the way it does on Windows (Service.py).
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
RunBySaraSuite = IsWindows or not HasSystemd
AutostartDir = ConfigHome / "autostart"
SharedConfigDir = ConfigHome / "SaraSuite" SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes" ThemesDir = SharedConfigDir / "Themes"
SharedConfigPath = SharedConfigDir / "Config.toml" SharedConfigPath = SharedConfigDir / "Config.toml"
+4 -2
View File
@@ -2,7 +2,8 @@
A GUI for configuring [SaraSortd](https://git.srhtnin.garden/sarah/SaraSortd), built with Flet. A GUI for configuring [SaraSortd](https://git.srhtnin.garden/sarah/SaraSortd), built with Flet.
SaraSort doesn't sort anything itself: SaraSortd stays the backend, and SaraSort reads and edits its SaraSort doesn't sort anything itself: SaraSortd stays the backend, and SaraSort reads and edits its
configs and controls its systemd user service. configs and controls its systemd user service (or, without systemd, the service SaraSuite runs
for it: see SaraSuite's README).
Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `ValidInputDirs`). Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `ValidInputDirs`).
@@ -23,7 +24,8 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `
Patterns, Edit Rules, Add Patterns, Copy Patterns, Save as Template, Edit Output Config, Remove Arrow. Patterns, Edit Rules, Add Patterns, Copy Patterns, Save as Template, Edit Output Config, Remove Arrow.
The background: Add Directory, Edit Mode, Tidy Layout, Reload. The background: Add Directory, Edit Mode, Tidy Layout, Reload.
- **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the - **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the
SaraSuite install it came from), and its logs: its own log files or the systemd journal. SaraSuite install it came from), and its logs: its own log files, or the systemd journal (the
service log, where SaraSuite runs it: Windows, and Linux without systemd).
- **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with - **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with
help text, a live NewFileName preview and variable buttons) or as raw TOML. Saving only writes the help text, a live NewFileName preview and variable buttons) or as raw TOML. Saving only writes the
values you changed, so SaraSortd's own NextNum/NextChar/LastFile updates aren't overwritten. values you changed, so SaraSortd's own NextNum/NextChar/LastFile updates aren't overwritten.
+112 -50
View File
@@ -1,22 +1,29 @@
"""SaraSortd as a background service: status, start/stop/restart, autostart, """SaraSortd as a background service: status, start/stop/restart, autostart,
and its journal. SaraSortd's own log files are read from its LogDir. and its logs. SaraSortd's own log files are read from its LogDir.
Linux: its systemd user unit (systemctl / journalctl). Linux with systemd: its systemd user unit (systemctl / journalctl).
Windows: SaraSuite's stand-in for a systemd service, used the same way here: Windows, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py` it itself (its Services.py), and this does the same:
from SaraSortd's config folder, and ServiceRun.py records its PID in - it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told); (pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its
- autostart is a shortcut to that command in the Startup folder. output in SaraSuite's Services folder as SaraSortd.log (the "Service log"
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows. here) and records its PID in SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder (Windows)
or an XDG autostart entry, ~/.config/autostart/SaraSortd.desktop (Linux).
Unit is the systemd unit name ("SaraSortd.service"); run by SaraSuite, the
service is called the same without ".service".
""" """
import os
import re import re
import signal
import subprocess import subprocess
import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig, RunBySaraSuite
@dataclass @dataclass
@@ -41,90 +48,142 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow) creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------ # --- Run by SaraSuite (Windows; Linux without systemd) ------------------------------------------
def _PidFile(Name: str) -> Path: def _Name(Unit: str) -> str:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid" return Unit.removesuffix(".service")
def _StartupLink(Name: str) -> Path: def _ServicesFile(Name: str, Suffix: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}{Suffix}"
def _AutostartFile(Name: str) -> Path:
if IsWindows:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk" return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
return AutostartDir / f"{Name}.desktop"
def _WindowsCommand() -> tuple[Path, list[str], Path]: def _Command(Name: str) -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout.""" """(python, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig() Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser() ProgramDir = Path(Config.SortdAppDir).expanduser().parent
ProgramDir = AppDir.parent Folder = Path(Config.SortdConfigDir).expanduser()
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe" if IsWindows:
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)] Python = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser() Arguments = ["-X", "utf8"]
else:
Python = ProgramDir / ".venv" / "bin" / "python"
Arguments = []
Arguments += [str(ProgramDir / "ServiceRun.py"), Name, str(Config.SortdScriptPath), str(Folder)]
return Python, Arguments, Folder
def _WindowsPid(Name: str) -> str: def _Pid(Name: str) -> str:
try:
Pid = int(_ServicesFile(Name, ".pid").read_text().strip())
except (OSError, ValueError):
return ""
if not IsWindows:
try:
return str(Pid) if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else ""
except OSError:
return ""
try: try:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return "" return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else "" return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus: def _OwnStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand() Python, Arguments, _Folder = _Command(Name)
Pid = _WindowsPid(Name) Pid = _Pid(Name)
Since = "" Since = ""
if Pid: if Pid:
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") Since = datetime.fromtimestamp(_ServicesFile(Name, ".pid").stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
Runner = Path(Arguments[-4])
return ServiceStatus( return ServiceStatus(
Active="active" if Pid else "inactive", Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead", Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled", Enabled="enabled" if _AutostartFile(Name).exists() else "disabled",
Pid=Pid, Pid=Pid,
Since=Since, Since=Since,
Found=PythonW.is_file() and Path(Arguments[2]).is_file(), Found=Python.is_file() and Runner.is_file(),
) )
def _WindowsControl(Name: str, Action: str) -> str: def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
PythonW, Arguments, Folder = _WindowsCommand() """The same entry SaraSuite writes (its Services.AutostartEntry)."""
def Quote(Text: str) -> str:
Text = Text.replace("%", "%%")
return Text if re.fullmatch(r"[\w@%+=:,./-]+", Text) else '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
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={' '.join(Quote(Part) for Part in [str(Python), *Arguments])}\n"
"Terminal=false\n"
"NoDisplay=true\n"
"X-GNOME-Autostart-enabled=true\n"
)
def _OwnControl(Name: str, Action: str) -> str:
Python, Arguments, Folder = _Command(Name)
try: try:
if Action in ("stop", "restart"): if Action in ("stop", "restart"):
Pid = _WindowsPid(Name) Pid = _Pid(Name)
if Pid: if Pid and IsWindows:
_Run(["taskkill", "/PID", Pid, "/T", "/F"]) _Run(["taskkill", "/PID", Pid, "/T", "/F"])
_PidFile(Name).unlink(missing_ok=True) elif Pid:
os.kill(int(Pid), signal.SIGTERM) # ServiceRun.py finishes cleanly
for _ in range(50):
if not _Pid(Name):
break
time.sleep(0.2)
else:
os.kill(int(Pid), signal.SIGKILL)
_ServicesFile(Name, ".pid").unlink(missing_ok=True)
if Action in ("start", "restart"): if Action in ("start", "restart"):
if _WindowsPid(Name): if _Pid(Name):
return "" return ""
Folder.mkdir(parents=True, exist_ok=True) Folder.mkdir(parents=True, exist_ok=True)
Extra = ({"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow} if IsWindows
else {"start_new_session": True})
subprocess.Popen( subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True, [str(Python), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
) )
elif Action == "enable": elif Action == "enable":
Entry = _AutostartFile(Name)
Entry.parent.mkdir(parents=True, exist_ok=True)
if IsWindows:
import pylnk3 import pylnk3
Link = _StartupLink(Name)
Link.parent.mkdir(parents=True, exist_ok=True)
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments) Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder), pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)") description=f"{Name} (background service)")
else:
Entry.write_text(_AutostartEntry(Python, Arguments, Name))
elif Action == "disable": elif Action == "disable":
_StartupLink(Name).unlink(missing_ok=True) _AutostartFile(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error: except (OSError, subprocess.SubprocessError) as Error:
return str(Error) return str(Error)
return "" return ""
# --- Both ----------------------------------------------------------------------------------- # --- Both ------------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus: def Status(Unit: str) -> ServiceStatus:
if IsWindows: if RunBySaraSuite:
return _WindowsStatus(Unit) return _OwnStatus(_Name(Unit))
try: try:
Result = _Run(["systemctl", "--user", "show", Unit, Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"]) "-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
@@ -143,8 +202,8 @@ def Status(Unit: str) -> ServiceStatus:
def Control(Unit: str, Action: str) -> str: def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error.""" """Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows: if RunBySaraSuite:
return _WindowsControl(Unit, Action) return _OwnControl(_Name(Unit), Action)
try: try:
Result = _Run(["systemctl", "--user", Action, Unit]) Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error: except (OSError, subprocess.SubprocessError) as Error:
@@ -160,9 +219,12 @@ def RestartIfRunning(Unit: str) -> str:
def Journal(Unit: str, Lines: int) -> list[str]: def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows: """Its systemd journal, or (run by SaraSuite) ServiceRun.py's log of its output."""
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) " if RunBySaraSuite:
"have everything it does."] LogPath = _ServicesFile(_Name(Unit), ".log")
if not LogPath.is_file():
return [f"No service log yet ({LogPath}): it's written once the service has run."]
return ReadTail(LogPath, Lines)
try: try:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"]) Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
except (OSError, subprocess.SubprocessError) as Error: except (OSError, subprocess.SubprocessError) as Error:
+6 -5
View File
@@ -1,5 +1,6 @@
"""SaraSortd's service: status, start/stop/restart, start on login, its """SaraSortd's service: status, start/stop/restart, start on login, its
version, and its logs (its own log files and the systemd journal).""" version, and its logs (its own log files, and the systemd journal or, where
SaraSuite runs it, its service log; see Service.py)."""
import asyncio import asyncio
from pathlib import Path from pathlib import Path
@@ -7,7 +8,7 @@ from pathlib import Path
import flet as ft import flet as ft
import tomlkit import tomlkit
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette, RunBySaraSuite
from Service import Control, Journal, LogFiles, ReadTail, Status from Service import Control, Journal, LogFiles, ReadTail, Status
from Widgets import App from Widgets import App
@@ -44,7 +45,7 @@ class ServicePage(ft.Column):
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT) self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files")) self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files"))
self.JournalButton = ChipButton("Journal", lambda e: self._SetSource("Journal")) self.JournalButton = ChipButton("Service log" if RunBySaraSuite else "Journal", lambda e: self._SetSource("Journal"))
self.FilePicker = ft.Dropdown(label="Log file", dense=True, width=260, on_select=lambda e: self._LoadLog()) self.FilePicker = ft.Dropdown(label="Log file", dense=True, width=260, on_select=lambda e: self._LoadLog())
self.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog()) self.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
self.Follow = ft.Checkbox(label="Follow", value=True) self.Follow = ft.Checkbox(label="Follow", value=True)
@@ -105,10 +106,10 @@ class ServicePage(ft.Column):
self.StateText.value = Text self.StateText.value = Text
self.StateText.color = Color self.StateText.color = Color
self.Details.controls = [ self.Details.controls = [
Note(f"{'Service' if IsWindows else 'Unit'}: {self.App.Config.ServiceName}"), Note(f"{'Service' if RunBySaraSuite else 'Unit'}: {self.App.Config.ServiceName.removesuffix('.service') if RunBySaraSuite else self.App.Config.ServiceName}"),
Note(f"Since: {Current.Since or '-'}"), Note(f"Since: {Current.Since or '-'}"),
Note(f"PID: {Current.Pid or '-'}"), Note(f"PID: {Current.Pid or '-'}"),
Note(f"{'Autostart (Startup folder)' if IsWindows else 'Unit file'}: {Current.Enabled}"), Note(f"{'Autostart (Startup folder)' if IsWindows else 'Autostart (at login)' if RunBySaraSuite else 'Unit file'}: {Current.Enabled}"),
] ]
self.StartButton.disabled = Current.Running or not Current.Found self.StartButton.disabled = Current.Running or not Current.Found
self.StopButton.disabled = not Current.Running self.StopButton.disabled = not Current.Running
+3 -3
View File
@@ -11,8 +11,8 @@ import flet as ft
import tomlkit import tomlkit
from Core import ( from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home, AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, HasSystemd,
ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings, Home, ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
) )
from Sortd import MajorVersion, Setup, SortdError from Sortd import MajorVersion, Setup, SortdError
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
@@ -137,7 +137,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir) ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir)
AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir) AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir)
UnitField = ft.TextField(label="systemd user unit", value=Config.ServiceName) UnitField = ft.TextField(label="systemd user unit" if HasSystemd else "Service name", value=Config.ServiceName)
AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart) AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart)
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds) RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
LogLinesField = _NumberField("Log lines to show", Config.LogLines) LogLinesField = _NumberField("Log lines to show", Config.LogLines)