From b5d2fe0f1c94063f4ca9a43b599dafe470334f68 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 17:51:30 +0200 Subject: [PATCH] SaraSortd's service without systemd (runit, OpenRC, ...) Co-Authored-By: Claude Opus 5.5 --- Core.py | 8 +++ README.md | 6 +- Service.py | 170 +++++++++++++++++++++++++++++++++---------------- ServicePage.py | 11 ++-- Settings.py | 6 +- 5 files changed, 137 insertions(+), 64 deletions(-) diff --git a/Core.py b/Core.py index 158d8e1..914a60b 100644 --- a/Core.py +++ b/Core.py @@ -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" diff --git a/README.md b/README.md index 5433643..28bd7f1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Service.py b/Service.py index 3b384a8..5beb188 100644 --- a/Service.py +++ b/Service.py @@ -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 ` + (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: diff --git a/ServicePage.py b/ServicePage.py index f4e2260..b45963b 100644 --- a/ServicePage.py +++ b/ServicePage.py @@ -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 diff --git a/Settings.py b/Settings.py index c04b990..356db32 100644 --- a/Settings.py +++ b/Settings.py @@ -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)