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
+137 -64

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 shutil
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
@@ -31,6 +32,13 @@ else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
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"
ThemesDir = SharedConfigDir / "Themes"
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.
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`).
@@ -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.
The background: Add Directory, Edit Mode, Tidy Layout, Reload.
- **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
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.
+116 -54
View File
@@ -1,22 +1,29 @@
"""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).
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
from SaraSortd's config folder, and ServiceRun.py records its PID in
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder.
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
Linux with systemd: its systemd user unit (systemctl / journalctl).
Windows, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs
it itself (its Services.py), and this does the same:
- it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
(pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its
output in SaraSuite's Services folder as SaraSortd.log (the "Service log"
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 signal
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig, RunBySaraSuite
@dataclass
@@ -41,90 +48,142 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------
# --- Run by SaraSuite (Windows; Linux without systemd) ------------------------------------------
def _PidFile(Name: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
def _Name(Unit: str) -> str:
return Unit.removesuffix(".service")
def _StartupLink(Name: str) -> Path:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
def _ServicesFile(Name: str, Suffix: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}{Suffix}"
def _WindowsCommand() -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
def _AutostartFile(Name: str) -> Path:
if IsWindows:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
return AutostartDir / f"{Name}.desktop"
def _Command(Name: str) -> tuple[Path, list[str], Path]:
"""(python, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser()
ProgramDir = AppDir.parent
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
ProgramDir = Path(Config.SortdAppDir).expanduser().parent
Folder = Path(Config.SortdConfigDir).expanduser()
if IsWindows:
Python = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
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:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError):
except (OSError, subprocess.SubprocessError):
return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand()
Pid = _WindowsPid(Name)
def _OwnStatus(Name: str) -> ServiceStatus:
Python, Arguments, _Folder = _Command(Name)
Pid = _Pid(Name)
Since = ""
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(
Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
Enabled="enabled" if _AutostartFile(Name).exists() else "disabled",
Pid=Pid,
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:
PythonW, Arguments, Folder = _WindowsCommand()
def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
"""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:
if Action in ("stop", "restart"):
Pid = _WindowsPid(Name)
if Pid:
Pid = _Pid(Name)
if Pid and IsWindows:
_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 _WindowsPid(Name):
if _Pid(Name):
return ""
Folder.mkdir(parents=True, exist_ok=True)
Extra = ({"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow} if IsWindows
else {"start_new_session": True})
subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
[str(Python), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
)
elif Action == "enable":
import pylnk3
Entry = _AutostartFile(Name)
Entry.parent.mkdir(parents=True, exist_ok=True)
if IsWindows:
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)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
else:
Entry.write_text(_AutostartEntry(Python, Arguments, Name))
elif Action == "disable":
_StartupLink(Name).unlink(missing_ok=True)
_AutostartFile(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return ""
# --- Both -----------------------------------------------------------------------------------
# --- Both ------------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus:
if IsWindows:
return _WindowsStatus(Unit)
if RunBySaraSuite:
return _OwnStatus(_Name(Unit))
try:
Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
@@ -143,8 +202,8 @@ def Status(Unit: str) -> ServiceStatus:
def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows:
return _WindowsControl(Unit, Action)
if RunBySaraSuite:
return _OwnControl(_Name(Unit), Action)
try:
Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error:
@@ -160,9 +219,12 @@ def RestartIfRunning(Unit: str) -> str:
def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows:
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
"have everything it does."]
"""Its systemd journal, or (run by SaraSuite) ServiceRun.py's log of its output."""
if RunBySaraSuite:
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:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
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
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
from pathlib import Path
@@ -7,7 +8,7 @@ from pathlib import Path
import flet as ft
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 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.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.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
self.Follow = ft.Checkbox(label="Follow", value=True)
@@ -105,10 +106,10 @@ class ServicePage(ft.Column):
self.StateText.value = Text
self.StateText.color = Color
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"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.StopButton.disabled = not Current.Running
+3 -3
View File
@@ -11,8 +11,8 @@ import flet as ft
import tomlkit
from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, HasSystemd,
Home, ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
)
from Sortd import MajorVersion, Setup, SortdError
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)
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)
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
LogLinesField = _NumberField("Log lines to show", Config.LogLines)