From bf3ac4af1a4f4258c2e041eca7866e3ba7272595 Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 09:55:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(m9):=20systemd=20--user=20trigger=20modes?= =?UTF-8?q?=20+=20game-launch=20watcher=20=E2=80=94=200.23.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D6 trigger modes, no root: - core/service.py: write/enable `systemd --user` units; apply_mode(manual/ always-on/game-launch) reconciles the recorder + watcher services; status(). - core/watcher.py + `rigdoctor watch`: poll Steam RunningAppID, auto-bracket a focused capture (D12 zero-config fallback; wrapper stays primary). - CLI `rigdoctor service status|mode`; config `trigger_mode`. - GUI Settings: "Recording trigger" dropdown (Apply runs apply_mode off-thread). - Tests for unit generation, mode reconciliation, watcher transitions/parse. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 +++ docs/ROADMAP.md | 8 ++- pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 40 +++++++++++ src/rigdoctor/config.py | 1 + src/rigdoctor/core/service.py | 118 ++++++++++++++++++++++++++++++++ src/rigdoctor/core/watcher.py | 107 +++++++++++++++++++++++++++++ src/rigdoctor/gui/setup_page.py | 52 +++++++++++++- tests/test_service.py | 58 ++++++++++++++++ tests/test_watcher.py | 69 +++++++++++++++++++ 11 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 src/rigdoctor/core/service.py create mode 100644 src/rigdoctor/core/watcher.py create mode 100644 tests/test_service.py create mode 100644 tests/test_watcher.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61199f5..1c38622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.23.0] - 2026-05-22 +### Added +- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**, + **always-on** (a background service records continuously), and **game-launch** (auto-records + while a Steam game runs). Set it from **Settings → Recording trigger** or + `rigdoctor service mode `; `rigdoctor service status` shows it. + `core/service.py` writes/enables the user units. +- **Zero-config game-launch watcher** (`core/watcher.py`, `rigdoctor watch`) — polls Steam's + RunningAppID and brackets a focused capture around the running game (the D12 fallback for users + who don't add the `wrap` launch option; the wrapper stays the precise primary path). + ## [0.22.0] - 2026-05-22 ### Added - **M6 breadth.** Environment checks now also report **GPU PowerMizer** mode (NVIDIA, X — flags diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e8caf29..f4f556f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,9 +61,11 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user` service. - [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install - (`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`** - (no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection - config + `systemd --user` service enable + trigger-mode pick. + (`rigdoctor install`, GUI Settings); **user-local `install.sh` + self-extracting `.run`** + (no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger + modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI + Settings "Recording trigger") incl. the zero-config **game-launch watcher** + (`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install. - [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI ## Phase 5 — Breadth (later) diff --git a/pyproject.toml b/pyproject.toml index 0b7dc81..c8aac31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.22.0" +version = "0.23.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index a7a8c57..39487d5 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.22.0" +__version__ = "0.23.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index 182ba41..c343cee 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -416,6 +416,34 @@ def cmd_wrap(args) -> int: return wrap.run(args.command) +def cmd_watch(args) -> int: + from .core import watcher + + interval = args.interval or load_config().get("interval", 1.0) + print("Watching for a running Steam game (Ctrl-C to stop)…") + return watcher.watch(interval=max(2.0, interval)) + + +def cmd_service(args) -> int: + from .core import service + + sub = args.service_cmd or "status" + if sub == "mode": + ok, msg = service.apply_mode(args.mode) + print(f"Trigger mode set to '{args.mode}'.") + if not ok and msg: + print(f" note: {msg}") + return 0 if ok or not service.available() else 1 + + info = service.status() + print(f"Trigger mode: {info['mode']}") + print(f"systemd --user: {'available' if info['available'] else 'not available'}") + if info["available"]: + print(f" recorder service: {'active' if info.get('recorder_active') else 'inactive'}") + print(f" watcher service: {'active' if info.get('watch_active') else 'inactive'}") + return 0 + + def cmd_gameenv(args) -> int: from dataclasses import asdict @@ -618,6 +646,18 @@ def build_parser() -> argparse.ArgumentParser: wrap_p.add_argument("command", nargs=argparse.REMAINDER, help="the game command — use `rigdoctor wrap %%command%%` in Steam") wrap_p.set_defaults(func=cmd_wrap) + + watch_p = sub.add_parser("watch", help="auto-capture while a Steam game runs (game-launch trigger)") + watch_p.add_argument("-n", "--interval", type=float, default=None, help="poll interval (s)") + watch_p.set_defaults(func=cmd_watch) + + svc_p = sub.add_parser("service", help="crash-logger trigger mode + systemd --user service (M9/D6)") + svc_sub = svc_p.add_subparsers(dest="service_cmd") + svc_sub.add_parser("status", help="show the trigger mode and service state").set_defaults(func=cmd_service) + mode_p = svc_sub.add_parser("mode", help="set the trigger mode") + mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch")) + mode_p.set_defaults(func=cmd_service) + svc_p.set_defaults(func=cmd_service, service_cmd=None) return p diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index 5d29156..c54486e 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -154,6 +154,7 @@ DEFAULTS: dict = { "cpu_temp_alert": 95.0, # °C — alert when CPU reaches this "relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12) "steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet + "trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch } diff --git a/src/rigdoctor/core/service.py b/src/rigdoctor/core/service.py new file mode 100644 index 0000000..044876f --- /dev/null +++ b/src/rigdoctor/core/service.py @@ -0,0 +1,118 @@ +"""`systemd --user` services for the crash logger + game watcher (M9 / D6 trigger modes). + +Three trigger modes (D6): **manual** (no service — start/stop by hand), **always-on** (a user +service samples continuously, bounded by log rotation), and **game-launch** (a watcher service +auto-brackets a capture around each game). No root: everything is a `systemd --user` unit in +``~/.config/systemd/user``. Degrades gracefully when systemd isn't available. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from .. import config + +UNIT_DIR = Path(os.path.expanduser("~/.config/systemd/user")) +RECORDER_UNIT = "rigdoctor-recorder.service" +WATCH_UNIT = "rigdoctor-watch.service" +MODES = ("manual", "always-on", "game-launch") + +_UNITS = { + RECORDER_UNIT: ("RigDoctor crash-capture recorder (always-on)", ["record", "run"]), + WATCH_UNIT: ("RigDoctor game-launch watcher", ["watch"]), +} + + +def available() -> bool: + return shutil.which("systemctl") is not None + + +def _rigdoctor_bin() -> str: + exe = Path(sys.executable).with_name("rigdoctor") # next to the venv python + if exe.exists(): + return str(exe) + return shutil.which("rigdoctor") or "rigdoctor" + + +def _systemctl(*args: str) -> tuple[int, str]: + try: + proc = subprocess.run(["systemctl", "--user", *args], + capture_output=True, text=True, timeout=20) + return proc.returncode, (proc.stdout + proc.stderr).strip() + except (OSError, subprocess.SubprocessError) as exc: + return 1, str(exc) + + +def unit_text(description: str, args: list[str]) -> str: + exec_cmd = " ".join([_rigdoctor_bin(), *args]) + return ( + "[Unit]\n" + f"Description={description}\n\n" + "[Service]\n" + "Type=simple\n" + f"ExecStart={exec_cmd}\n" + "Restart=on-failure\n" + "RestartSec=5\n\n" + "[Install]\n" + "WantedBy=default.target\n" + ) + + +def install_units() -> None: + """Write/refresh both unit files and reload systemd (idempotent).""" + UNIT_DIR.mkdir(parents=True, exist_ok=True) + for name, (desc, args) in _UNITS.items(): + (UNIT_DIR / name).write_text(unit_text(desc, args)) + _systemctl("daemon-reload") + + +def is_active(name: str) -> bool: + return _systemctl("is-active", name)[0] == 0 + + +def is_enabled(name: str) -> bool: + return _systemctl("is-enabled", name)[0] == 0 + + +def _enable(name: str) -> tuple[int, str]: + return _systemctl("enable", "--now", name) + + +def _disable(name: str) -> tuple[int, str]: + return _systemctl("disable", "--now", name) + + +def apply_mode(mode: str) -> tuple[bool, str]: + """Reconcile the user services to `mode` and persist it. Returns (ok, message).""" + if mode not in MODES: + return False, f"Unknown trigger mode: {mode}" + if not available(): + config.update_config(trigger_mode=mode) + return False, "systemd --user isn't available — mode saved, but no service was changed." + install_units() + if mode == "always-on": + _disable(WATCH_UNIT) + rc, out = _enable(RECORDER_UNIT) + elif mode == "game-launch": + _disable(RECORDER_UNIT) + rc, out = _enable(WATCH_UNIT) + else: # manual + _disable(RECORDER_UNIT) + _disable(WATCH_UNIT) + rc, out = 0, "" + config.update_config(trigger_mode=mode) + return rc == 0, out + + +def status() -> dict: + """Current trigger mode (config) + live service states (best-effort).""" + cfg = config.load_config() + info = {"available": available(), "mode": cfg.get("trigger_mode", "manual")} + if info["available"]: + info["recorder_active"] = is_active(RECORDER_UNIT) + info["watch_active"] = is_active(WATCH_UNIT) + return info diff --git a/src/rigdoctor/core/watcher.py b/src/rigdoctor/core/watcher.py new file mode 100644 index 0000000..bfff18d --- /dev/null +++ b/src/rigdoctor/core/watcher.py @@ -0,0 +1,107 @@ +"""Zero-config game-launch watcher (D12 fallback): poll Steam's RunningAppID and +auto-bracket a focused capture around the running game. + +For users who won't add the `rigdoctor wrap %command%` launch option. Less precise than the +wrapper (it depends on Steam writing RunningAppID to registry.vdf, and only covers Steam), so +the wrapper stays the primary mechanism. Stdlib only; safe to run as a `systemd --user` service +(the game-launch trigger mode). +""" + +from __future__ import annotations + +import os +import signal +import time +from pathlib import Path + +from . import reccontrol, steam +from .steam import _parse_vdf + +_REGISTRY_CANDIDATES = ("~/.steam/registry.vdf", "~/.steam/steam/registry.vdf") + + +def _registry_path() -> Path | None: + for cand in _REGISTRY_CANDIDATES: + p = Path(os.path.expanduser(cand)) + if p.exists(): + return p + return None + + +def _find_key(data: dict, key: str): + """Recursively find a (case-insensitive) scalar key in nested VDF dicts.""" + target = key.lower() + for k, v in data.items(): + if isinstance(v, dict): + found = _find_key(v, key) + if found is not None: + return found + elif k.lower() == target: + return v + return None + + +def running_appid() -> int: + """The Steam appid currently running (0 if none / unknown).""" + path = _registry_path() + if path is None: + return 0 + try: + data = _parse_vdf(path.read_text(encoding="utf-8", errors="replace")) + except OSError: + return 0 + raw = _find_key(data, "RunningAppID") + try: + return int(raw) + except (TypeError, ValueError): + return 0 + + +def transition(prev: int, current: int) -> str | None: + """'start' when a game begins, 'stop' when it ends, else None.""" + if current and not prev: + return "start" + if prev and not current: + return "stop" + return None + + +def _name_for(appid: int) -> str: + target = str(appid) + for g in steam.cached_games() or steam.scan_games(steam.selected_library_paths()): + if g.appid == target: + return g.name + return f"Steam app {appid}" + + +def watch(interval: float = 5.0) -> int: + """Poll for a running Steam game and bracket a capture around it. Blocks until signalled.""" + from . import diagnostic + + stop = {"flag": False} + + def _on_signal(_sig, _frame): + stop["flag"] = True + + signal.signal(signal.SIGTERM, _on_signal) + signal.signal(signal.SIGINT, _on_signal) + + prev = 0 + started = False + while not stop["flag"]: + current = running_appid() + action = transition(prev, current) + if action == "start" and not reccontrol.running_pid(): + started = diagnostic.start(game=_name_for(current)) is not None + elif action == "stop" and started: + reccontrol.stop_background() + started = False + prev = current + # Sleep in small slices so a stop signal is handled promptly. + slept = 0.0 + while slept < interval and not stop["flag"]: + time.sleep(min(0.25, interval - slept)) + slept += 0.25 + if started: + reccontrol.stop_background() + return 0 diff --git a/src/rigdoctor/gui/setup_page.py b/src/rigdoctor/gui/setup_page.py index b72d790..43d7dec 100644 --- a/src/rigdoctor/gui/setup_page.py +++ b/src/rigdoctor/gui/setup_page.py @@ -9,6 +9,7 @@ from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QApplication, QCheckBox, + QComboBox, QDoubleSpinBox, QFrame, QGridLayout, @@ -24,7 +25,7 @@ from PySide6.QtWidgets import ( ) from .. import config -from ..core import alerts, installer, sysenv, uninstall, updates +from ..core import alerts, installer, service, sysenv, uninstall, updates from .theme import GOOD, MUTED, WARN @@ -52,6 +53,7 @@ _BACKEND_DESC = { class SetupPage(QWidget): _installed = Signal(int, str) _upd_state = Signal(object) + _mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change changed = Signal() # alert settings saved — main window re-applies them live def __init__(self) -> None: @@ -59,6 +61,7 @@ class SetupPage(QWidget): self.setObjectName("Page") self._installed.connect(self._on_installed) self._upd_state.connect(self._on_upd_state) + self._mode_applied.connect(self._on_mode_applied) root = QVBoxLayout(self) root.setContentsMargins(20, 18, 20, 18) @@ -123,6 +126,35 @@ class SetupPage(QWidget): alerts_layout.addLayout(alerts_buttons) root.addWidget(alerts_card) + # Recording trigger (M9 / D6): when the crash logger runs. + trig_card, trig_layout = _panel("Recording trigger") + trig_desc = QLabel( + "When the crash logger runs (uses a systemd --user service):\n" + "• Manual — you start/stop it yourself.\n" + "• Always-on — a background service records continuously.\n" + "• Game-launch — auto-records while a Steam game is running." + ) + trig_desc.setObjectName("Muted") + trig_desc.setWordWrap(True) + trig_layout.addWidget(trig_desc) + trig_row = QHBoxLayout() + self._trigger = QComboBox() + self._trigger.addItems(list(service.MODES)) + apply_trigger = QPushButton("Apply") + apply_trigger.setObjectName("PrimaryButton") + apply_trigger.clicked.connect(self._apply_trigger) + trig_row.addWidget(self._trigger, 1) + trig_row.addWidget(apply_trigger) + trig_layout.addLayout(trig_row) + self._trigger_status = QLabel("") + self._trigger_status.setObjectName("Muted") + self._trigger_status.setWordWrap(True) + trig_layout.addWidget(self._trigger_status) + if not service.available(): + apply_trigger.setEnabled(False) + self._trigger_status.setText("systemd --user isn't available on this system.") + root.addWidget(trig_card) + # Account access (M13/M12): one Gitea token gates updates and session sharing. upd_card, upd_layout = _panel("Account access") hint = QLabel("A Gitea access token unlocks updates and session sharing. " @@ -167,8 +199,26 @@ class SetupPage(QWidget): self._refresh() self._load_alerts() + self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual")) self._refresh_update_status() + # --- recording trigger (M9) ----------------------------------------------- + def _apply_trigger(self) -> None: + mode = self._trigger.currentText() + self._trigger_status.setText(f"Applying “{mode}”… (may take a moment)") + threading.Thread(target=self._work_trigger, args=(mode,), daemon=True).start() + + def _work_trigger(self, mode: str) -> None: + ok, msg = service.apply_mode(mode) + self._mode_applied.emit((mode, ok, msg)) + + def _on_mode_applied(self, result) -> None: + mode, ok, msg = result + if ok: + self._trigger_status.setText(f"Recording trigger set to “{mode}”.") + else: + self._trigger_status.setText(f"“{mode}” saved. {msg}") + # --- alerts (M8) ---------------------------------------------------------- @staticmethod def _spin() -> QDoubleSpinBox: diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..14c0cc1 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,58 @@ +"""Tests for the M9 systemd --user trigger-mode service manager.""" + +import unittest +from unittest import mock + +from rigdoctor.core import service + + +class UnitTextTests(unittest.TestCase): + def test_unit_text_has_required_sections(self): + txt = service.unit_text("RigDoctor recorder", ["record", "run"]) + self.assertIn("[Unit]", txt) + self.assertIn("[Service]", txt) + self.assertIn("ExecStart=", txt) + self.assertIn("record run", txt) + self.assertIn("WantedBy=default.target", txt) + + +class ApplyModeTests(unittest.TestCase): + def test_unknown_mode_rejected(self): + ok, msg = service.apply_mode("turbo") + self.assertFalse(ok) + self.assertIn("Unknown", msg) + + def test_no_systemd_saves_mode_but_reports(self): + with mock.patch.object(service, "available", return_value=False), \ + mock.patch.object(service.config, "update_config") as update: + ok, msg = service.apply_mode("always-on") + self.assertFalse(ok) + self.assertIn("available", msg.lower()) + update.assert_called_once_with(trigger_mode="always-on") + + def test_always_on_enables_recorder_disables_watch(self): + calls = [] + with mock.patch.object(service, "available", return_value=True), \ + mock.patch.object(service, "install_units"), \ + mock.patch.object(service, "_enable", side_effect=lambda n: calls.append(("enable", n)) or (0, "")), \ + mock.patch.object(service, "_disable", side_effect=lambda n: calls.append(("disable", n)) or (0, "")), \ + mock.patch.object(service.config, "update_config"): + ok, _ = service.apply_mode("always-on") + self.assertTrue(ok) + self.assertIn(("enable", service.RECORDER_UNIT), calls) + self.assertIn(("disable", service.WATCH_UNIT), calls) + + def test_manual_disables_both(self): + disabled = [] + with mock.patch.object(service, "available", return_value=True), \ + mock.patch.object(service, "install_units"), \ + mock.patch.object(service, "_enable", return_value=(0, "")), \ + mock.patch.object(service, "_disable", side_effect=lambda n: disabled.append(n) or (0, "")), \ + mock.patch.object(service.config, "update_config"): + ok, _ = service.apply_mode("manual") + self.assertTrue(ok) + self.assertEqual(set(disabled), {service.RECORDER_UNIT, service.WATCH_UNIT}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_watcher.py b/tests/test_watcher.py new file mode 100644 index 0000000..3a8edcc --- /dev/null +++ b/tests/test_watcher.py @@ -0,0 +1,69 @@ +"""Tests for the M9/D12 game-launch watcher (RunningAppID parse + transitions).""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from rigdoctor.core import watcher + +_REGISTRY = """"Registry" +{ +\t"HKCU" +\t{ +\t\t"Software" +\t\t{ +\t\t\t"Valve" +\t\t\t{ +\t\t\t\t"Steam" +\t\t\t\t{ +\t\t\t\t\t"RunningAppID"\t\t"%s" +\t\t\t\t} +\t\t\t} +\t\t} +\t} +} +""" + + +class TransitionTests(unittest.TestCase): + def test_transitions(self): + self.assertEqual(watcher.transition(0, 570), "start") + self.assertEqual(watcher.transition(570, 0), "stop") + self.assertIsNone(watcher.transition(570, 570)) + self.assertIsNone(watcher.transition(0, 0)) + + +class FindKeyTests(unittest.TestCase): + def test_case_insensitive_nested(self): + data = {"Registry": {"HKCU": {"steam": {"runningappid": "42"}}}} + self.assertEqual(watcher._find_key(data, "RunningAppID"), "42") + + def test_missing(self): + self.assertIsNone(watcher._find_key({"a": {"b": "c"}}, "RunningAppID")) + + +class RunningAppIdTests(unittest.TestCase): + def _with_registry(self, content): + d = tempfile.mkdtemp() + path = Path(d) / "registry.vdf" + path.write_text(content) + return path + + def test_reads_running_appid(self): + path = self._with_registry(_REGISTRY % "570") + with mock.patch.object(watcher, "_registry_path", return_value=path): + self.assertEqual(watcher.running_appid(), 570) + + def test_zero_when_idle(self): + path = self._with_registry(_REGISTRY % "0") + with mock.patch.object(watcher, "_registry_path", return_value=path): + self.assertEqual(watcher.running_appid(), 0) + + def test_zero_when_no_registry(self): + with mock.patch.object(watcher, "_registry_path", return_value=None): + self.assertEqual(watcher.running_appid(), 0) + + +if __name__ == "__main__": + unittest.main()