mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-13 18:28:18 +02:00
SaraSortd's service without systemd (runit, OpenRC, ...)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
826216cd03
commit
b5d2fe0f1c
5 files changed
+137
-64
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user