Compare commits

...

35 Commits

Author SHA1 Message Date
jessey bfbad9cbc6 Merge pull request 'feat(share): render colors in the shared terminal — 0.24.0' (#20) from feat/share-terminal into main
release / release (push) Successful in 14s
Reviewed-on: #20
2026-05-22 08:05:12 +00:00
jessey 2e545ff718 feat(share): terminal-only sharing, bigger + full-screen — 0.25.0
Scope M12 down to a single shared-terminal mode (D23, amends D16):
- Share page rewritten terminal-only: host shares their PTY/shell; guest watches
  and may type only if the host ticks "Allow the guest to type" (read-only
  otherwise — the D9 consent exception). Terminal is larger; either side can pop
  it full-screen (Esc to exit).
- Removed the read-only stats view + HTTP server (core/share.py) and the
  `rigdoctor share serve` CLI; deleted their tests.
- Docs: D23 added; SPEC/MODULES/ROADMAP updated (M12 → done, terminal-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:04:52 +02:00
jessey 5e5dc2d54a feat(share): render colors in the shared terminal — 0.24.0
The terminal view rendered monochrome (QPlainTextEdit.setPlainText), dropping
pyte's per-cell attributes. Rewritten as a QTextEdit that renders fg/bg/bold/
reverse per cell (block cursor = inverted cell), preserving scrollback. The
session already runs the host's $SHELL + config with TERM=xterm-256color, so
fish/ls/git/prompts now look the same as locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:00:23 +02:00
jessey 7804893054 Merge pull request 'feat(m9): systemd --user trigger modes + game-launch watcher — 0.23.0' (#19) from feat/m9-installer into main
release / release (push) Successful in 14s
Reviewed-on: #19
2026-05-22 07:55:47 +00:00
jessey bf3ac4af1a feat(m9): systemd --user trigger modes + game-launch watcher — 0.23.0
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) <noreply@anthropic.com>
2026-05-22 09:55:36 +02:00
jessey e4a37176e1 Merge pull request 'feat(m6): PowerMizer + Wine/Steam versions + non-Steam launchers — 0.22.0' (#18) from feat/m6-leftovers into main
release / release (push) Successful in 14s
Reviewed-on: #18
2026-05-22 07:47:26 +00:00
jessey 67665974dc feat(m6): PowerMizer + Wine/Steam versions + non-Steam launchers — 0.22.0
M6 leftovers (the watcher defers to M9's trigger-mode work):
- gameenv: check_gpu_powermizer (NVIDIA, X; degrades when the gpu target won't
  resolve), check_wine (wine --version), check_steam_client (dpkg package version);
  steam.client_version() helper.
- core/launchers.py: detect Lutris (read-only SQLite pga.db) and Heroic (Epic
  legendary + GOG JSON) installed games; Game gained a `launcher` field.
- Games page + `rigdoctor games` list non-Steam games alongside Steam, tagged by
  launcher; Run Diagnostic works on them (auto-launch stays Steam-only).
- Tests for launchers (synthetic Lutris db + Heroic json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:46:42 +02:00
jessey 51b7ed69bd Merge pull request 'feat: live monitor TUI (M2) — 0.21.0' (#17) from feat/m11-tray into main
release / release (push) Successful in 15s
Reviewed-on: #17
2026-05-22 07:38:17 +00:00
jessey 6fca2c9aba feat: live monitor TUI (M2) — 0.21.0
Upgrade `rigdoctor monitor` from a basic redraw to a stdlib curses dashboard
(tui.py): current / session-min / session-max per sensor, grouped by subsystem,
with temperature & utilization color bands (GPU-lost flagged red). q quits,
r resets min/max. Plain full-screen redraw fallback on a non-TTY (--plain forces
it). Pure track()/band() helpers are unit-tested; curses path verified in a pty.

Completes the Monitoring bundle (M2 + M8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:37:57 +02:00
jessey 4c5a6547ec Merge pull request 'refactor(gui): grouped navigation + clearer page names — 0.20.0' (#16) from feat/m11-tray into main
release / release (push) Successful in 15s
Reviewed-on: #16
2026-05-22 07:31:06 +00:00
jessey 587568e574 refactor(gui): grouped navigation + clearer page names — 0.20.0
Reshape the IA so it reads by intent instead of a flat pile of pages.

- Grouped sidebar: Monitor / Diagnose / System / App (section headers).
- Renames: Health → System Health, Environment → Tuning, Logs → Recordings,
  Setup → Settings.
- Settings absorbs Notifications (alerts) as a section; Notifications dropped as a
  separate page (notifications_page.py removed; SetupPage gains the alerts card +
  `changed` signal wired to the live alert monitor).
- Recordings is now a hub: a source dropdown to view any captured log (always-on /
  last diagnostic / preserved crash) + Analyze-crash in place, plus the recorder
  controls; status line now shows the captured game.
- main_window nav is data-driven (_NAV groups → _PAGES order → stack); show_page,
  badges, and tray flows updated. GUI smoke test asserts the new page set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:30:35 +02:00
jessey cc84bbda88 Merge pull request 'feat(gui): system-tray applet (M11) + GUI smoke tests — 0.19.0' (#15) from feat/m11-tray into main
release / release (push) Successful in 13s
Reviewed-on: #15
2026-05-22 07:22:04 +00:00
jessey 75a4da7af3 feat(gui): system-tray applet (M11) + GUI smoke tests — 0.19.0
QSystemTrayIcon applet (gui/tray.py, D13): menu with live CPU/GPU temp + memory
used/total, a status line, a Run Diagnostic submenu per detected game, plus Open
dashboard / Start-Stop recording / Snapshot-copy / Quit. Reuses the dashboard's
sample stream; drives existing MainWindow flows.

- MainWindow creates the tray when one is available; closing the window hides to
  tray (Quit exits); setQuitOnLastWindowClosed(False) so dialogs don't quit it.
- app: `--tray` starts hidden for autostart.
- tests/test_gui_smoke.py: construct MainWindow headless + exercise the tray, so
  a startup crash (like the 0.18.0 import bug) fails the build. Skips if no PySide6.
- docs: M10/M11 marked done in MODULES/ROADMAP.

Completes the Desktop UI bundle (M10 + M11).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:21:37 +02:00
jessey f95387c5b8 Merge pull request 'fix(gui): correct relative import that broke app startup — 0.18.2' (#14) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #14
2026-05-22 07:10:47 +00:00
jessey 1dc86121f6 fix(gui): correct relative import that broke app startup — 0.18.2
The recording indicator (0.18.0) used `from .core import diagnostic`, which
resolves to the non-existent rigdoctor.gui.core and crashed MainWindow on launch.
Fixed to `from ..core import diagnostic`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:10:28 +02:00
jessey cd54e5f2c5 Merge pull request 'feat(gui): global recording indicator in the sidebar — 0.18.0' (#13) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #13
2026-05-22 07:08:28 +00:00
jessey 1b24d1b032 fix(gui): drop sample count from the recording badge — 0.18.1
The live sample count wasn't useful at a glance. The sidebar badge now shows
just ● Recording + the game, plus a ⚠ GPU-lost line when detected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:08:04 +02:00
jessey 7ac14416b5 feat(gui): global recording indicator in the sidebar — 0.18.0
While a capture runs, the sidebar shows a red "● Recording" badge on every page
with the game and live sample count (+ GPU-lost flag). A 1.5s poll of the
recorder status reflects captures started any way — manual record, a guided
diagnostic, or the Steam launch wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:07:11 +02:00
jessey b22a2f5593 Merge pull request 'feat(gui): bring back the Inventory page — 0.17.0' (#12) from feat/m6-steam-detection into main
release / release (push) Successful in 15s
Reviewed-on: #12
2026-05-22 07:05:49 +00:00
jessey f45d8c9b34 feat(gui): bring back the Inventory page — 0.17.0
Restore the GUI Inventory page (removed in 0.7.2 for the CLI). Sidebar Inventory
→ System/CPU/Firmware/Memory/GPU/Storage/Display cards, Copy Markdown / Save… /
Refresh; root-only dmidecode details (motherboard/BIOS/RAM) fill in after launch
elevation. Reuses the existing M5 core/inventory.py; CLI unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:04:47 +02:00
jessey 8d6ce47e87 Merge pull request 'feat: D12 Steam-launch wrapper for auto crash-capture + doc status fixes — 0.16.0' (#11) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #11
2026-05-22 07:01:44 +00:00
jessey 03b2dd8363 feat: D12 Steam-launch wrapper for auto crash-capture + doc status fixes — 0.16.0
D12 "build first" wrapper: `rigdoctor wrap %command%` (Steam launch option /
Lutris/Heroic wrapper field) auto-brackets a focused diagnostic around a game —
start a game-tagged capture on launch, clean stop on exit; a hard freeze leaves
it unterminated → flagged as a crash next launch.

- core/wrap.py: game name from SteamAppId, PATH-proof launch_option(), run()
  that doesn't disturb an existing capture and returns the game's exit code.
- diagnostic.start() preserves an unanalyzed crash to diagnostic-crash.jsonl
  before clearing, so auto-relaunch can't wipe an unseen crash; pending_crash/
  analyze_crash check the archive first.
- GUI: "Auto-capture…" helper dialog (copyable launch-option string).
- Tests for wrap (name resolution, exit-code passthrough, no-double-start).
- docs: fix stale MODULES.md status column (M1/M3/M4/M5/M8/M10/M13 → done),
  update ROADMAP/MODULES for the wrapper + crash detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:59:54 +02:00
jessey ab89dda0b4 Merge pull request 'feat: detect a hard-crashed diagnostic + analyze the crash boot — 0.15.0' (#10) from feat/m6-steam-detection into main
release / release (push) Successful in 13s
Reviewed-on: #10
2026-05-22 06:53:13 +00:00
jessey 305c88ba09 feat: detect a hard-crashed diagnostic + analyze the crash boot — 0.15.0
A focused capture that ends without a clean stop (no session-stop, no live
recorder) is treated as a likely hard freeze.

- core/diagnostic.py: pending_crash() detects the unterminated session;
  acknowledge_crash() dismisses it; analyze_crash() combines the captured window
  (final readings + GPU-lost) with a focused scan of the PREVIOUS (crashed) boot
  + SMART/driver/persistence/temps.
- health.check_previous_boot() scans `journalctl -k -b -1`; run_health_checks
  gained include_journal to avoid double-scanning for the crash path.
- GUI: Games page shows a warning banner on launch for an interrupted diagnostic
  with Analyze crash / Dismiss → results dialog.
- Tests for crash detection / clean-stop / acknowledge / in-progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:52:59 +02:00
jessey 82f3ea49de Merge pull request 'feat(gui): dashboard history graphs for headline metrics — 0.14.0' (#9) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #9
2026-05-22 06:51:06 +00:00
jessey 8d695227bc feat(gui): dashboard history graphs for headline metrics — 0.14.0
Replace the four headline gauges (GPU temp, GPU load, CPU temp, memory) with
HistoryGraph trend tiles: each plots its session history with the current value,
window min/max, a dashed warn-threshold line, and a kind-colored line (temp band
/ usage / accent). QPainter-drawn, no new dependency. Seeing changes over time is
more useful than the live-only snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:45:20 +02:00
jessey 82bef0a08c Merge pull request 'feat(gui): explain Run Diagnostic + offer to launch the game — 0.13.0' (#8) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #8
2026-05-22 06:43:57 +00:00
jessey 73f347449e feat(gui): explain Run Diagnostic + offer to launch the game — 0.13.0
The recording banner gave no guidance, so it wasn't clear what to do after
clicking Run Diagnostic.

- Start dialog now spells out the flow: play the game, reproduce the crash, then
  Finish & analyze (data survives a hard freeze + reboot), with "Launch game &
  start" (steam.launch_game via steam:// appid URL) or "Start without launching".
- Recording banner now states the next step, not just a sample count.
- steam.launch_game(appid): best-effort Steam launch (steam / xdg-open).
- Fix: escape "&" in button labels (Qt mnemonic) so "Finish & analyze" shows
  correctly instead of "Finish _analyze".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:40:50 +02:00
jessey 5cd51beadf Merge pull request 'feat(gui): Run Diagnostic flow on the Games page — 0.12.0' (#7) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #7
2026-05-22 06:32:30 +00:00
jessey 934b489fec feat(gui): Run Diagnostic flow on the Games page — 0.12.0
Brings the guided diagnostic (0.11.0 core/CLI) into the GUI:
- Each game row gets a "Run Diagnostic" button → starts a focused, game-tagged
  capture and shows a recording banner (live sample count + GPU-lost indicator)
  with Finish & analyze / Discard.
- Finishing runs core.diagnostic.finish() off the UI thread and opens a results
  dialog (gui/diagnostic_dialog.py): window-scoped capture summary + findings
  cards (reusing render_summary + finding_card).
- Banner restores on showEvent if a capture is still running (navigate away/back).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:32:04 +02:00
jessey 7a283dc338 Merge pull request 'feat: guided diagnostic session (CLI) — pick a game, capture, analyze — 0.11.0' (#6) from feat/m6-steam-detection into main
release / release (push) Successful in 15s
Reviewed-on: #6
2026-05-22 06:28:21 +00:00
jessey 5682878f22 feat: guided diagnostic session (CLI) — pick a game, capture, analyze — 0.11.0
The seed use case end to end, orchestrating M3 + M4 (ARCHITECTURE §7.1).

- core/diagnostic.py: start(game) runs a focused, game-tagged capture into a
  dedicated diagnostic log (window-scoped report, separate from the always-on
  crash log); finish() stops it and combines the capture summary (M3) with the
  health findings (M4). Game recorded as a log event so it survives crash+reboot.
- CLI: rigdoctor diagnose start --game/--appid | status | finish.
- recorder/record run gained an optional --game tag; reccontrol passes it through.
- Tests for game recovery + the finish() combination.

GUI/tray "Run Diagnostic" button and auto start/stop (D12 wrapper) come next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:27:53 +02:00
jessey 5a584c08d5 Merge pull request 'fix(gui): readable Environment dropdowns and action buttons — 0.10.1' (#5) from feat/m6-steam-detection into main
release / release (push) Successful in 14s
Reviewed-on: #5
2026-05-22 06:22:40 +00:00
jessey 8b1083a29b fix(gui): show the real reason an Environment Apply/Install failed — 0.10.2
Thread the command output through to the status line and classify it: cancelled
at the password prompt vs. the system rejecting the change (e.g. a BIOS/kernel-
locked PCIe ASPM policy), instead of a vague "cancelled, or needs privileges".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:21:01 +02:00
jessey 25b7a58e3c fix(gui): readable Environment dropdowns and action buttons — 0.10.1
- Style the QComboBox popup (QAbstractItemView) — it's a separate widget the
  theme didn't cover, so the drop-down list rendered light-on-light.
- Install/Apply finding buttons used PrimaryButton (accent fill + dark text),
  whose fill didn't paint reliably inside the finding cards, leaving dim
  dark-on-dark text. New outlined ActionButton style: bright accent text on the
  dark card, fills accent on hover, with a min-height so the row can't crush it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:16:31 +02:00
46 changed files with 3221 additions and 719 deletions
+184
View File
@@ -5,6 +5,190 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). release tag (so the auto-updater, D18, can compare versions).
## [0.25.0] - 2026-05-22
### Changed
- **Share is now terminal-only (D23, amends D16).** The Share page is a single shared-terminal
experience: the host shares their shell, the guest watches and may type **only if the host
ticks "Allow the guest to type"** (otherwise read-only). The terminal is larger and either
side can pop it **full-screen** (Esc to exit).
### Removed
- The read-only **stats view** (live sensors/health/inventory over the relay) and the
`rigdoctor share serve` HTTP server — the shared terminal replaces them. (`core/share.py`
removed; the `share` CLI command is gone.)
## [0.24.0] - 2026-05-22
### Added
- **Shared terminal is now in color.** The terminal view renders pyte's per-cell foreground/
background, bold, and reverse, so the host's real shell keeps its theming — fish, `ls`,
`git`, prompts, etc. look the same as locally (the session already runs the host's `$SHELL`
with its config and `TERM=xterm-256color`; only the rendering was monochrome).
## [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 <manual|always-on|game-launch>`; `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
Adaptive/Auto and suggests Prefer-Max-Performance), the **Wine** version, and the **Steam
client** version.
- **Non-Steam launchers.** Lutris (its SQLite library) and Heroic (Epic + GOG JSON stores) are
detected (`core/launchers.py`) and listed on the Games page and `rigdoctor games`, tagged by
launcher. You can Run Diagnostic on them too (records while you play; auto-launch stays
Steam-only).
### Notes
- The zero-config game watcher (D12 fallback) is deferred to the M9 trigger-mode work, where the
service integration lives.
## [0.21.0] - 2026-05-22
### Added
- **Live monitor TUI (M2).** `rigdoctor monitor` is now a proper **curses** dashboard:
current / session-min / session-max per sensor, grouped by subsystem, with temperature and
utilization **color bands** (and GPU-lost flagged red). `q` quits, `r` resets the session
min/max. Falls back to a plain full-screen redraw on a non-TTY (`--plain` forces it). The
terminal face of the same live data the GUI dashboard graphs. Completes the Monitoring bundle.
## [0.20.0] - 2026-05-22
### Changed
- **Reorganized navigation** into grouped sidebar sections — **Monitor** (Dashboard) ·
**Diagnose** (Games, Recordings, System Health, Tuning) · **System** (Inventory) · **App**
(Settings, Share) — so it's clear where to go.
- **Renames for clarity:** *Health → System Health* (it's the overall 7-day system scan, not
per-game), *Environment → Tuning* (gaming tunables + fixes), *Logs → Recordings*,
*Setup → Settings*.
- **Settings** absorbed **Notifications** (alerts) — app configuration (components/deps, alerts,
account access, uninstall) now lives in one page; Notifications is no longer a separate item.
- **Recordings** is now a hub: pick which captured log to view (always-on capture, last
diagnostic, or a preserved crash), **Analyze crash** in place, alongside the recorder controls.
## [0.19.0] - 2026-05-22
### Added
- **System-tray applet (M11, D13).** A tray icon whose menu shows live **CPU / GPU temp** and
**memory used/total**, a **status line** (Normal / Hot / GPU not responding), and is led by a
**Run Diagnostic** submenu (pick a detected game → the guided session), plus **Open dashboard**,
**Start/Stop recording**, **Snapshot (copy)**, and **Quit**. It reuses the dashboard's sample
stream (no extra sampling). With a tray present, **closing the window hides to the tray** (Quit
exits); `rigdoctor-gui --tray` starts hidden for autostart. Needs a tray host — on GNOME the
AppIndicator extension; degrades to a no-op if none is available. Completes the Desktop UI bundle.
- **GUI smoke tests**: construct `MainWindow` headless and exercise the tray, so a startup crash
fails the build (closes the gap that let the 0.18.0 import regression ship).
## [0.18.2] - 2026-05-22
### Fixed
- **GUI wouldn't start** (0.18.0 regression): the recording indicator used a wrong relative
import (`from .core``rigdoctor.gui.core`, which doesn't exist), crashing `MainWindow` on
launch. Corrected to `from ..core`.
## [0.18.1] - 2026-05-22
### Changed
- Recording badge: dropped the sample count (not useful at a glance) — it now shows just
**● Recording** + the game, plus a **⚠ GPU-lost** line if one is detected.
## [0.18.0] - 2026-05-22
### Added
- **Global recording indicator.** While a capture is running, the sidebar shows a red
**● Recording** badge on every page — with the **game** being captured and the live sample
count (and a GPU-lost flag if seen). It polls the recorder, so it reflects captures started
any way: manual `record`, a guided diagnostic, or the Steam launch wrapper.
## [0.17.0] - 2026-05-22
### Added
- **Inventory page is back in the GUI** (it was removed in 0.7.2 in favor of the CLI). Sidebar
**Inventory** → System / CPU / Firmware / Memory / GPU / Storage / Display as cards, with
**Copy Markdown** and **Save…** for pasting into forum/bug reports, and **Refresh**. Root-only
details (motherboard/BIOS/RAM modules via dmidecode) fill in after the launch password prompt.
Backed by the existing M5 `core/inventory.py` — the CLI `rigdoctor inventory` is unchanged.
## [0.16.0] - 2026-05-22
### Added
- **Automatic crash-capture via a Steam launch wrapper (M6/D12).** Set `rigdoctor wrap
%command%` as a game's Steam launch option (or in Lutris/Heroic's wrapper field) and RigDoctor
starts a focused, game-tagged capture when the game launches and stops it cleanly on exit — no
manual Run Diagnostic / Finish. A hard freeze leaves the capture unterminated, so it's flagged
as a crash next launch. The wrapper resolves the game name from Steam's `SteamAppId`, doesn't
disturb an existing capture, and returns the game's exit code. (`core/wrap.py`, `rigdoctor wrap`.)
- GUI **Auto-capture…** helper on the Games page: shows the exact launch-option line (absolute
path, copy button) and how to set it in Steam.
- Auto-capture preserves an unanalyzed crash (`diagnostic-crash.jsonl`) before starting a new
capture, so relaunching the game can't wipe a crash report you haven't seen yet.
### Fixed
- `docs/MODULES.md` status column was stale — M1, M3, M4, M5, M8, M10, and M13 are done and now
marked ✅ (only M2 and M11 remain not-started; M6/M9/M12 in progress).
## [0.15.0] - 2026-05-22
### Added
- **Hard-crash detection & recovery for the guided diagnostic.** If a focused capture ends
without a clean stop (the recorder never wrote `session-stop` and isn't running), RigDoctor
treats it as a likely hard freeze. On launch the **Games** page shows a warning banner —
*"Your last diagnostic for <game> ended unexpectedly…"* — with **Analyze crash** / **Dismiss**.
- **Deeper crash analysis.** *Analyze crash* combines the captured window (final readings before
the freeze + any GPU-lost event) with a focused scan of the **previous (crashed) boot's kernel
log** (`journalctl -k -b -1`: Xid/panic/OOM/MCE/AER/thermal) plus SMART/driver/persistence/
live-temp checks — the full "what happened" picture. `core/diagnostic.py` gains
`pending_crash()` / `analyze_crash()`; `health.check_previous_boot()` +
`run_health_checks(include_journal=False)` back it.
## [0.14.0] - 2026-05-22
### Changed
- **Dashboard headline tiles are now history trend graphs** instead of single-value gauges —
GPU temp, GPU load, CPU temp, and memory each plot their recent history (with the current
value, window min/max, and a dashed warning-threshold line), so you can see changes over time
rather than only the instantaneous reading. New `HistoryGraph` widget (QPainter, no new deps).
## [0.13.0] - 2026-05-22
### Added
- **Run Diagnostic now explains itself and can launch the game.** Clicking Run Diagnostic shows
what to do — *play the game, reproduce the crash, then Finish & analyze* (and that data
survives a hard freeze + reboot) — and offers **Launch game & start** (asks Steam to run it by
appid) or **Start without launching**. The recording banner now spells out the next step
instead of just showing a sample count.
### Fixed
- Button labels containing "&" (e.g. "Finish & analyze") rendered as "Finish _analyze" because
Qt treated the "&" as a keyboard mnemonic — now escaped so the ampersand shows literally.
## [0.12.0] - 2026-05-22
### Added
- **Guided diagnostic in the GUI.** Each game on the **Games** page now has a **Run Diagnostic**
button → a focused, game-tagged capture starts and a recording banner appears (live sample
count, GPU-lost indicator) with **Finish & analyze** / **Discard**. Finishing opens a results
dialog: the window-scoped capture summary (peak temps/power, events, last samples) plus the
health findings as cards. The banner persists/restores if you navigate away and back while a
capture is running. Shares `core/diagnostic.py` with the CLI (one flow, three front-ends).
## [0.11.0] - 2026-05-22
### Added
- **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose
start --game "<name>"` runs a **focused crash-capture tagged with that game** (its own
diagnostic log, so the report is scoped to just that session), `diagnose status` shows
progress, and `diagnose finish` stops it and prints a combined report: the **capture
summary** (peak temps/power, GPU-lost events, last samples — M3) plus the **health findings**
(Xid/SMART/driver/etc. — M4). The game can be given by `--game` or `--appid` (resolved from
the Steam scan), and is recorded as a log event so it survives a crash + reboot.
- Shared orchestration lives in `core/diagnostic.py` (one callable for CLI/GUI/tray, per
ARCHITECTURE §7.1); the recorder/`record run` gained an optional `--game` tag.
## [0.10.2] - 2026-05-22
### Changed
- When an Environment **Apply**/**Install** fails, the status now shows the **real reason**
(cancelled at the password prompt vs. the system rejecting the change, e.g. a BIOS/kernel-
locked PCIe ASPM policy) instead of a vague "cancelled, or needs privileges".
## [0.10.1] - 2026-05-22
### Fixed
- **Environment-page contrast.** The combo-box **drop-down list** was rendering light-on-light
(the popup view is a separate widget the theme didn't cover) — now dark with readable text.
- The **Install / Apply** buttons on findings were hard to read (the accent fill didn't paint
reliably inside the finding cards, leaving dim dark-on-dark text). They're now an outlined
style — bright accent text on the dark card, filling accent on hover — readable regardless,
and given a minimum height so the row can't crush them.
## [0.10.0] - 2026-05-22 ## [0.10.0] - 2026-05-22
### Added ### Added
- **Actionable Environment page (M6) — install & apply, not just advice.** Findings that - **Actionable Environment page (M6) — install & apply, not just advice.** Findings that
+11 -1
View File
@@ -239,9 +239,19 @@ consent." That milestone lands here, **scoped tightly to stay safe**:
the apply UI is an additive convenience in the GUI, not the only path. Installing optional the apply UI is an additive convenience in the GUI, not the only path. Installing optional
tools (GameMode/MangoHud/cpupower) reuses the M9 installer and is likewise one-click. tools (GameMode/MangoHud/cpupower) reuses the M9 installer and is likewise one-click.
### D23 — Session sharing scoped to a shared terminal only — *DECIDED 2026-05-22; amends D16*
D16's escalating ladder (export → read-only stats view → terminal) is **cut down to just the
shared terminal.** Rationale: the terminal is the only mode the owner wants; the stats view
duplicated what the GUI already shows and added surface area. Concretely:
- **Removed:** the read-only stats view + its HTTP server (`core/share.py`, `rigdoctor share
serve`) and the (never-built) bundle export. The `share` CLI command is gone.
- **Kept & finished:** the relay **shared terminal** (host PTY of `$SHELL`) — now color-rendered
(preserves fish/ls/git theming), full-screen-able, with the guest read-only unless the host
ticks "Allow the guest to type" (the D9 consent exception). Account-gated by the Gitea token.
## Open ## Open
None currently — all tracked decisions (D1D22) are resolved. New questions will be added None currently — all tracked decisions (D1D23) are resolved. New questions will be added
here as they arise. Remaining detail to flesh out during build: the tray's supporting-action here as they arise. Remaining detail to flesh out during build: the tray's supporting-action
set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's
update mechanism (APT repo vs. self-installed `.deb`). update mechanism (APT repo vs. self-installed `.deb`).
+50 -31
View File
@@ -8,18 +8,18 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| ID | Module | Bundle | Key deps | GPU scope | Priority | Status | | ID | Module | Bundle | Key deps | GPU scope | Priority | Status |
|----|--------|--------|----------|-----------|----------|--------| |----|--------|--------|----------|-----------|----------|--------|
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | | | M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | |
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | |
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | |
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | |
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 | | M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | |
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 | | M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 | | M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | |
| M9 | Installer | (meta) | none | all | P1 | 🟨 | | M9 | Installer | (meta) | none | all | P1 | 🟨 |
| M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 | | M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | |
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | 🟨 | | M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | |
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
## Notes per module ## Notes per module
@@ -31,15 +31,20 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
*Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation *Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation
(`log_max_bytes`/`log_backups`), GPU-lost/recovered event markers, atomic status file, and (`log_max_bytes`/`log_backups`), GPU-lost/recovered event markers, atomic status file, and
`rigdoctor record run|start|stop|status|report`. The foreground `run` is the systemd-ready `rigdoctor record run|start|stop|status|report`. The foreground `run` is the systemd-ready
entrypoint; the service unit + always-on/game-launch triggers (D6/D12) land in Phase 4. entrypoint. The **game-launch trigger** is implemented via the D12 wrapper (`rigdoctor wrap
Also fully driven from the GUI's Recording/Logs page (M10) via shared `core.reccontrol`. %command%`, see M6/below); the `systemd --user` service unit + always-on trigger (D6) and the
zero-config watcher (D12) are still pending. Also fully driven from the GUI's Recording/Logs
page (M10) via shared `core.reccontrol`.
- **M4 Health report** — turns scattered logs into a prioritized, plain-language findings - **M4 Health report** — turns scattered logs into a prioritized, plain-language findings
list with **suggested** fixes (read-only, D9). Reuses M1 for a live snapshot. Also powers list with **suggested** fixes (read-only, D9). Reuses M1 for a live snapshot. Also powers
the **guided diagnostic session** (with M3): pick a game → focused capture → scan → the **guided diagnostic session** (with M3): pick a game → focused capture → scan →
findings (see SPEC §4). *Implemented:* journalctl scan (Xid/panic/OOM/MCE/AER/thermal/amdgpu), findings (see SPEC §4). *Implemented:* journalctl scan (Xid/panic/OOM/MCE/AER/thermal/amdgpu),
SMART, NVIDIA driver-mismatch, journald-persistence + live-temp checks; `rigdoctor report` SMART, NVIDIA driver-mismatch, journald-persistence + live-temp checks; `rigdoctor report`
(text/JSON) + GUI Health tab. GPU-firmware verification deferred. (text/JSON) + GUI Health tab. GPU-firmware verification deferred.
- **M2 Live monitor** — depends on M1; the terminal "HWMonitor for Linux" face. Stdlib-only. - **M2 Live monitor** — the terminal "HWMonitor for Linux" face. *Implemented (`tui.py`):*
`rigdoctor monitor` is a stdlib **curses** dashboard — current / session-min / session-max
per sensor, grouped by subsystem, with temperature & utilization color bands; `q` quits,
`r` resets the min/max. Falls back to a plain redraw on a non-TTY (`--plain` forces it).
- **M5 / M6 Diagnostics** — inventory export + gaming-env checks; M6 flags risky settings and - **M5 / M6 Diagnostics** — inventory export + gaming-env checks; M6 flags risky settings and
suggests the fix command but does not apply it (D9). *M6 implemented (Steam detection first — suggests the fix command but does not apply it (D9). *M6 implemented (Steam detection first —
the D12 "pick a game" foundation):* discovers Steam installs + all library folders the D12 "pick a game" foundation):* discovers Steam installs + all library folders
@@ -56,19 +61,32 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness / for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness /
THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install** THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install**
of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations
stay suggestion-only. *Pending:* non-Steam launchers (Lutris/Heroic) and GPU power-profile stay suggestion-only. *Guided diagnostic (D12 "pick a game", `core/diagnostic.py`):* a focused
(PowerMizer) checks. capture tagged with a game → window-scoped report (capture summary + M4 findings), in the CLI
(`rigdoctor diagnose start/status/finish`) and GUI (per-game **Run Diagnostic** → recording
banner → results dialog). **Auto-capture** via the D12 wrapper (`rigdoctor wrap %command%`,
`core/wrap.py`; GUI "Auto-capture…" helper). **Hard crashes are detected** (capture left
without a clean stop) and flagged on next launch with a crash-boot kernel-log analysis
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). **Non-Steam launchers**
(Lutris SQLite + Heroic JSON, `core/launchers.py`) are detected and listed alongside Steam
games; env checks also cover **GPU PowerMizer** (X), **Wine** and **Steam-client** versions.
*Pending:* the zero-config watcher (D12 fallback) — landing with M9's trigger-mode work.
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11). - **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log - **M10 Desktop GUI** — PySide6 graphical front-end over the core engine. Optional; adds the
browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System /
early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar App) over: **Dashboard** (live history graphs + per-subsystem cards), **Games** (M6 detection
nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature- + Run Diagnostic), **Recordings** (recorder controls + view/report any captured log + analyze
colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status + a crash), **System Health** (M4 scan), **Tuning** (M6 gaming tunables + fixes), **Inventory**
post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17. (M5), **Settings** (components/deps + alerts + account + uninstall), and **Share** (M12). A
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts global recording badge shows on every page. GUI-first per D17.
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic** - **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording / the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
Snapshot / Quit (D13). Optional; shares the Qt dependency with M10. (Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
the guided session), plus Open dashboard / Start-Stop recording / Snapshot-copy / Quit. It
shares the dashboard's sample stream (no extra sampling) and drives the existing MainWindow
flows. With a tray present, closing the window **hides to the tray** (Quit exits); `rigdoctor-gui
--tray` starts hidden for autostart. Optional; shares the Qt dependency with M10. *Needs a tray
host* — on GNOME that means the AppIndicator extension; degrades to no-op if none is available.
- **M9 Installer** — interactive wizard layered on the `.deb` (D8); apt-first dependency - **M9 Installer** — interactive wizard layered on the `.deb` (D8); apt-first dependency
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/ resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`), package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
@@ -78,12 +96,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
**`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:* **`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:*
config/module selection + `systemd --user` config/module selection + `systemd --user`
service enable. service enable.
- **M12 Session sharing / remote assist** (D16) — let a helper inspect a user's machine, in - **M12 Session sharing / remote assist** (D16, scoped to terminal-only by **D23**) — a single
an escalating ladder: (1) **diagnostic bundle export** (inventory + recent log + report, mode: a **host-consented shared terminal** over the relay. The host shares a real PTY running
one-way), (2) **live read-only view** over a user-chosen tunnel (Tailscale/cloudflared/SSH, their `$SHELL` (colors/theming preserved — fish etc.); the guest watches live and can type
no hosted relay), (3) **gated interactive terminal** wrapping tmate/sshx (read-only by **only if the host allows it** (otherwise read-only) — a deliberate, consent-gated exception
default; read-write only on explicit consent — a deliberate exception to D9). Per-session to D9. The host reads along and can type too (e.g. a sudo password, which stays local). Either
consent, ephemeral revocable tokens, audit log. side can pop the terminal **full-screen**. Account-gated by the Gitea token. *The earlier
read-only stats view and `share serve` (Tier 1/2) were removed.*
- **M13 Auto-update** (D18) — *check + auth implemented:* updates are **gated to Gitea account - **M13 Auto-update** (D18) — *check + auth implemented:* updates are **gated to Gitea account
holders** via a Personal Access Token, stored **encrypted in the OS keyring** (`secret-tool`) holders** via a Personal Access Token, stored **encrypted in the OS keyring** (`secret-tool`)
with a 0600-file fallback (`config.load_token`/`save_token`/`token_backend`). `core/updates` with a 0600-file fallback (`config.load_token`/`save_token`/`token_backend`). `core/updates`
+36 -21
View File
@@ -22,7 +22,8 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
last readings + a plausible cause. last readings + a plausible cause.
## Phase 2 — Live monitor (terminal) ## Phase 2 — Live monitor (terminal)
- [ ] M2 TUI dashboard (current/min/max, grouped, throttle highlighting) - [x] M2 TUI dashboard (`rigdoctor monitor`, `tui.py`): curses, current/min/max grouped by
subsystem with temp/usage color bands; q quit / r reset; plain-redraw fallback on non-TTY
- [ ] M8 basic alerting (overheat/throttle/GPU-lost notifications) - [ ] M8 basic alerting (overheat/throttle/GPU-lost notifications)
## Phase 3 — Diagnostics breadth ## Phase 3 — Diagnostics breadth
@@ -33,22 +34,38 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
This is also the D12 "pick a game" foundation. *Env-check engine done* (`rigdoctor gameenv` This is also the D12 "pick a game" foundation. *Env-check engine done* (`rigdoctor gameenv`
+ GUI Environment page): PCIe ASPM, NVIDIA persistence, CPU governor, GameMode, MangoHud, + GUI Environment page): PCIe ASPM, NVIDIA persistence, CPU governor, GameMode, MangoHud,
swappiness, shader cache, THP, mitigations, Proton versions — read-only with fix commands. swappiness, shader cache, THP, mitigations, Proton versions — read-only with fix commands.
*Pending:* non-Steam launchers (Lutris/Heroic) + GPU power-profile (PowerMizer) checks. Also: GPU PowerMizer (X), Wine + Steam-client versions, and non-Steam launchers
(Lutris/Heroic, `core/launchers.py`). *Pending:* the zero-config watcher (D12 fallback,
lands with M9's trigger-mode work).
- [ ] SMART integration (smartmontools if present) - [ ] SMART integration (smartmontools if present)
## Phase 4 — Desktop UI & installer ## Phase 4 — Desktop UI & installer
- [ ] M10 desktop GUI (PySide6: dashboard, log browser, report viewer, logger controls) - [x] M10 desktop GUI (PySide6: dashboard w/ history graphs, logs, health, games, environment,
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic + inventory, setup, notifications, share)
supporting actions — D13) - [x] M11 tray / menu-bar applet (`gui/tray.py`: live CPU/GPU temp + memory readouts, status
- [ ] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings), line, Run Diagnostic submenu per game, Open dashboard / Start-Stop recording / Snapshot /
shared by tray/GUI/CLI Quit — D13; close-to-tray, `--tray` autostart). Needs a tray host (AppIndicator on GNOME).
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first: - [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
(Steam RunningAppID + /proc) and GameMode hook follow) diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
page → recording banner → results dialog with the capture summary + findings). Tags a
focused capture with the chosen game (own diagnostic log, window-scoped report) and
combines the capture summary with the M4 findings. **Auto start/stop** via the D12
wrapper is wired in, and a **hard-crash is detected** (capture left without a clean stop)
→ flagged on next launch with a deeper crash-boot log analysis. *Pending:* the tray (M11)
entry point and the zero-config watcher.
- [~] Logger trigger modes: always-on + game-launch (D12) — *game-launch **wrapper** done:*
`rigdoctor wrap %command%` (per-game Steam launch option / Lutris/Heroic wrapper field)
auto-brackets a focused capture around the game; GUI "Auto-capture…" helper shows the
launch-option string. *Pending:* global Steam compat-tool registration, the zero-config
watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user`
service.
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install - [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`** (`rigdoctor install`, GUI Settings); **user-local `install.sh` + self-extracting `.run`**
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection (no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger
config + `systemd --user` service enable + trigger-mode pick. 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 - [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later) ## Phase 5 — Breadth (later)
@@ -62,14 +79,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot. NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot.
GRUB-based fixes + CPU mitigations remain suggestion-only. GRUB-based fixes + CPU mitigations remain suggestion-only.
## Phase 6 — Session sharing / remote assist (M12, D16) ## Phase 6 — Session sharing / remote assist (M12, D16 → scoped to terminal-only by D23)
Escalating ladder, built in order: - [x] **Shared terminal** — a real PTY (host's `$SHELL`) shared over the relay, color-rendered
- [ ] Tier 1: `share export` — diagnostic bundle (inventory + recent log + report); B opens (pyte), full-screen-able; the guest watches and may type only on host consent (D9
it in RigDoctor. One-way, safest. exception); host reads along + can type (sudo). The single share mode.
- [x] Tier 2: live read-only view — `rigdoctor share serve` (stdlib HTTP, token-gated: - [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the
sensors + health + inventory). Remote = user-chosen tunnel; GUI controls still to add. shared terminal is the only sharing mode.
- [x] Tier 3: host-consented interactive terminal — a real PTY shell shared over the relay
(own `pty`, pyte-rendered guest), off by default; host reads along + can type (sudo).
> **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond > **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond
> Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out. > Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out.
+7 -9
View File
@@ -144,15 +144,13 @@ bundles with descriptions and the exact packages each needs → resolve & instal
mode. Delivered with the user-local install (and the optional `.deb`) (D8). Module mode. Delivered with the user-local install (and the optional `.deb`) (D8). Module
list/bundling is final per D14. list/bundling is final per D14.
### M12 — Session sharing / remote assist (D16) ### M12 — Session sharing / remote assist (D16, scoped to terminal-only by D23)
Lets a user (A) grant a helper (B) inspection access, as an escalating, consent-driven Lets a user (A) grant a helper (B) a **shared terminal** over the relay: A shares a real PTY
ladder: (1) **diagnostic bundle export** (inventory + recent capture log + report, one-way); running their shell; B watches live and may type **only if A allows it** (otherwise read-only)
(2) **live read-only view** of the dashboard + logs over a user-chosen tunnel — a deliberate, consent-gated exception to the read-only stance (D9). A reads along and can
(Tailscale/cloudflared/SSH — no RigDoctor-hosted relay); (3) **gated interactive terminal** type too (e.g. a sudo password, which stays local and is never sent to B). Account-gated by the
wrapping an existing tool (tmate/sshx), read-only by default, read-write only on explicit Gitea token; per-session share code. The shared terminal preserves colors/theming and can be
consent. Per-session consent, ephemeral revocable tokens, permission escalation (view ≠ viewed full-screen. *(The earlier read-only stats view / bundle export were dropped — D23.)*
shell), and a session audit log. Tier 3 is a deliberate, consent-gated exception to the
read-only stance (D9). Built in Phase 6.
## 5. Non-functional requirements ## 5. Non-functional requirements
- **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt - **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.10.0" version = "0.25.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.10.0" __version__ = "0.25.0"
+172 -46
View File
@@ -44,17 +44,10 @@ def cmd_snapshot(args) -> int:
def cmd_monitor(args) -> int: def cmd_monitor(args) -> int:
from .tui import run
interval = args.interval or load_config()["interval"] interval = args.interval or load_config()["interval"]
try: return run(interval, plain=getattr(args, "plain", False))
for sample in _sampler().stream(interval=interval):
# Basic full-screen redraw; the rich TUI (M2) comes later.
print("\033[2J\033[H", end="")
print(f"RigDoctor — live (every {interval:g}s, Ctrl-C to quit)\n")
print(render_snapshot(sample))
sys.stdout.flush()
except KeyboardInterrupt:
print()
return 0
def cmd_gui(args) -> int: def cmd_gui(args) -> int:
@@ -86,6 +79,7 @@ def cmd_record_run(args) -> int:
max_bytes=cfg["log_max_bytes"], max_bytes=cfg["log_max_bytes"],
backups=cfg["log_backups"], backups=cfg["log_backups"],
status_path=config.STATUS_FILE, status_path=config.STATUS_FILE,
game=getattr(args, "game", None),
) )
def _handle(_sig, _frame): def _handle(_sig, _frame):
@@ -295,12 +289,6 @@ def cmd_uninstall(args) -> int:
return 0 return 0
def cmd_share_serve(args) -> int:
from .core import share
return share.serve(host=args.host, port=args.port)
def cmd_collect_priv(args) -> int: def cmd_collect_priv(args) -> int:
"""Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch.""" """Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch."""
from dataclasses import asdict from dataclasses import asdict
@@ -345,6 +333,111 @@ def cmd_report(args) -> int:
return 0 return 0
def _resolve_game(args) -> str | None:
"""Game name from --game, or looked up from --appid via the Steam scan."""
if getattr(args, "game", None):
return args.game
if getattr(args, "appid", None):
from .core import steam
for g in steam.scan_games(steam.selected_library_paths()):
if g.appid == str(args.appid):
return g.name
return None
return None
def cmd_diagnose(args) -> int:
from .core import diagnostic, reccontrol, steam
sub = args.diagnose_cmd or "status"
if sub == "start":
if reccontrol.running_pid():
print("A capture is already running — finish it with: rigdoctor diagnose finish")
return 1
game = _resolve_game(args)
if game is None and (args.game or args.appid):
print("Couldn't match that game in your selected Steam libraries.")
return 1
if game is None:
games = steam.cached_games() or steam.scan_games(steam.selected_library_paths())
if games:
print("Pick a game to focus on, then re-run with --game:")
for g in games:
print(f" --game {g.name!r}")
else:
print("No games detected. Select a library: rigdoctor games libraries --all")
return 1
pid = diagnostic.start(game=game, interval=args.interval)
time.sleep(1.0)
if pid and reccontrol.pid_alive(pid):
print(f"Diagnostic capture started for {game!r} (pid {pid}).")
print(" Play your game. When you're done (or after a crash + reboot):")
print(" rigdoctor diagnose finish")
return 0
print(f"Capture failed to start; see {config.SPAWN_LOG}")
return 1
if sub == "status":
status = diagnostic.active()
if not status:
print("No diagnostic capture is running.")
return 0
game = status.get("game") or ""
print(f"Capturing for {game!r}: {status.get('samples', 0)} samples"
+ (" · GPU-lost seen" if status.get("gpu_lost") else ""))
return 0
# finish
if not reccontrol.running_pid() and not config.DIAG_LOG.exists():
print("No diagnostic to analyze. Start one with: rigdoctor diagnose start --game <name>")
return 1
print("Stopping capture and analyzing…\n")
result = diagnostic.finish(last_n=args.last)
from .render import render_health, render_summary
if result.game:
print(f"Diagnostic — {result.game}\n")
print(render_summary(result.summary, log_path=config.DIAG_LOG))
print("\n" + render_health(result.findings, title="Findings"))
return 0
def cmd_wrap(args) -> int:
from .core import wrap
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: def cmd_gameenv(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -360,34 +453,41 @@ def cmd_gameenv(args) -> int:
def cmd_games(args) -> int: def cmd_games(args) -> int:
from .core import steam
selected = steam.selected_library_paths()
if not selected:
print("No Steam libraries selected to scan.")
print(" See them with: rigdoctor games libraries")
print(" Then enable one: rigdoctor games libraries --enable <path> (or --all)")
return 1
result = steam.rescan()
if args.json:
from dataclasses import asdict from dataclasses import asdict
from .core import launchers, steam
selected = steam.selected_library_paths()
result = steam.rescan() if selected else None
steam_games = result.games if result else []
extra = launchers.scan() # non-Steam (Lutris/Heroic)
all_games = list(steam_games) + list(extra)
if args.json:
print(json.dumps({ print(json.dumps({
"scanned_at": result.scanned_at, "scanned_at": result.scanned_at if result else None,
"new_appids": result.new_appids, "new_appids": result.new_appids if result else [],
"games": [asdict(g) for g in result.games], "games": [asdict(g) for g in all_games],
}, indent=2, ensure_ascii=False)) }, indent=2, ensure_ascii=False))
return 0 return 0
if not result.games:
print("No games found in the selected Steam libraries.") if not all_games:
if not selected:
print("No Steam libraries selected and no non-Steam games found.")
print(" Pick a Steam library: rigdoctor games libraries --enable <path> (or --all)")
return 1
print("No games found.")
return 0 return 0
new = set(result.new_appids)
print(f"{len(result.games)} game(s) across {len(selected)} librar(y/ies):\n") new = set(result.new_appids) if result else set()
for g in result.games: print(f"{len(all_games)} game(s):\n")
flag = " NEW" if g.appid in new else "" for g in all_games:
print(f" {g.name:<48} {steam.human_size(g.size_bytes):>9}{flag}") tag = " NEW" if g.appid in new else ""
if new: src = "" if g.launcher == "steam" else f" [{g.launcher}]"
print(f"\n{len(new)} newly-installed since the last scan.") size = steam.human_size(g.size_bytes) if g.size_bytes else ""
print(f" {g.name:<46}{src:<10} {size:>9}{tag}")
if not selected:
print("\n(no Steam libraries selected — `rigdoctor games libraries --all` to add them)")
return 0 return 0
@@ -438,8 +538,9 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--json", action="store_true", help="output JSON instead of text") sp.add_argument("--json", action="store_true", help="output JSON instead of text")
sp.set_defaults(func=cmd_snapshot) sp.set_defaults(func=cmd_snapshot)
mp = sub.add_parser("monitor", help="live-refreshing sensor view") mp = sub.add_parser("monitor", help="live monitor TUI (current/min/max, M2)")
mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)") mp.add_argument("-n", "--interval", type=float, default=None, help="refresh interval (s)")
mp.add_argument("--plain", action="store_true", help="plain redraw instead of the curses UI")
mp.set_defaults(func=cmd_monitor) mp.set_defaults(func=cmd_monitor)
sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui) sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui)
@@ -470,6 +571,7 @@ def build_parser() -> argparse.ArgumentParser:
run_p = rec_sub.add_parser("run", help="run the capture loop in the foreground (systemd-friendly)") run_p = rec_sub.add_parser("run", help="run the capture loop in the foreground (systemd-friendly)")
run_p.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)") run_p.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)")
run_p.add_argument("-o", "--out", default=None, help="log file path") run_p.add_argument("-o", "--out", default=None, help="log file path")
run_p.add_argument("--game", default=None, help="tag the capture with a game name (M6/diagnose)")
run_p.set_defaults(func=cmd_record_run) run_p.set_defaults(func=cmd_record_run)
start_p = rec_sub.add_parser("start", help="start recording in the background") start_p = rec_sub.add_parser("start", help="start recording in the background")
@@ -492,13 +594,6 @@ def build_parser() -> argparse.ArgumentParser:
cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec
cp.set_defaults(func=cmd_collect_priv) cp.set_defaults(func=cmd_collect_priv)
share_p = sub.add_parser("share", help="session sharing (M12)")
share_sub = share_p.add_subparsers(dest="share_cmd", required=True)
serve_p = share_sub.add_parser("serve", help="serve a read-only live view (token-gated)")
serve_p.add_argument("--host", default="127.0.0.1", help="bind address (use 0.0.0.0 + a tunnel for remote)")
serve_p.add_argument("--port", type=int, default=8765, help="port")
serve_p.set_defaults(func=cmd_share_serve)
inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details") inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details")
inv.add_argument("--json", action="store_true", help="output JSON") inv.add_argument("--json", action="store_true", help="output JSON")
inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)") inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)")
@@ -519,6 +614,37 @@ def build_parser() -> argparse.ArgumentParser:
env_p = sub.add_parser("gameenv", help="gaming environment checks (M6): flag stability/perf settings") env_p = sub.add_parser("gameenv", help="gaming environment checks (M6): flag stability/perf settings")
env_p.add_argument("--json", action="store_true", help="output JSON instead of text") env_p.add_argument("--json", action="store_true", help="output JSON instead of text")
env_p.set_defaults(func=cmd_gameenv) env_p.set_defaults(func=cmd_gameenv)
diag_p = sub.add_parser("diagnose", help="guided diagnostic: capture while gaming, then analyze")
diag_sub = diag_p.add_subparsers(dest="diagnose_cmd")
diag_start = diag_sub.add_parser("start", help="start a focused capture for a game")
diag_start.add_argument("--game", default=None, help="game name to focus on")
diag_start.add_argument("--appid", default=None, help="Steam appid to focus on (resolved to a name)")
diag_start.add_argument("-n", "--interval", type=float, default=None, help="sampling interval (s)")
diag_start.set_defaults(func=cmd_diagnose)
diag_sub.add_parser("status", help="show the in-progress diagnostic").set_defaults(func=cmd_diagnose)
diag_finish = diag_sub.add_parser("finish", help="stop the capture and analyze it")
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
diag_finish.set_defaults(func=cmd_diagnose)
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
wrap_p = sub.add_parser(
"wrap", help="run a game with automatic crash-capture (Steam launch option, D12)")
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 return p
+7
View File
@@ -23,6 +23,12 @@ CONFIG_FILE = CONFIG_DIR / "config.toml"
# Crash-capture logger (M3) # Crash-capture logger (M3)
LOG_FILE = LOG_DIR / "capture.jsonl" LOG_FILE = LOG_DIR / "capture.jsonl"
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on
# crash log, so its report covers only that session's window.
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture
# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first.
DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl"
STATUS_FILE = STATE_DIR / "recorder.json" STATUS_FILE = STATE_DIR / "recorder.json"
PID_FILE = STATE_DIR / "recorder.pid" PID_FILE = STATE_DIR / "recorder.pid"
SPAWN_LOG = STATE_DIR / "recorder.out" SPAWN_LOG = STATE_DIR / "recorder.out"
@@ -148,6 +154,7 @@ DEFAULTS: dict = {
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this "cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12) "relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet "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
} }
+187
View File
@@ -0,0 +1,187 @@
"""Guided diagnostic session (SPEC §4 / ARCHITECTURE §7.1): orchestrate M3 + M4.
The seed use case, one flow: **pick a game** → **focused crash-capture** scoped to that
session (M3, tagged with the game) → on **finish**, **scan & analyze** (M4 health report)
over the captured window + system logs → return a prioritized result. This is not a new
module — it's a single shared callable so the CLI, GUI, and tray run the identical flow.
The capture is **manually bracketed** (start/finish) for now; auto start/stop on game launch
(the D12 wrapper/watcher) plugs in here later without changing the result shape.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from .. import config
from . import reccontrol
from .crashlog import Summary, summarize
from .health import CRITICAL, OK, WARNING, Finding
_SEV_ORDER = {CRITICAL: 0, WARNING: 1, "info": 2, OK: 3}
@dataclass
class DiagnosticResult:
game: str | None
summary: Summary # capture window: peak temps/power, events, last samples (M3)
findings: list[Finding] # health findings: Xid/SMART/driver/etc. (M4)
@dataclass
class CrashInfo:
game: str | None
samples: int
when: float | None # ts of the last captured sample (≈ when the freeze hit)
gpu_lost: bool
def _clear_diag_log() -> None:
"""Each diagnostic is a fresh focused capture — drop any previous session + segments."""
base = config.DIAG_LOG
for p in [base, *base.parent.glob(base.name + ".*")]:
try:
p.unlink()
except OSError:
pass
def start(game: str | None = None, interval: float | None = None) -> int | None:
"""Begin a focused capture, tagged with the game, into the dedicated diagnostic log.
Returns the pid, or None if a capture is already running."""
if reccontrol.running_pid():
return None
if _crash_from_log(config.DIAG_LOG): # preserve an unanalyzed crash before overwriting it
try:
config.DIAG_LOG.replace(config.DIAG_CRASH)
except OSError:
pass
_clear_diag_log()
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
def is_running() -> bool:
return reccontrol.running_pid() is not None
def active() -> dict | None:
"""Status of the in-progress session (running flag, game, samples), or None if idle."""
if not is_running():
return None
return reccontrol.read_status()
def _await_stopped(timeout: float = 6.0) -> None:
deadline = time.monotonic() + timeout
while reccontrol.running_pid() and time.monotonic() < deadline:
time.sleep(0.1)
def _game_from_summary(summary: Summary) -> str | None:
"""Recover the focused game from the log's 'game' event (survives a crash + reboot)."""
for _ts, kind, detail in reversed(summary.events):
if kind == "game" and detail:
return detail
return None
def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
"""Stop the capture (if running), summarize the window, and run the health report."""
from .health import run_health_checks
reccontrol.stop_background()
_await_stopped()
path = log_path or config.DIAG_LOG
summary = summarize(path, last_n=last_n)
game = _game_from_summary(summary) or (reccontrol.read_status() or {}).get("game")
findings = run_health_checks()
return DiagnosticResult(game=game, summary=summary, findings=findings)
# --- hard-crash detection & post-crash analysis -----------------------------------
def _crash_from_log(path) -> CrashInfo | None:
"""CrashInfo if `path` holds an abnormally-ended session (start, no stop, not acked)."""
if not path.exists():
return None
summary = summarize(path)
kinds = {kind for _ts, kind, _detail in summary.events}
if "session-start" not in kinds:
return None
if "session-stop" in kinds or "diagnostic-acknowledged" in kinds:
return None
return CrashInfo(
game=_game_from_summary(summary),
samples=summary.samples,
when=summary.end,
gpu_lost="gpu-lost" in kinds,
)
def _crash_path():
"""Where the pending crash lives: the preserved archive if present, else the live log."""
return config.DIAG_CRASH if config.DIAG_CRASH.exists() else config.DIAG_LOG
def pending_crash() -> CrashInfo | None:
"""Detect a diagnostic that ended abnormally (no clean stop, no live recorder).
A focused capture writes `session-start` (+ `game`) and, on a clean stop, `session-stop`.
After a hard freeze that block never runs, so the log has a start with no stop and no
live recorder — that's our hard-crash signal. A crash preserved across an auto-relaunch
(`DIAG_CRASH`) is checked first. Returns None if a capture is running, none is recorded,
it stopped cleanly, or the user already acknowledged it.
"""
info = _crash_from_log(config.DIAG_CRASH) # preserved across a relaunch (wrapper)
if info is not None:
return info
if is_running():
return None
return _crash_from_log(config.DIAG_LOG)
def acknowledge_crash() -> None:
"""Mark the recorded crash as seen so it stops prompting."""
try:
config.DIAG_CRASH.unlink() # drop the preserved archive, if any
except OSError:
pass
try:
config.DIAG_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(config.DIAG_LOG, "a", encoding="utf-8") as fh:
fh.write(json.dumps({"ts": time.time(), "event": "diagnostic-acknowledged", "detail": ""}) + "\n")
except OSError:
pass
def _crash_headline(summary: Summary) -> Finding:
gpu_lost = any(kind == "gpu-lost" for _ts, kind, _detail in summary.events)
when = time.strftime("%H:%M:%S", time.localtime(summary.end)) if summary.end else "?"
detail = (
f"The capture stopped abruptly at {when} after {summary.samples} samples, with no clean "
"shutdown recorded — consistent with a hard freeze or power loss."
)
if gpu_lost:
detail += " A GPU-lost event was captured during the session."
return Finding(
CRITICAL if gpu_lost else WARNING,
"Diagnostic",
"Session ended without a clean stop (likely a hard crash)",
detail,
"Review the last readings (Capture, above) and the crash-boot findings below.",
)
def analyze_crash(last_n: int = 15) -> DiagnosticResult:
"""Analyze a recorded hard crash: the captured window + the previous boot's kernel log
+ the rest of the health report (SMART/driver/persistence/temps)."""
from .health import check_previous_boot, run_health_checks
summary = summarize(_crash_path(), last_n=last_n)
findings: list[Finding] = [_crash_headline(summary)]
findings += check_previous_boot() # the crashed boot's kernel log
findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps
findings.sort(key=lambda f: _SEV_ORDER.get(f.severity, 9))
return DiagnosticResult(game=_game_from_summary(summary), summary=summary, findings=findings)
+57
View File
@@ -71,6 +71,32 @@ def check_pcie_aspm() -> list[Finding]:
# --- NVIDIA persistence mode (seed-case relevant) ------------------------------------- # --- NVIDIA persistence mode (seed-case relevant) -------------------------------------
def check_gpu_powermizer() -> list[Finding]:
"""NVIDIA PowerMizer preferred-performance mode (X only, via nvidia-settings)."""
if shutil.which("nvidia-settings") is None or not os.environ.get("DISPLAY"):
return []
try:
proc = subprocess.run(
["nvidia-settings", "-q", "[gpu:0]/GPUPowerMizerMode", "-t"],
capture_output=True, text=True, timeout=10,
)
except (subprocess.SubprocessError, OSError):
return []
raw = proc.stdout.strip().splitlines()[0].strip() if proc.stdout.strip() else ""
if not raw.isdigit(): # no X target / Wayland / query failed — skip quietly
return []
names = {0: "Adaptive", 1: "Prefer Maximum Performance", 2: "Auto"}
name = names.get(int(raw), f"mode {raw}")
if int(raw) == 1:
return [Finding(OK, "GPU", f"GPU PowerMizer: {name}", "The GPU prefers maximum performance.")]
return [Finding(
INFO, "GPU", f"GPU PowerMizer: {name}",
"Adaptive/Auto can downclock the GPU between load spikes, hurting frame consistency.",
"Prefer max performance (X only, resets on reboot): "
"`nvidia-settings -a '[gpu:0]/GPUPowerMizerMode=1'`.",
)]
def check_gpu_persistence() -> list[Finding]: def check_gpu_persistence() -> list[Finding]:
if shutil.which("nvidia-smi") is None: if shutil.which("nvidia-smi") is None:
return [] return []
@@ -235,6 +261,34 @@ def check_mitigations() -> list[Finding]:
# --- Proton versions (informational) -------------------------------------------------- # --- Proton versions (informational) --------------------------------------------------
def check_wine() -> list[Finding]:
"""System Wine version (used by Lutris / non-Proton games)."""
if shutil.which("wine") is None:
return []
try:
proc = subprocess.run(["wine", "--version"], capture_output=True, text=True, timeout=10)
except (subprocess.SubprocessError, OSError):
return []
ver = proc.stdout.strip().split()[0] if proc.stdout.strip() else ""
if not ver:
return []
return [Finding(
INFO, "Tools", f"Wine: {ver}",
"System Wine — used by Lutris and non-Proton titles.",
"Steam games generally run best on Proton; keep Wine current for native/Lutris use.",
)]
def check_steam_client() -> list[Finding]:
"""Installed Steam client package version."""
from . import steam
ver = steam.client_version()
if not ver:
return []
return [Finding(INFO, "Tools", f"Steam client: {ver}", "The installed Steam package version.")]
def check_proton() -> list[Finding]: def check_proton() -> list[Finding]:
from . import steam from . import steam
@@ -259,6 +313,7 @@ def run_gameenv_checks() -> list[Finding]:
findings: list[Finding] = [] findings: list[Finding] = []
findings += check_pcie_aspm() findings += check_pcie_aspm()
findings += check_gpu_persistence() findings += check_gpu_persistence()
findings += check_gpu_powermizer()
findings += check_cpu_governor() findings += check_cpu_governor()
findings += check_gamemode() findings += check_gamemode()
findings += check_mangohud() findings += check_mangohud()
@@ -267,5 +322,7 @@ def run_gameenv_checks() -> list[Finding]:
findings += check_thp() findings += check_thp()
findings += check_mitigations() findings += check_mitigations()
findings += check_proton() findings += check_proton()
findings += check_wine()
findings += check_steam_client()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings return findings
+21 -1
View File
@@ -146,6 +146,22 @@ def check_journal() -> list[Finding]:
return findings return findings
def check_previous_boot() -> list[Finding]:
"""Scan the previous boot's kernel log — the boot that crashed — for fault signatures.
Needs persistent journald (else the crashed boot's logs were lost on reboot, which the
persistence check flags separately). Findings are framed as coming from that boot.
"""
out = _journalctl(["-k", "-b", "-1", "--no-pager", "-o", "cat"])
if not out or not out.strip():
return []
tagged = []
for f in scan_journal_text(out):
detail = ("Logged during the previous (crashed) boot. " + (f.detail or "")).strip()
tagged.append(Finding(f.severity, f.category, f.title, detail, f.suggestion))
return tagged
def check_journal_persistence() -> list[Finding]: def check_journal_persistence() -> list[Finding]:
if Path("/var/log/journal").is_dir(): if Path("/var/log/journal").is_dir():
return [] return []
@@ -235,16 +251,20 @@ def check_live_temps() -> list[Finding]:
)] )]
def run_health_checks() -> list[Finding]: def run_health_checks(include_journal: bool = True) -> list[Finding]:
"""Run all checks and return findings sorted by severity (worst first). """Run all checks and return findings sorted by severity (worst first).
SMART needs root; if the session collected it via launch elevation, use that SMART needs root; if the session collected it via launch elevation, use that
instead of re-running smartctl (which would just report "needs root"). instead of re-running smartctl (which would just report "needs root").
`include_journal=False` skips the 7-day kernel-journal scan — used by the crash
analysis, which scans the previous (crashed) boot specifically instead.
""" """
from . import elevation from . import elevation
findings: list[Finding] = [] findings: list[Finding] = []
findings += check_nvidia_driver() findings += check_nvidia_driver()
if include_journal:
findings += check_journal() findings += check_journal()
findings += check_journal_persistence() findings += check_journal_persistence()
priv = elevation.privileged() priv = elevation.privileged()
+89
View File
@@ -0,0 +1,89 @@
"""Non-Steam game detection (M6): Lutris + Heroic installed games.
Reads each launcher's own install records (Lutris' SQLite library, Heroic's JSON stores),
returning the same `steam.Game` shape tagged with the launcher. Stdlib only; every reader
degrades to [] if the launcher isn't installed or its files can't be parsed.
"""
from __future__ import annotations
import json
import os
import sqlite3
from pathlib import Path
from .steam import Game
LUTRIS_DB = Path(os.path.expanduser("~/.local/share/lutris/pga.db"))
HEROIC_DIR = Path(os.path.expanduser("~/.config/heroic"))
def _lutris_games() -> list[Game]:
db = LUTRIS_DB
if not db.exists():
return []
games: list[Game] = []
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) # read-only
try:
rows = con.execute(
"SELECT name, slug FROM games WHERE installed = 1 AND name IS NOT NULL"
).fetchall()
finally:
con.close()
except (sqlite3.Error, OSError):
return []
for name, slug in rows:
if name:
games.append(Game(appid=slug or "", name=str(name), library="", installdir="",
launcher="lutris"))
return games
def _read_json(path: Path):
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return None
def _heroic_games() -> list[Game]:
base = HEROIC_DIR
if not base.is_dir():
return []
games: list[Game] = []
# Epic / Legendary: {app_name: {"title": ..., ...}}
epic = _read_json(base / "legendaryConfig" / "legendary" / "installed.json")
if isinstance(epic, dict):
for app_name, info in epic.items():
if isinstance(info, dict):
games.append(Game(appid=str(app_name), name=info.get("title") or str(app_name),
library="", installdir="", launcher="heroic"))
# GOG: {"installed": [{"appName", "install_path", "title"?}]}
gog = _read_json(base / "gog_store" / "installed.json")
entries = gog.get("installed") if isinstance(gog, dict) else None
if isinstance(entries, list):
for e in entries:
if not isinstance(e, dict):
continue
install_path = e.get("install_path") or ""
title = e.get("title") or os.path.basename(install_path.rstrip("/")) or str(e.get("appName", ""))
if title:
games.append(Game(appid=str(e.get("appName", "")), name=title, library="",
installdir="", launcher="heroic"))
return games
def scan() -> list[Game]:
"""Installed non-Steam games (Lutris + Heroic), de-duplicated, sorted by name."""
seen: set[tuple[str, str]] = set()
out: list[Game] = []
for game in _lutris_games() + _heroic_games():
key = (game.launcher, game.name)
if key in seen:
continue
seen.add(key)
out.append(game)
return sorted(out, key=lambda g: g.name.lower())
+5 -1
View File
@@ -38,7 +38,9 @@ def read_status() -> dict | None:
return None return None
def start_background(interval: float | None = None, out: str | None = None) -> int | None: def start_background(
interval: float | None = None, out: str | None = None, game: str | None = None
) -> int | None:
"""Spawn a detached `record run`. Returns the child pid, or None if already running.""" """Spawn a detached `record run`. Returns the child pid, or None if already running."""
if running_pid(): if running_pid():
return None return None
@@ -48,6 +50,8 @@ def start_background(interval: float | None = None, out: str | None = None) -> i
cmd += ["--interval", str(interval)] cmd += ["--interval", str(interval)]
if out: if out:
cmd += ["--out", out] cmd += ["--out", out]
if game:
cmd += ["--game", game]
out_fh = open(config.SPAWN_LOG, "a") out_fh = open(config.SPAWN_LOG, "a")
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
+5
View File
@@ -27,12 +27,14 @@ class Recorder:
backups: int = 10, backups: int = 10,
status_path=None, status_path=None,
sampler: Sampler | None = None, sampler: Sampler | None = None,
game: str | None = None,
) -> None: ) -> None:
self.interval = interval self.interval = interval
self.sampler = sampler or Sampler(available_sources()) self.sampler = sampler or Sampler(available_sources())
self.writer = CrashLogWriter(log_path, max_bytes, backups) self.writer = CrashLogWriter(log_path, max_bytes, backups)
self.log_path = Path(log_path) self.log_path = Path(log_path)
self.status_path = Path(status_path) if status_path else None self.status_path = Path(status_path) if status_path else None
self.game = game or None
self.samples = 0 self.samples = 0
self._stop = threading.Event() self._stop = threading.Event()
self._gpu_lost = False self._gpu_lost = False
@@ -43,6 +45,8 @@ class Recorder:
def run(self) -> None: def run(self) -> None:
self.writer.write_event("session-start", f"interval={self.interval:g}s") self.writer.write_event("session-start", f"interval={self.interval:g}s")
if self.game:
self.writer.write_event("game", self.game) # tag the focused-diagnostic target
self._write_status(running=True) self._write_status(running=True)
try: try:
while not self._stop.is_set(): while not self._stop.is_set():
@@ -81,6 +85,7 @@ class Recorder:
"samples": self.samples, "samples": self.samples,
"updated": time.time(), "updated": time.time(),
"gpu_lost": self._gpu_lost, "gpu_lost": self._gpu_lost,
"game": self.game,
} }
if sample is not None: if sample is not None:
data["latest"] = headline(sample) data["latest"] = headline(sample)
+118
View File
@@ -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
-194
View File
@@ -1,194 +0,0 @@
"""Session sharing (M12, Tier 2): a read-only live view over a local HTTP server.
Serves the live sensor snapshot + health report + inventory, **read-only**, gated by a
random share token. Bind to localhost for local testing, or to all interfaces behind a
user-chosen tunnel (Tailscale / cloudflared / SSH) for remote help. No actions, no terminal.
"""
from __future__ import annotations
import json
import secrets
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from .sampler import Sampler
from .sources import available_sources
_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>RigDoctor — shared</title>
<style>
body{background:#101216;color:#e6e8eb;font-family:system-ui,sans-serif;margin:0;padding:24px}
h1{font-size:20px;margin:0 0 4px} h2{font-size:14px;color:#8b929c;margin:18px 0 6px}
.card{background:#1b1f26;border:1px solid #2a2f39;border-radius:12px;padding:16px;margin:14px 0}
table{width:100%;border-collapse:collapse} td{padding:3px 0;font-size:14px}
td.v{text-align:right;font-weight:600} .muted{color:#8b929c}
.critical{color:#f87171} .warning{color:#fb923c} .ok{color:#4ade80} .info{color:#8b929c}
.badge{display:inline-block;background:#38bdf8;color:#06222e;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:700}
</style></head><body>
<h1>RigDoctor <span class="badge">read-only share</span></h1>
<p class="muted">A live view shared by the machine's owner. You can look, not change anything.</p>
<div class="card"><div id="live">loading…</div></div>
<div class="card"><h2 style="margin-top:0">Health</h2><div id="health">loading…</div></div>
<div class="card"><h2 style="margin-top:0">Inventory</h2><div id="inv">loading…</div></div>
<script>
const T=new URLSearchParams(location.search).get('t');
const j=async p=>(await fetch(p+'?t='+encodeURIComponent(T))).json();
const fmt=(v,u)=>v==null?'N/A':(u==='\\u00b0C'?(+v).toFixed(1)+' °C':(u?v+' '+u:v));
async function live(){try{const d=await j('/api/snapshot');let h='';
for(const[g,items]of Object.entries(d.groups)){h+='<h2>'+g.toUpperCase()+'</h2><table>';
for(const it of items)h+='<tr><td class="muted">'+it.name+'</td><td class="v">'+fmt(it.value,it.unit)+'</td></tr>';
h+='</table>';}document.getElementById('live').innerHTML=h;}catch(e){}}
async function once(){try{const r=await j('/api/report');
document.getElementById('health').innerHTML=r.map(f=>'<div><span class="'+f.severity+'">['+f.severity.toUpperCase()+']</span> '+f.category+': '+f.title+'</div>').join('')||'no findings';}catch(e){}
try{const inv=await j('/api/inventory');let h='';
for(const[s,kv]of Object.entries(inv)){h+='<h2>'+s+'</h2><table>';
for(const[k,v]of Object.entries(kv))h+='<tr><td class="muted">'+k+'</td><td class="v">'+v+'</td></tr>';
h+='</table>';}document.getElementById('inv').innerHTML=h;}catch(e){}}
live();once();setInterval(live,2000);
</script></body></html>"""
def _snapshot(sampler: Sampler) -> dict:
sample = sampler.sample()
groups: dict[str, list] = {}
for r in sample.readings:
if r.metric == "name":
item = {"name": "device", "value": r.label, "unit": ""}
else:
item = {"name": (r.label + " " + r.metric).strip() if r.label else r.metric,
"value": r.value, "unit": r.unit}
groups.setdefault(r.source, []).append(item)
return {"ts": sample.ts, "groups": groups}
def _report() -> list:
from .health import run_health_checks
return [asdict(f) for f in run_health_checks()]
def _inventory() -> dict:
from .inventory import collect, to_dict
return to_dict(collect())
# --- Relay (M12) frames: a host streams these; a guest renders them. -----------------
def host_full_frame(sampler: Sampler) -> str:
"""Initial frame: live snapshot + health report + inventory."""
return json.dumps({"type": "full", "snapshot": _snapshot(sampler),
"report": _report(), "inventory": _inventory()})
def host_snapshot_frame(sampler: Sampler) -> str:
"""Recurring frame: just the live snapshot."""
return json.dumps({"type": "snapshot", "snapshot": _snapshot(sampler)})
def _fmt(value, unit: str) -> str:
if value is None:
return "N/A"
if unit == "°C":
try:
return f"{float(value):.1f} °C"
except (TypeError, ValueError):
return str(value)
return f"{value} {unit}".strip()
def guest_html(snapshot: dict | None, report: list | None, inventory: dict | None) -> str:
"""Render a received frame as read-only dark HTML for the guest's view."""
import html as _html
def esc(x) -> str:
return _html.escape(str(x))
out = ['<div style="font-family:sans-serif;color:#e6e8eb">']
if snapshot:
for group, items in snapshot.get("groups", {}).items():
out.append(f'<h3 style="color:#8b929c">{esc(group).upper()}</h3><table width="100%">')
for it in items:
out.append(f'<tr><td style="color:#8b929c">{esc(it.get("name"))}</td>'
f'<td align="right"><b>{esc(_fmt(it.get("value"), it.get("unit", "")))}</b></td></tr>')
out.append("</table>")
if report:
out.append('<h3 style="color:#8b929c">HEALTH</h3>')
colors = {"critical": "#f87171", "warning": "#fb923c", "ok": "#4ade80"}
for f in report:
sev = f.get("severity", "info")
out.append(f'<div><span style="color:{colors.get(sev, "#8b929c")}">[{esc(sev).upper()}]</span> '
f'{esc(f.get("category"))}: {esc(f.get("title"))}</div>')
if inventory:
out.append('<h3 style="color:#8b929c">INVENTORY</h3>')
for section, kv in inventory.items():
out.append(f'<h4 style="margin:6px 0;color:#8b929c">{esc(section)}</h4><table width="100%">')
for k, v in kv.items():
out.append(f'<tr><td style="color:#8b929c">{esc(k)}</td><td align="right"><b>{esc(v)}</b></td></tr>')
out.append("</table>")
out.append("</div>")
return "".join(out)
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # quiet
pass
def _authed(self, query: dict) -> bool:
return secrets.compare_digest(query.get("t", [""])[0], self.server.token)
def _send(self, code: int, ctype: str, body: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._authed(parse_qs(parsed.query)):
self._send(403, "text/plain", b"Forbidden: missing or invalid share token")
return
if parsed.path == "/":
self._send(200, "text/html; charset=utf-8", _PAGE.encode())
elif parsed.path == "/api/snapshot":
self._send(200, "application/json", json.dumps(_snapshot(self.server.sampler)).encode())
elif parsed.path == "/api/report":
self._send(200, "application/json", json.dumps(_report()).encode())
elif parsed.path == "/api/inventory":
self._send(200, "application/json", json.dumps(_inventory()).encode())
else:
self._send(404, "text/plain", b"Not found")
class _Server(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, addr, token: str):
super().__init__(addr, _Handler)
self.token = token
self.sampler = Sampler(available_sources())
def make_server(host: str = "127.0.0.1", port: int = 0, token: str | None = None) -> tuple[_Server, str]:
token = token or secrets.token_urlsafe(16)
return _Server((host, port), token), token
def serve(host: str = "127.0.0.1", port: int = 8765) -> int:
srv, token = make_server(host, port)
url = f"http://{host}:{srv.server_address[1]}/?t={token}"
print(
f"Sharing a read-only live view at:\n {url}\n\n"
"Anyone with this URL (and network access to this host) can VIEW your sensors,\n"
"health report, and inventory — read-only. For remote help, expose it via a tunnel\n"
"(Tailscale / cloudflared / `ssh -R`). Press Ctrl-C to stop sharing.",
flush=True,
)
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\nStopped sharing.")
finally:
srv.shutdown()
return 0
+39 -2
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import subprocess
import time import time
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
@@ -56,10 +58,11 @@ class SteamLibrary:
class Game: class Game:
appid: str appid: str
name: str name: str
library: str # library path the game lives in library: str # library path the game lives in (Steam)
installdir: str # folder name under <library>/steamapps/common installdir: str # folder name under <library>/steamapps/common
size_bytes: int = 0 size_bytes: int = 0
last_updated: int = 0 # epoch seconds (acf LastUpdated), 0 if unknown last_updated: int = 0 # epoch seconds (acf LastUpdated), 0 if unknown
launcher: str = "steam" # "steam" | "lutris" | "heroic"
# --- VDF (Valve Data Format) parsing -------------------------------------------------- # --- VDF (Valve Data Format) parsing --------------------------------------------------
@@ -311,7 +314,8 @@ def cached_games() -> list[Game]:
cache = load_cache() cache = load_cache()
if not cache: if not cache:
return [] return []
return [Game(**{k: g.get(k) for k in Game.__dataclass_fields__}) for g in cache.get("games", [])] # Only pass keys present in the record so dataclass defaults fill any new fields.
return [Game(**{k: g[k] for k in Game.__dataclass_fields__ if k in g}) for g in cache.get("games", [])]
def rescan(cfg: dict | None = None) -> ScanResult: def rescan(cfg: dict | None = None) -> ScanResult:
@@ -351,6 +355,39 @@ def acknowledge_new() -> None:
# --- formatting ----------------------------------------------------------------------- # --- formatting -----------------------------------------------------------------------
def client_version() -> str | None:
"""The installed Steam package version (apt), or None — best-effort, offline."""
if shutil.which("dpkg-query") is None:
return None
for pkg in ("steam-installer", "steam-launcher", "steam"):
try:
proc = subprocess.run(["dpkg-query", "-W", "-f=${Version}", pkg],
capture_output=True, text=True, timeout=10)
except (subprocess.SubprocessError, OSError):
continue
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
return None
def launch_game(appid: str) -> bool:
"""Best-effort: ask Steam to launch a game by appid (steam:// URL). Non-blocking."""
if not appid:
return False
url = f"steam://rungameid/{appid}"
for cmd in (["steam", url], ["xdg-open", url]):
if shutil.which(cmd[0]):
try:
subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
return True
except (OSError, subprocess.SubprocessError):
continue
return False
def human_size(num_bytes: int) -> str: def human_size(num_bytes: int) -> str:
if num_bytes <= 0: if num_bytes <= 0:
return "" return ""
+107
View File
@@ -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
+78
View File
@@ -0,0 +1,78 @@
"""Steam-launch wrapper (D12): auto-bracket a focused diagnostic around a game.
Set as a per-game Steam launch option — `rigdoctor wrap %command%` — or in Lutris/Heroic's
wrapper field. Steam expands `%command%` to the real game command; we start a focused capture
(tagged with the game), run the game, and stop the capture cleanly when it exits. A hard
freeze means the game (and this wrapper) never returns, so the capture is left without a clean
stop — which RigDoctor then flags as a crash on next launch.
Deterministic and daemonless (D12 "build first"): no polling, and it knows the title.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
from pathlib import Path
def game_name_from_env() -> str | None:
"""The launching game's name, resolved from Steam's SteamAppId env var via the scan."""
appid = os.environ.get("SteamAppId") or os.environ.get("SteamGameId")
if not appid:
return None
from . import steam
games = steam.cached_games() or steam.scan_games(steam.selected_library_paths())
for game in games:
if game.appid == str(appid):
return game.name
return f"Steam app {appid}"
def launch_option() -> str:
"""The exact string to paste into Steam's Launch Options (absolute path → PATH-proof)."""
exe = Path(sys.executable).with_name("rigdoctor")
prog = str(exe) if exe.exists() else "rigdoctor"
quoted = f'"{prog}"' if " " in prog else prog
return f"{quoted} wrap %command%"
def run(command: list[str]) -> int:
"""Start a focused capture (unless one's already running), run the game, then stop it.
Returns the game's exit code so Steam sees the right status."""
from . import diagnostic, reccontrol
if not command:
print("usage: rigdoctor wrap %command% (set as a Steam launch option)", file=sys.stderr)
return 2
game = game_name_from_env() or os.path.basename(command[0])
started = False
if not reccontrol.running_pid(): # don't disturb an existing capture
started = diagnostic.start(game=game) is not None
proc: subprocess.Popen | None = None
def _forward(signum, _frame): # pass Steam's stop signal to the game
if proc is not None and proc.poll() is None:
try:
proc.send_signal(signum)
except OSError:
pass
previous = {sig: signal.signal(sig, _forward) for sig in (signal.SIGTERM, signal.SIGINT)}
try:
proc = subprocess.Popen(command)
rc = proc.wait()
except (OSError, ValueError, subprocess.SubprocessError) as exc:
print(f"rigdoctor wrap: couldn't launch the game: {exc}", file=sys.stderr)
rc = 1
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
if started:
reccontrol.stop_background() # clean stop → no false crash flag
return rc
+6
View File
@@ -30,6 +30,12 @@ def main(argv: list[str] | None = None) -> int:
interval = float(load_config().get("interval", 1.0)) interval = float(load_config().get("interval", 1.0))
window = MainWindow(interval=interval) window = MainWindow(interval=interval)
# `--tray` starts hidden to the system tray (for autostart); if no tray is available,
# fall back to showing the window so the app is never invisible-and-unreachable.
args = argv if argv is not None else sys.argv
if "--tray" in args and window.tray_available():
window.start_minimized_note()
else:
window.show() window.show()
return app.exec() return app.exec()
+17 -17
View File
@@ -17,19 +17,19 @@ from PySide6.QtWidgets import (
from ..core.sample import Sample from ..core.sample import Sample
from ..render import metric_label from ..render import metric_label
from .widgets import Card, MetricBar, MetricRow, StatGauge from .widgets import Card, HistoryGraph, MetricBar, MetricRow
_GROUP_ORDER = ["gpu", "cpu", "memory", "storage"] _GROUP_ORDER = ["gpu", "cpu", "memory", "storage"]
_GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"} _GROUP_TITLES = {"gpu": "GPU", "cpu": "CPU", "memory": "Memory", "storage": "Storage"}
_BAR_METRICS = {"util", "mem_util", "fan", "used_pct"} _BAR_METRICS = {"util", "mem_util", "fan", "used_pct"}
def _gauge_card(gauge: StatGauge) -> QFrame: def _tile_card(widget: QWidget) -> QFrame:
card = QFrame() card = QFrame()
card.setObjectName("Card") card.setObjectName("Card")
layout = QVBoxLayout(card) layout = QVBoxLayout(card)
layout.setContentsMargins(6, 14, 6, 8) layout.setContentsMargins(6, 10, 6, 8)
layout.addWidget(gauge) layout.addWidget(widget)
return card return card
@@ -54,16 +54,16 @@ class Dashboard(QWidget):
header.addWidget(self._updated) header.addWidget(self._updated)
root.addLayout(header) root.addLayout(header)
# Headline gauges # Headline trend graphs (history over the session, not just the live value)
self._g_gpu_temp = StatGauge("GPU Temp", "°C", 100, "temp") self._g_gpu_temp = HistoryGraph("GPU Temp", "°C", 30, 100, "temp")
self._g_gpu_load = StatGauge("GPU Load", "%", 100, "accent") self._g_gpu_load = HistoryGraph("GPU Load", "%", 0, 100, "accent")
self._g_cpu_temp = StatGauge("CPU Temp", "°C", 100, "temp") self._g_cpu_temp = HistoryGraph("CPU Temp", "°C", 30, 100, "temp")
self._g_mem = StatGauge("Memory", "%", 100, "usage") self._g_mem = HistoryGraph("Memory", "%", 0, 100, "usage")
gauges = QHBoxLayout() graphs = QHBoxLayout()
gauges.setSpacing(14) graphs.setSpacing(14)
for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem): for g in (self._g_gpu_temp, self._g_gpu_load, self._g_cpu_temp, self._g_mem):
gauges.addWidget(_gauge_card(g)) graphs.addWidget(_tile_card(g))
root.addLayout(gauges) root.addLayout(graphs)
# Per-subsystem cards (scrollable, 2-column grid) # Per-subsystem cards (scrollable, 2-column grid)
scroll = QScrollArea() scroll = QScrollArea()
@@ -81,10 +81,10 @@ class Dashboard(QWidget):
root.addWidget(scroll, 1) root.addWidget(scroll, 1)
def update_sample(self, sample: Sample) -> None: def update_sample(self, sample: Sample) -> None:
self._g_gpu_temp.set_value(self._val(sample, "gpu", "temp", "")) self._g_gpu_temp.add_value(self._val(sample, "gpu", "temp", ""))
self._g_gpu_load.set_value(self._val(sample, "gpu", "util")) self._g_gpu_load.add_value(self._val(sample, "gpu", "util"))
self._g_cpu_temp.set_value(self._cpu_temp(sample)) self._g_cpu_temp.add_value(self._cpu_temp(sample))
self._g_mem.set_value(self._val(sample, "memory", "used_pct")) self._g_mem.add_value(self._val(sample, "memory", "used_pct"))
keys = [r.key for r in sample.readings] keys = [r.key for r in sample.readings]
if keys != self._built_keys: # sources appeared/disappeared if keys != self._built_keys: # sources appeared/disappeared
+81
View File
@@ -0,0 +1,81 @@
"""Results view for a guided diagnostic session (M6/D12): capture summary + findings."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..render import render_summary
from .widgets import finding_card
class DiagnosticDialog(QDialog):
def __init__(self, result, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
self.resize(660, 680)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 16)
root.setSpacing(14)
title = QLabel(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
title.setObjectName("PageTitle")
root.addWidget(title)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(10)
col.setAlignment(Qt.AlignmentFlag.AlignTop)
# Capture window summary (peaks / events / last samples) — monospace for the columns.
cap_head = QLabel("Capture")
cap_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(cap_head)
summary = QLabel(render_summary(result.summary))
summary.setObjectName("Report")
summary.setFont(QFont("monospace"))
summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
summary.setWordWrap(False)
summary.setStyleSheet(
"background: #0d0f13; color: #cfd3da; border: 1px solid #2a2f39; "
"border-radius: 8px; padding: 10px;"
)
col.addWidget(summary)
find_head = QLabel(f"Findings ({len(result.findings)})")
find_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(find_head)
if result.findings:
for finding in result.findings:
col.addWidget(finding_card(finding))
else:
none = QLabel("No findings.")
none.setObjectName("Muted")
col.addWidget(none)
scroll.setWidget(body)
root.addWidget(scroll, 1)
buttons = QHBoxLayout()
buttons.addStretch(1)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(self.accept)
buttons.addWidget(close)
root.addLayout(buttons)
+19 -8
View File
@@ -19,9 +19,20 @@ from PySide6.QtWidgets import (
from .widgets import finding_card from .widgets import finding_card
def _fail_reason(out: str) -> str:
"""Turn the failed command's output into a short, human reason."""
low = (out or "").lower()
if "not authorized" in low or "dismissed" in low or "authentication" in low:
return "cancelled at the password prompt"
if "operation not permitted" in low or "invalid argument" in low or "permission denied" in low:
return "the system rejected the change (it may be locked by BIOS/kernel)"
last = next((ln.strip() for ln in reversed((out or "").splitlines()) if ln.strip()), "")
return (last[:80] or "no privileges, or cancelled")
class EnvironmentPage(QWidget): class EnvironmentPage(QWidget):
_result = Signal(object) # list[Finding] _result = Signal(object) # list[Finding]
_action_done = Signal(object) # (label, rc) — install or apply finished _action_done = Signal(object) # (label, rc, output) — install or apply finished
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -35,7 +46,7 @@ class EnvironmentPage(QWidget):
root.setSpacing(16) root.setSpacing(16)
header = QHBoxLayout() header = QHBoxLayout()
title = QLabel("Environment") title = QLabel("Tuning")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
@@ -117,8 +128,8 @@ class EnvironmentPage(QWidget):
def _work_install(self, component) -> None: def _work_install(self, component) -> None:
from ..core import installer from ..core import installer
rc, _out = installer.install_packages(list(component.apt)) rc, out = installer.install_packages(list(component.apt))
self._action_done.emit((component.name, rc)) self._action_done.emit((component.name, rc, out))
def _apply(self, fix_id: str, value: str) -> None: def _apply(self, fix_id: str, value: str) -> None:
if self._busy: if self._busy:
@@ -131,15 +142,15 @@ class EnvironmentPage(QWidget):
def _work_apply(self, fix_id: str, value: str) -> None: def _work_apply(self, fix_id: str, value: str) -> None:
from ..core import fixes from ..core import fixes
rc, _out = fixes.apply(fix_id, value) rc, out = fixes.apply(fix_id, value)
self._action_done.emit((value, rc)) self._action_done.emit((value, rc, out))
def _on_action_done(self, result) -> None: def _on_action_done(self, result) -> None:
label, rc = result label, rc, out = result
self._busy = False self._busy = False
if rc == 0: if rc == 0:
self._status.setText(f"{label} applied — re-checking…") self._status.setText(f"{label} applied — re-checking…")
self._run() # re-run so the finding reflects the new state self._run() # re-run so the finding reflects the new state
else: else:
self._run_btn.setEnabled(True) self._run_btn.setEnabled(True)
self._status.setText(f"'{label}' failed (cancelled, or needs privileges)") self._status.setText(f"'{label}' failed {_fail_reason(out)}")
+274 -6
View File
@@ -13,10 +13,14 @@ import time
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCheckBox, QCheckBox,
QDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QMessageBox,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QVBoxLayout, QVBoxLayout,
@@ -24,10 +28,11 @@ from PySide6.QtWidgets import (
) )
from ..config import load_config, update_config from ..config import load_config, update_config
from .theme import ACCENT, GOOD, MUTED from .diagnostic_dialog import DiagnosticDialog
from .theme import ACCENT, GOOD, MUTED, WARN
def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame: def _game_row(name: str, sublabel: str, size: str, is_new: bool, appid: str = "", on_diagnose=None) -> QFrame:
card = QFrame() card = QFrame()
card.setObjectName("Card") card.setObjectName("Card")
h = QHBoxLayout(card) h = QHBoxLayout(card)
@@ -59,6 +64,13 @@ def _game_row(name: str, sublabel: str, size: str, is_new: bool) -> QFrame:
size_label.setMinimumWidth(80) size_label.setMinimumWidth(80)
size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
h.addWidget(size_label, 0) h.addWidget(size_label, 0)
if on_diagnose is not None:
diag_btn = QPushButton("Run Diagnostic")
diag_btn.setObjectName("ActionButton")
diag_btn.setCursor(Qt.CursorShape.PointingHandCursor)
diag_btn.clicked.connect(lambda: on_diagnose(name, appid))
h.addWidget(diag_btn, 0)
return card return card
@@ -66,14 +78,18 @@ class GamesPage(QWidget):
_libraries_ready = Signal(object) # list[dict(path, label, count, selected)] _libraries_ready = Signal(object) # list[dict(path, label, count, selected)]
_scanned = Signal(object) # steam.ScanResult _scanned = Signal(object) # steam.ScanResult
new_count_changed = Signal(int) # newly-installed game count (for the nav badge) new_count_changed = Signal(int) # newly-installed game count (for the nav badge)
_diag_done = Signal(object) # DiagnosticResult — focused capture analyzed
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._libraries_ready.connect(self._render_libraries) self._libraries_ready.connect(self._render_libraries)
self._scanned.connect(self._render_games) self._scanned.connect(self._render_games)
self._diag_done.connect(self._on_diag_done)
self._busy = False self._busy = False
self._new_appids: set[str] = set() self._new_appids: set[str] = set()
self._extra_games: list = [] # non-Steam (Lutris/Heroic), appended after a scan
self._diag_game: str | None = None
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -87,12 +103,61 @@ class GamesPage(QWidget):
self._status = QLabel("") self._status = QLabel("")
self._status.setObjectName("Muted") self._status.setObjectName("Muted")
header.addWidget(self._status) header.addWidget(self._status)
self._autocap_btn = QPushButton("Auto-capture…")
self._autocap_btn.clicked.connect(self._show_autocapture)
header.addWidget(self._autocap_btn)
self._rescan_btn = QPushButton("Rescan") self._rescan_btn = QPushButton("Rescan")
self._rescan_btn.setObjectName("PrimaryButton") self._rescan_btn.setObjectName("PrimaryButton")
self._rescan_btn.clicked.connect(self.refresh) self._rescan_btn.clicked.connect(self.refresh)
header.addWidget(self._rescan_btn) header.addWidget(self._rescan_btn)
root.addLayout(header) root.addLayout(header)
# In-progress diagnostic banner (hidden until a focused capture is running).
self._banner = QFrame()
self._banner.setObjectName("Card")
self._banner.setStyleSheet(f"#Card {{ border: 1px solid {ACCENT}; }}")
banner_h = QHBoxLayout(self._banner)
banner_h.setContentsMargins(16, 10, 16, 10)
banner_h.setSpacing(10)
self._banner_label = QLabel("")
self._banner_label.setWordWrap(True)
self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;")
banner_h.addWidget(self._banner_label, 1)
self._finish_btn = QPushButton("Finish && analyze") # && → literal & (not a mnemonic)
self._finish_btn.setObjectName("ActionButton")
self._finish_btn.clicked.connect(self._finish_diagnostic)
banner_h.addWidget(self._finish_btn)
self._discard_btn = QPushButton("Discard")
self._discard_btn.clicked.connect(self._discard_diagnostic)
banner_h.addWidget(self._discard_btn)
self._banner.hide()
root.addWidget(self._banner)
# Hard-crash banner: a previous diagnostic ended without a clean stop.
self._crash_banner = QFrame()
self._crash_banner.setObjectName("Card")
self._crash_banner.setStyleSheet(f"#Card {{ border: 1px solid {WARN}; }}")
crash_h = QHBoxLayout(self._crash_banner)
crash_h.setContentsMargins(16, 10, 16, 10)
crash_h.setSpacing(10)
self._crash_label = QLabel("")
self._crash_label.setWordWrap(True)
self._crash_label.setStyleSheet(f"color: {WARN}; font-weight: 700; background: transparent;")
crash_h.addWidget(self._crash_label, 1)
self._analyze_btn = QPushButton("Analyze crash")
self._analyze_btn.setObjectName("ActionButton")
self._analyze_btn.clicked.connect(self._analyze_crash)
crash_h.addWidget(self._analyze_btn)
self._dismiss_btn = QPushButton("Dismiss")
self._dismiss_btn.clicked.connect(self._dismiss_crash)
crash_h.addWidget(self._dismiss_btn)
self._crash_banner.hide()
root.addWidget(self._crash_banner)
self._diag_timer = QTimer(self)
self._diag_timer.setInterval(1000)
self._diag_timer.timeout.connect(self._poll_diag)
# Libraries (opt-in checkboxes) # Libraries (opt-in checkboxes)
lib_card = QFrame() lib_card = QFrame()
lib_card.setObjectName("Card") lib_card.setObjectName("Card")
@@ -126,6 +191,7 @@ class GamesPage(QWidget):
self._load_cached() # instant display from the last scan self._load_cached() # instant display from the last scan
QTimer.singleShot(400, self.refresh) # then rescan in the background on launch QTimer.singleShot(400, self.refresh) # then rescan in the background on launch
self._check_crash() # surface an interrupted (crashed) diagnostic
# --- loading ---------------------------------------------------------------------- # --- loading ----------------------------------------------------------------------
@@ -148,7 +214,7 @@ class GamesPage(QWidget):
threading.Thread(target=self._work, daemon=True).start() threading.Thread(target=self._work, daemon=True).start()
def _work(self) -> None: def _work(self) -> None:
from ..core import steam from ..core import launchers, steam
try: try:
selected = {os.path.realpath(p) for p in steam.selected_library_paths()} selected = {os.path.realpath(p) for p in steam.selected_library_paths()}
@@ -158,6 +224,10 @@ class GamesPage(QWidget):
for lib in steam.discover_libraries() for lib in steam.discover_libraries()
] ]
self._libraries_ready.emit(libs) self._libraries_ready.emit(libs)
try:
self._extra_games = launchers.scan() # Lutris / Heroic (non-Steam)
except Exception:
self._extra_games = []
self._scanned.emit(steam.rescan()) self._scanned.emit(steam.rescan())
except Exception: except Exception:
self._scanned.emit(None) self._scanned.emit(None)
@@ -200,11 +270,13 @@ class GamesPage(QWidget):
self._status.setText("scan failed") self._status.setText("scan failed")
return return
self._new_appids = set(result.new_appids) self._new_appids = set(result.new_appids)
self._populate_games(result.games, self._new_appids) games = list(result.games) + list(self._extra_games)
self._populate_games(games, self._new_appids)
new = len(self._new_appids) new = len(self._new_appids)
suffix = f" · {new} new" if new else "" suffix = f" · {new} new" if new else ""
non_steam = f" · {len(self._extra_games)} non-Steam" if self._extra_games else ""
self._status.setText( self._status.setText(
f"{len(result.games)} games · {time.strftime('%H:%M:%S')}{suffix}" f"{len(games)} games · {time.strftime('%H:%M:%S')}{suffix}{non_steam}"
) )
self.new_count_changed.emit(new) self.new_count_changed.emit(new)
@@ -228,14 +300,198 @@ class GamesPage(QWidget):
return return
for g in games: for g in games:
launcher = getattr(g, "launcher", "steam")
if launcher != "steam":
sublabel, appid = launcher.title(), "" # non-Steam: can't steam:// launch it
else:
sublabel, appid = (os.path.basename(g.library.rstrip("/")) or g.library), g.appid
self._list.addWidget(_game_row( self._list.addWidget(_game_row(
g.name, g.name,
os.path.basename(g.library.rstrip("/")) or g.library, sublabel,
steam.human_size(g.size_bytes), steam.human_size(g.size_bytes),
g.appid in new_appids, g.appid in new_appids,
appid=appid,
on_diagnose=self._start_diagnostic,
)) ))
self._list.addStretch(1) self._list.addStretch(1)
# --- guided diagnostic (M6/D12) ---------------------------------------------------
def _start_diagnostic(self, name: str, appid: str = "") -> None:
from ..core import diagnostic, steam
if diagnostic.is_running():
QMessageBox.information(
self, "RigDoctor",
"A capture is already running — finish or discard it first.")
return
# Tell the user what the flow actually is, and offer to launch the game for them.
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle(f"Run Diagnostic — {name}")
box.setText(f"Record a focused diagnostic while you play {name}?")
box.setInformativeText(
"RigDoctor will capture sensors in the background. Then:\n\n"
"1. Play the game and try to reproduce the freeze / black screen / crash.\n"
"2. When you're done — or after a hard freeze and reboot — come back here and "
"click “Finish & analyze”.\n\n"
"Your readings are saved continuously, so even a hard lock won't lose them."
)
launch_btn = box.addButton("Launch game && start", QMessageBox.ButtonRole.AcceptRole)
start_btn = box.addButton("Start without launching", QMessageBox.ButtonRole.ActionRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
if not appid:
launch_btn.setEnabled(False) # no appid → can't ask Steam to launch it
box.exec()
clicked = box.clickedButton()
if clicked not in (launch_btn, start_btn):
return
if diagnostic.start(game=name) is None:
QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.")
return
launched = steam.launch_game(appid) if clicked is launch_btn else False
self._diag_game = name
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._banner.show()
self._diag_timer.start()
self._poll_diag()
if clicked is launch_btn and not launched:
QMessageBox.information(
self, "RigDoctor",
"Recording started, but couldn't launch the game automatically — "
"launch it yourself, then click “Finish & analyze” when you're done.")
def _poll_diag(self) -> None:
from ..core import diagnostic
status = diagnostic.active()
if not status:
self._diag_timer.stop() # recorder exited on its own
return
samples = status.get("samples", 0)
lost = " · ⚠ GPU-lost detected" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or "your game"
self._banner_label.setText(
f"● Recording {game} — play it and reproduce the problem, then click "
f"“Finish & analyze”. ({samples} samples{lost})"
)
def _finish_diagnostic(self) -> None:
self._diag_timer.stop()
self._finish_btn.setEnabled(False)
self._discard_btn.setEnabled(False)
self._banner_label.setText("Analyzing… (running the health report)")
threading.Thread(target=self._work_finish, daemon=True).start()
def _work_finish(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.finish()
except Exception:
result = None
self._diag_done.emit(result)
def _on_diag_done(self, result) -> None:
self._banner.hide()
self._crash_banner.hide()
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._analyze_btn.setEnabled(True)
if result is None:
QMessageBox.warning(self, "RigDoctor", "The diagnostic couldn't be analyzed.")
return
DiagnosticDialog(result, self).exec()
def _discard_diagnostic(self) -> None:
from ..core import reccontrol
self._diag_timer.stop()
reccontrol.stop_background()
self._banner.hide()
def _show_autocapture(self) -> None:
from ..core import wrap
option = wrap.launch_option()
dlg = QDialog(self)
dlg.setWindowTitle("Auto-capture in Steam")
dlg.resize(580, 250)
v = QVBoxLayout(dlg)
v.setContentsMargins(20, 18, 20, 16)
v.setSpacing(12)
info = QLabel(
"Capture automatically every time you launch a game — no need to click "
"Run Diagnostic.\n\n"
"1. In Steam, right-click the game → Properties → Launch Options.\n"
"2. Paste the line below.\n\n"
"RigDoctor starts a focused capture when the game launches and stops it on exit. "
"If the game hard-freezes, you'll get a crash report next time you open RigDoctor."
)
info.setWordWrap(True)
v.addWidget(info)
row = QHBoxLayout()
field = QLineEdit(option)
field.setReadOnly(True)
row.addWidget(field, 1)
copy = QPushButton("Copy")
copy.setObjectName("PrimaryButton")
copy.clicked.connect(lambda: QApplication.clipboard().setText(option))
row.addWidget(copy)
v.addLayout(row)
buttons = QHBoxLayout()
buttons.addStretch(1)
close = QPushButton("Close")
close.clicked.connect(dlg.accept)
buttons.addWidget(close)
v.addLayout(buttons)
dlg.exec()
# --- hard-crash recovery ----------------------------------------------------------
def _check_crash(self) -> None:
from ..core import diagnostic
info = diagnostic.pending_crash()
if info is None:
self._crash_banner.hide()
return
game = info.game or "your last game"
extra = " · ⚠ GPU-lost was captured" if info.gpu_lost else ""
self._crash_label.setText(
f"⚠ Your last diagnostic for {game} ended unexpectedly — likely a hard crash "
f"({info.samples} samples{extra}). Analyze it to see the final readings and the "
f"likely cause from the system logs."
)
self._analyze_btn.setEnabled(True)
self._crash_banner.show()
def _analyze_crash(self) -> None:
from ..core import diagnostic
diagnostic.acknowledge_crash() # don't prompt again for this one
self._analyze_btn.setEnabled(False)
self._crash_label.setText("Analyzing the crash (final readings + system logs)…")
threading.Thread(target=self._work_analyze_crash, daemon=True).start()
def _work_analyze_crash(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.analyze_crash()
except Exception:
result = None
self._diag_done.emit(result)
def _dismiss_crash(self) -> None:
from ..core import diagnostic
diagnostic.acknowledge_crash()
self._crash_banner.hide()
# --- nav badge integration -------------------------------------------------------- # --- nav badge integration --------------------------------------------------------
def showEvent(self, event) -> None: # noqa: N802 (Qt override) def showEvent(self, event) -> None: # noqa: N802 (Qt override)
@@ -247,3 +503,15 @@ class GamesPage(QWidget):
threading.Thread(target=steam.acknowledge_new, daemon=True).start() threading.Thread(target=steam.acknowledge_new, daemon=True).start()
self.new_count_changed.emit(0) self.new_count_changed.emit(0)
# Reflect a capture that's still running (e.g. started earlier, navigated back).
from ..core import diagnostic
if diagnostic.is_running():
status = diagnostic.active() or {}
self._diag_game = status.get("game") or self._diag_game
self._banner.show()
if not self._diag_timer.isActive():
self._diag_timer.start()
else:
self._check_crash() # re-surface an interrupted diagnostic if one is pending
+1 -1
View File
@@ -32,7 +32,7 @@ class HealthPage(QWidget):
root.setSpacing(16) root.setSpacing(16)
header = QHBoxLayout() header = QHBoxLayout()
title = QLabel("Health") title = QLabel("System Health")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
+150
View File
@@ -0,0 +1,150 @@
"""Inventory page (M5 in the GUI): system inventory with copy/save + admin re-collect."""
from __future__ import annotations
import os
import threading
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..core import inventory
def _section_card(section) -> QFrame:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(6)
title = QLabel(section.title)
title.setStyleSheet("font-weight: 700; background: transparent;")
layout.addWidget(title)
grid = QGridLayout()
grid.setColumnStretch(1, 1)
grid.setHorizontalSpacing(14)
grid.setVerticalSpacing(4)
for row, (key, value) in enumerate(section.items):
k = QLabel(key)
k.setObjectName("Muted")
v = QLabel(value)
v.setWordWrap(True)
v.setStyleSheet("background: transparent;")
grid.addWidget(k, row, 0)
grid.addWidget(v, row, 1)
layout.addLayout(grid)
return card
class InventoryPage(QWidget):
_result = Signal(object) # list[Section]
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._sections: list = []
self._result.connect(self._render)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
header = QHBoxLayout()
title = QLabel("Inventory")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
self._status = QLabel("")
self._status.setObjectName("Muted")
header.addWidget(self._status)
self._copy_btn = QPushButton("Copy Markdown")
self._copy_btn.clicked.connect(self._copy)
header.addWidget(self._copy_btn)
self._save_btn = QPushButton("Save…")
self._save_btn.clicked.connect(self._save)
header.addWidget(self._save_btn)
self._refresh_btn = QPushButton("Refresh")
self._refresh_btn.setObjectName("PrimaryButton")
self._refresh_btn.clicked.connect(self._run)
header.addWidget(self._refresh_btn)
root.addLayout(header)
self._scroll = scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
self._container = QWidget()
self._list = QVBoxLayout(self._container)
self._list.setContentsMargins(0, 0, 0, 0)
self._list.setSpacing(12)
self._list.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(self._container)
root.addWidget(scroll, 1)
QTimer.singleShot(300, self._run)
def _run(self) -> None:
self._busy("Collecting…")
threading.Thread(target=self._work, daemon=True).start()
def _work(self) -> None:
try:
sections = inventory.collect()
except Exception:
sections = []
self._result.emit(sections)
def _busy(self, text: str) -> None:
self._status.setText(text)
for b in (self._refresh_btn, self._copy_btn, self._save_btn):
b.setEnabled(False)
def _render(self, sections) -> None:
self._refresh_btn.setEnabled(True)
self._copy_btn.setEnabled(True)
self._save_btn.setEnabled(True)
if sections is None: # collection failed — keep current
self._status.setText("collection failed")
return
if sections == self._sections: # unchanged — don't rebuild (would jump scroll)
self._status.setText("")
return
scroll_pos = self._scroll.verticalScrollBar().value()
self._sections = sections
while self._list.count():
item = self._list.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
for section in sections:
self._list.addWidget(_section_card(section))
self._list.addStretch(1)
self._status.setText("")
# restore scroll after the layout settles so re-renders don't yank to the top
QTimer.singleShot(0, lambda: self._scroll.verticalScrollBar().setValue(scroll_pos))
def _copy(self) -> None:
if self._sections:
QApplication.clipboard().setText(inventory.render_markdown(self._sections))
self._status.setText("copied as Markdown")
def _save(self) -> None:
if not self._sections:
return
path, _ = QFileDialog.getSaveFileName(self, "Save inventory", "rigdoctor-inventory.md", "Markdown (*.md)")
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(inventory.render_markdown(self._sections))
self._status.setText(f"saved {os.path.basename(path)}")
+147 -20
View File
@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import html
import os import os
import sys import sys
import threading import threading
from pathlib import Path
from PySide6.QtCore import Qt, QProcess, QTimer, Signal from PySide6.QtCore import Qt, QProcess, QTimer, Signal
from PySide6.QtGui import QTextDocument from PySide6.QtGui import QIcon, QTextDocument
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
@@ -19,6 +21,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QStackedWidget, QStackedWidget,
QSystemTrayIcon,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -31,14 +34,24 @@ from .dashboard import Dashboard
from .environment_page import EnvironmentPage from .environment_page import EnvironmentPage
from .games_page import GamesPage from .games_page import GamesPage
from .health_page import HealthPage from .health_page import HealthPage
from .notifications_page import NotificationsPage from .inventory_page import InventoryPage
from .recorder_page import RecorderPage from .recorder_page import RecorderPage
from .setup_page import SetupPage from .setup_page import SetupPage
from .share_page import SharePage from .share_page import SharePage
from .theme import ACCENT, GOOD, MUTED from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT
from .tray import TrayIcon
from .worker import SamplerWorker from .worker import SamplerWorker
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"] # Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
# filled in this order, so _PAGES.index(name) is the stack index.
_NAV = [
("Monitor", ["Dashboard"]),
("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
("System", ["Inventory"]),
("App", ["Settings", "Share"]),
]
_PAGES = [name for _section, names in _NAV for name in names]
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -71,18 +84,23 @@ class MainWindow(QMainWindow):
self.games_page = GamesPage() self.games_page = GamesPage()
self.games_page.new_count_changed.connect(self._set_games_badge) self.games_page.new_count_changed.connect(self._set_games_badge)
self.environment_page = EnvironmentPage() self.environment_page = EnvironmentPage()
self.inventory_page = InventoryPage()
self.setup_page = SetupPage() self.setup_page = SetupPage()
self.notifications_page = NotificationsPage() self.setup_page.changed.connect(self._apply_alert_settings)
self.notifications_page.changed.connect(self._apply_alert_settings)
self.share_page = SharePage() self.share_page = SharePage()
self._stack.addWidget(self.dashboard) # 0 Dashboard # Page name → widget; the stack is filled in _PAGES order so indices line up.
self._stack.addWidget(self.recorder_page) # 1 Logs self._pages = {
self._stack.addWidget(self.health_page) # 2 Health "Dashboard": self.dashboard,
self._stack.addWidget(self.games_page) # 3 Games "Games": self.games_page,
self._stack.addWidget(self.environment_page) # 4 Environment "Recordings": self.recorder_page,
self._stack.addWidget(self.setup_page) # 5 Setup "System Health": self.health_page,
self._stack.addWidget(self.notifications_page) # 6 Notifications "Tuning": self.environment_page,
self._stack.addWidget(self.share_page) # 7 Share "Inventory": self.inventory_page,
"Settings": self.setup_page,
"Share": self.share_page,
}
for name in _PAGES:
self._stack.addWidget(self._pages[name])
content_layout.addWidget(self._stack) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
@@ -124,6 +142,30 @@ class MainWindow(QMainWindow):
self._update_timer.timeout.connect(self._start_update_check) self._update_timer.timeout.connect(self._start_update_check)
self._update_timer.start() self._update_timer.start()
# Reflect any capture (manual, diagnostic, or the Steam wrapper) in the sidebar on
# every page, so it's always clear when RigDoctor is recording and for which game.
self._rec_timer = QTimer(self)
self._rec_timer.setInterval(1500)
self._rec_timer.timeout.connect(self._update_recording)
self._rec_timer.start()
self._update_recording()
# System-tray applet (M11) — optional; only when the desktop offers a tray. When
# present, closing the window hides to the tray instead of quitting.
self._tray = None
self._quitting = False
self._tray_hint_shown = False
if QSystemTrayIcon.isSystemTrayAvailable():
icon = self.windowIcon() if not self.windowIcon().isNull() else QIcon(str(_ICON))
self._tray = TrayIcon(
self, icon,
gpu_alert=float(cfg.get("gpu_temp_alert", 90.0)),
cpu_alert=float(cfg.get("cpu_temp_alert", 95.0)),
)
self._worker.sampled.connect(self._tray.update_sample)
self._tray.show()
QApplication.instance().setQuitOnLastWindowClosed(False)
def _build_sidebar(self) -> QFrame: def _build_sidebar(self) -> QFrame:
bar = QFrame() bar = QFrame()
bar.setObjectName("Sidebar") bar.setObjectName("Sidebar")
@@ -138,19 +180,36 @@ class MainWindow(QMainWindow):
subtitle.setObjectName("AppSubtitle") subtitle.setObjectName("AppSubtitle")
v.addWidget(title) v.addWidget(title)
v.addWidget(subtitle) v.addWidget(subtitle)
# Global recording indicator — visible on every page while a capture runs.
self._rec_indicator = QLabel()
self._rec_indicator.setWordWrap(True)
self._rec_indicator.setTextFormat(Qt.TextFormat.RichText)
self._rec_indicator.setStyleSheet(
f"background: #241316; border: 1px solid {CRIT}; border-radius: 8px; padding: 8px 10px;"
)
self._rec_indicator.hide()
v.addSpacing(12)
v.addWidget(self._rec_indicator)
v.addSpacing(18) v.addSpacing(18)
group = QButtonGroup(self) group = QButtonGroup(self)
group.setExclusive(True) group.setExclusive(True)
self._nav_buttons: dict[str, QPushButton] = {} self._nav_buttons: dict[str, QPushButton] = {}
for i, name in enumerate(_NAV_ITEMS): for section, names in _NAV:
header = QLabel(section.upper())
header.setObjectName("NavSection")
v.addSpacing(8)
v.addWidget(header)
for name in names:
idx = _PAGES.index(name)
btn = QPushButton(name) btn = QPushButton(name)
btn.setObjectName("NavButton") btn.setObjectName("NavButton")
btn.setCheckable(True) btn.setCheckable(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.setChecked(i == 0) btn.setChecked(idx == 0)
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx)) btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
group.addButton(btn, i) group.addButton(btn, idx)
v.addWidget(btn) v.addWidget(btn)
self._nav_buttons[name] = btn self._nav_buttons[name] = btn
@@ -234,9 +293,64 @@ class MainWindow(QMainWindow):
self._elevated.emit() self._elevated.emit()
def _on_elevated(self) -> None: def _on_elevated(self) -> None:
# Re-run Health now that root-only SMART data is available. (dmidecode is still # Re-run Health + Inventory now that root-only data is available (SMART for Health,
# collected and used by the relay guest view + the CLI `rigdoctor inventory`.) # dmidecode motherboard/BIOS/RAM for Inventory).
self.health_page._run() self.health_page._run()
self.inventory_page._run()
# --- tray-driven actions (M11) ----------------------------------------------------
def show_page(self, name: str) -> None:
"""Bring the window forward on a given page (used by the tray)."""
if name in self._nav_buttons:
self._stack.setCurrentIndex(_PAGES.index(name))
self._nav_buttons[name].setChecked(True)
self.showNormal()
self.raise_()
self.activateWindow()
def show_dashboard(self) -> None:
self.show_page("Dashboard")
def tray_available(self) -> bool:
return self._tray is not None
def start_minimized_note(self) -> None:
"""Started hidden to the tray (autostart) — let the user know it's there."""
if self._tray is not None:
self._tray_hint_shown = True
self._tray.showMessage(
"RigDoctor", "Running in the tray — right-click the icon for actions.",
QSystemTrayIcon.MessageIcon.Information, 4000,
)
def run_diagnostic(self, name: str, appid: str) -> None:
self.show_page("Games")
self.games_page._start_diagnostic(name, appid)
def quit_app(self) -> None:
self._quitting = True
self._worker.stop()
self.share_page.shutdown()
if self._tray is not None:
self._tray.hide()
QApplication.instance().quit()
def _update_recording(self) -> None:
from ..core import diagnostic
status = diagnostic.active()
if not status:
self._rec_indicator.hide()
return
game = status.get("game")
lines = [f"<span style='color:{CRIT};'>●</span> <b style='color:{TEXT};'>Recording</b>"]
if game:
lines.append(f"<span style='color:{TEXT};'>{html.escape(str(game))}</span>")
if status.get("gpu_lost"):
lines.append(f"<span style='color:{CRIT};'>⚠ GPU-lost</span>")
self._rec_indicator.setText("<br>".join(lines))
self._rec_indicator.show()
def _set_games_badge(self, count: int) -> None: def _set_games_badge(self, count: int) -> None:
btn = self._nav_buttons.get("Games") btn = self._nav_buttons.get("Games")
@@ -319,6 +433,19 @@ class MainWindow(QMainWindow):
self._update_label.setText("up-to-date") self._update_label.setText("up-to-date")
def closeEvent(self, event) -> None: # noqa: N802 (Qt override) def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
# With a tray, closing the window hides it (the app keeps running for the tray
# readouts + any capture); Quit from the tray menu exits for real.
if self._tray is not None and not self._quitting:
event.ignore()
self.hide()
if not self._tray_hint_shown:
self._tray_hint_shown = True
self._tray.showMessage(
"RigDoctor",
"Still running in the tray — right-click the icon for actions or Quit.",
QSystemTrayIcon.MessageIcon.Information, 5000,
)
return
self._worker.stop() self._worker.stop()
self.share_page.shutdown() self.share_page.shutdown()
super().closeEvent(event) super().closeEvent(event)
-108
View File
@@ -1,108 +0,0 @@
"""Notifications page (M8 config): user-configurable alert settings."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..config import load_config, update_config
from ..core import alerts
class NotificationsPage(QWidget):
changed = Signal() # settings saved — main window re-applies them live
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
title = QLabel("Notifications")
title.setObjectName("PageTitle")
root.addWidget(title)
card = QFrame()
card.setObjectName("Card")
v = QVBoxLayout(card)
v.setContentsMargins(16, 14, 16, 14)
v.setSpacing(10)
head = QLabel("Alerts")
head.setStyleSheet("font-weight: 700; background: transparent;")
v.addWidget(head)
self._enabled = QCheckBox("Enable desktop notifications")
v.addWidget(self._enabled)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setColumnStretch(2, 1)
self._gpu = self._spin()
self._cpu = self._spin()
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
grid.addWidget(self._gpu, 0, 1)
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
grid.addWidget(self._cpu, 1, 1)
v.addLayout(grid)
note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
buttons = QHBoxLayout()
save = QPushButton("Save")
save.setObjectName("PrimaryButton")
save.clicked.connect(self._save)
test = QPushButton("Send test")
test.clicked.connect(self._test)
buttons.addWidget(save)
buttons.addWidget(test)
buttons.addStretch(1)
v.addLayout(buttons)
self._status = QLabel("")
self._status.setObjectName("Muted")
v.addWidget(self._status)
root.addWidget(card)
root.addStretch(1)
self._load()
@staticmethod
def _spin() -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(40, 110)
spin.setDecimals(0)
spin.setSingleStep(1)
spin.setSuffix(" °C")
return spin
def _load(self) -> None:
cfg = load_config()
self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
def _save(self) -> None:
update_config(
alerts_enabled=self._enabled.isChecked(),
gpu_temp_alert=self._gpu.value(),
cpu_temp_alert=self._cpu.value(),
)
self.changed.emit()
self._status.setText("Saved.")
def _test(self) -> None:
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
+73 -28
View File
@@ -1,16 +1,19 @@
"""Recording & Logs page (M3 in the GUI): start/stop/status + post-crash report. """Recordings page (M3 in the GUI): recorder controls + view/report any captured log.
Drives the same background recorder as the CLI via core.reccontrol, so the GUI and Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
`rigdoctor record …` are interchangeable. captured data — the always-on log, the last guided diagnostic, and a preserved hard-crash
(which can be analyzed in place). One place to see what was captured and what it means.
""" """
from __future__ import annotations from __future__ import annotations
import threading
import time import time
from PySide6.QtCore import Qt, QTimer, QUrl from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QFont from PySide6.QtGui import QDesktopServices, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -25,6 +28,7 @@ from .. import config
from ..core import reccontrol from ..core import reccontrol
from ..core.crashlog import summarize from ..core.crashlog import summarize
from ..render import format_headline, render_summary from ..render import format_headline, render_summary
from .diagnostic_dialog import DiagnosticDialog
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -45,31 +49,30 @@ def _fmt_time(value, fmt="%Y-%m-%d %H:%M:%S") -> str:
class RecorderPage(QWidget): class RecorderPage(QWidget):
_analyzed = Signal(object) # DiagnosticResult from a crash analysis
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._analyzed.connect(self._show_analysis)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16) root.setSpacing(16)
title = QLabel("Recording") title = QLabel("Recordings")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
root.addWidget(title) root.addWidget(title)
# --- Status + controls ------------------------------------------------- # --- Status + controls -------------------------------------------------
status_card, status_layout = _panel("Status") status_card, status_layout = _panel("Status")
self._state = QLabel("○ Not recording") self._state = QLabel("○ Not recording")
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;") self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
status_layout.addWidget(self._state) status_layout.addWidget(self._state)
self._info = QLabel("") self._info = QLabel("")
self._info.setObjectName("Muted") self._info.setObjectName("Muted")
status_layout.addWidget(self._info) status_layout.addWidget(self._info)
self._latest = QLabel("") self._latest = QLabel("")
status_layout.addWidget(self._latest) status_layout.addWidget(self._latest)
self._warn = QLabel("") self._warn = QLabel("")
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;") self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
self._warn.setVisible(False) self._warn.setVisible(False)
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
status_layout.addLayout(controls) status_layout.addLayout(controls)
root.addWidget(status_card) root.addWidget(status_card)
# --- Report ------------------------------------------------------------ # --- Captured logs -----------------------------------------------------
report_card = QFrame() report_card, report_layout = _panel("Captured logs")
report_card.setObjectName("Card")
report_layout = QVBoxLayout(report_card)
report_layout.setContentsMargins(16, 14, 16, 14)
report_layout.setSpacing(10)
header = QHBoxLayout() header = QHBoxLayout()
report_title = QLabel("Post-crash report") header.addWidget(QLabel("Show:"))
report_title.setStyleSheet("font-weight: 700; background: transparent;") self._source = QComboBox()
header.addWidget(report_title) self._source.currentIndexChanged.connect(self._load_report)
header.addStretch(1) header.addWidget(self._source, 1)
self._analyze_btn = QPushButton("Analyze crash")
self._analyze_btn.setObjectName("ActionButton")
self._analyze_btn.clicked.connect(self._analyze_crash)
self._analyze_btn.setVisible(False)
header.addWidget(self._analyze_btn)
refresh_btn = QPushButton("Refresh") refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self._load_report) refresh_btn.clicked.connect(self._refresh_sources)
header.addWidget(refresh_btn) header.addWidget(refresh_btn)
report_layout.addLayout(header) report_layout.addLayout(header)
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
report_layout.addWidget(self._report) report_layout.addWidget(self._report)
root.addWidget(report_card, 1) root.addWidget(report_card, 1)
# Poll recorder status once a second (reflects CLI-driven sessions too).
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setInterval(1000) self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_status) self._timer.timeout.connect(self._refresh_status)
self._timer.start() self._timer.start()
self._refresh_status() self._refresh_status()
self._load_report() self._refresh_sources()
# --- actions --------------------------------------------------------------- # --- actions ---------------------------------------------------------------
def _on_start(self) -> None: def _on_start(self) -> None:
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
self._stop_btn.setEnabled(False) self._stop_btn.setEnabled(False)
reccontrol.stop_background() reccontrol.stop_background()
QTimer.singleShot(600, self._refresh_status) QTimer.singleShot(600, self._refresh_status)
QTimer.singleShot(900, self._load_report) QTimer.singleShot(900, self._refresh_sources)
def _open_folder(self) -> None: def _open_folder(self) -> None:
config.LOG_DIR.mkdir(parents=True, exist_ok=True) config.LOG_DIR.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
# --- captured logs ---------------------------------------------------------
def _refresh_sources(self) -> None:
from ..core import diagnostic
current = self._source.currentData()
self._source.blockSignals(True)
self._source.clear()
self._source.addItem("Always-on capture", str(config.LOG_FILE))
if config.DIAG_LOG.exists():
self._source.addItem("Last diagnostic", str(config.DIAG_LOG))
if config.DIAG_CRASH.exists():
self._source.addItem("Crash (unanalyzed)", str(config.DIAG_CRASH))
# keep the previous selection if it's still present
idx = self._source.findData(current) if current else -1
self._source.setCurrentIndex(idx if idx >= 0 else 0)
self._source.blockSignals(False)
self._analyze_btn.setVisible(diagnostic.pending_crash() is not None)
self._load_report()
def _load_report(self) -> None:
path = self._source.currentData() or str(config.LOG_FILE)
summary = summarize(path, last_n=10)
self._report.setPlainText(render_summary(summary, log_path=path))
def _analyze_crash(self) -> None:
self._analyze_btn.setEnabled(False)
self._report.setPlainText("Analyzing the crash (final readings + system logs)…")
threading.Thread(target=self._work_analyze, daemon=True).start()
def _work_analyze(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.analyze_crash()
except Exception:
result = None
self._analyzed.emit(result)
def _show_analysis(self, result) -> None:
self._analyze_btn.setEnabled(True)
if result is not None:
DiagnosticDialog(result, self).exec()
self._refresh_sources()
# --- refresh --------------------------------------------------------------- # --- refresh ---------------------------------------------------------------
def _refresh_status(self) -> None: def _refresh_status(self) -> None:
pid = reccontrol.running_pid() pid = reccontrol.running_pid()
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
self._interval.setEnabled(not running) self._interval.setEnabled(not running)
if status: if status:
game = status.get("game")
game_line = f"Game: {game} " if game else ""
self._info.setText( self._info.setText(
f"Samples: {status.get('samples', 0)} " f"{game_line}Samples: {status.get('samples', 0)} "
f"Started: {_fmt_time(status.get('started'))} " f"Started: {_fmt_time(status.get('started'))} "
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n" f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
f"Log: {status.get('log', config.LOG_FILE)}" f"Log: {status.get('log', config.LOG_FILE)}"
@@ -179,7 +228,3 @@ class RecorderPage(QWidget):
self._info.setText("No recording yet. Press “Start recording”.") self._info.setText("No recording yet. Press “Start recording”.")
self._latest.setText("") self._latest.setText("")
self._warn.setVisible(False) self._warn.setVisible(False)
def _load_report(self) -> None:
summary = summarize(config.LOG_FILE, last_n=10)
self._report.setPlainText(render_summary(summary, log_path=config.LOG_FILE))
+123 -5
View File
@@ -1,4 +1,4 @@
"""Setup page (M9 in the GUI): show environment + optional components, install missing.""" """Settings page: components/deps, alerts (M8), account access (token), and uninstall."""
from __future__ import annotations from __future__ import annotations
@@ -8,7 +8,11 @@ from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame, QFrame,
QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -21,7 +25,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import installer, sysenv, uninstall, updates from ..core import alerts, installer, service, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -49,18 +53,21 @@ _BACKEND_DESC = {
class SetupPage(QWidget): class SetupPage(QWidget):
_installed = Signal(int, str) _installed = Signal(int, str)
_upd_state = Signal(object) _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: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._installed.connect(self._on_installed) self._installed.connect(self._on_installed)
self._upd_state.connect(self._on_upd_state) self._upd_state.connect(self._on_upd_state)
self._mode_applied.connect(self._on_mode_applied)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16) root.setSpacing(16)
title = QLabel("Setup") title = QLabel("Settings")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
root.addWidget(title) root.addWidget(title)
@@ -70,7 +77,7 @@ class SetupPage(QWidget):
env_layout.addWidget(self._env) env_layout.addWidget(self._env)
root.addWidget(env_card) root.addWidget(env_card)
comp_card, comp_layout = _panel("Optional components") comp_card, comp_layout = _panel("Components & dependencies")
self._components = QVBoxLayout() self._components = QVBoxLayout()
self._components.setSpacing(6) self._components.setSpacing(6)
comp_layout.addLayout(self._components) comp_layout.addLayout(self._components)
@@ -86,6 +93,68 @@ class SetupPage(QWidget):
comp_layout.addLayout(controls) comp_layout.addLayout(controls)
root.addWidget(comp_card) root.addWidget(comp_card)
# Alerts (M8) — folded in from the old Notifications page.
alerts_card, alerts_layout = _panel("Notifications")
self._alerts_enabled = QCheckBox("Enable desktop notifications")
alerts_layout.addWidget(self._alerts_enabled)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setColumnStretch(2, 1)
self._gpu_alert = self._spin()
self._cpu_alert = self._spin()
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
grid.addWidget(self._gpu_alert, 0, 1)
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
grid.addWidget(self._cpu_alert, 1, 1)
alerts_layout.addLayout(grid)
alerts_note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
alerts_note.setObjectName("Muted")
alerts_note.setWordWrap(True)
alerts_layout.addWidget(alerts_note)
alerts_buttons = QHBoxLayout()
save_alerts = QPushButton("Save")
save_alerts.setObjectName("PrimaryButton")
save_alerts.clicked.connect(self._save_alerts)
test_alerts = QPushButton("Send test")
test_alerts.clicked.connect(self._test_alerts)
alerts_buttons.addWidget(save_alerts)
alerts_buttons.addWidget(test_alerts)
alerts_buttons.addStretch(1)
self._alerts_status = QLabel("")
self._alerts_status.setObjectName("Muted")
alerts_buttons.addWidget(self._alerts_status)
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. # Account access (M13/M12): one Gitea token gates updates and session sharing.
upd_card, upd_layout = _panel("Account access") upd_card, upd_layout = _panel("Account access")
hint = QLabel("A Gitea access token unlocks updates and session sharing. " hint = QLabel("A Gitea access token unlocks updates and session sharing. "
@@ -115,7 +184,7 @@ class SetupPage(QWidget):
self._output = QTextEdit() self._output = QTextEdit()
self._output.setObjectName("Report") self._output.setObjectName("Report")
self._output.setReadOnly(True) self._output.setReadOnly(True)
self._output.setMinimumHeight(180) self._output.setMinimumHeight(160)
self._output.setVisible(False) self._output.setVisible(False)
root.addWidget(self._output) root.addWidget(self._output)
root.addStretch(1) root.addStretch(1)
@@ -129,8 +198,57 @@ class SetupPage(QWidget):
root.addLayout(danger) root.addLayout(danger)
self._refresh() self._refresh()
self._load_alerts()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._refresh_update_status() 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:
spin = QDoubleSpinBox()
spin.setRange(40, 110)
spin.setDecimals(0)
spin.setSingleStep(1)
spin.setSuffix(" °C")
return spin
def _load_alerts(self) -> None:
cfg = config.load_config()
self._alerts_enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
self._gpu_alert.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
self._cpu_alert.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
def _save_alerts(self) -> None:
config.update_config(
alerts_enabled=self._alerts_enabled.isChecked(),
gpu_temp_alert=self._gpu_alert.value(),
cpu_temp_alert=self._cpu_alert.value(),
)
self.changed.emit()
self._alerts_status.setText("Saved.")
def _test_alerts(self) -> None:
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
self._alerts_status.setText(
"Test sent." if ok else "notify-send not found — install libnotify-bin above.")
def _uninstall(self) -> None: def _uninstall(self) -> None:
box = QMessageBox(self) box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning) box.setIcon(QMessageBox.Icon.Warning)
+96 -96
View File
@@ -1,9 +1,10 @@
"""Share page (M12): host or join a shared session over the relay. """Share page (M12): a shared **terminal** session over the relay.
Guest sees the host's live sensors + health + inventory (read-only). If the host enables it, The host shares a real PTY running their shell; the guest watches it live and — only if the
a full **PTY terminal** is shared: the guest types and the commands run on the host (as the host ticks "Allow the guest to type" — can run commands (as the host's user). The host reads
host's user), the host reads along, and the host can type too e.g. a sudo password, which along and can type too, e.g. a sudo password, which stays local and is never sent to the guest.
stays local and is never sent to the guest. This is the only share mode (the old read-only stats view was removed). Either terminal can be
popped full-screen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,7 +12,8 @@ from __future__ import annotations
import base64 import base64
import json import json
from PySide6.QtCore import Qt, QSocketNotifier, QTimer, QUrl from PySide6.QtCore import Qt, QSocketNotifier, QUrl
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWebSockets import QWebSocket from PySide6.QtWebSockets import QWebSocket
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
@@ -20,16 +22,12 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..config import load_config, load_token from ..config import load_config, load_token
from ..core import share
from ..core.pty_session import PtySession from ..core.pty_session import PtySession
from ..core.sampler import Sampler
from ..core.sources import available_sources
from .terminal_widget import TerminalView from .terminal_widget import TerminalView
@@ -57,16 +55,13 @@ class SharePage(QWidget):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._sampler = Sampler(available_sources())
self._host_ws: QWebSocket | None = None self._host_ws: QWebSocket | None = None
self._guest_ws: QWebSocket | None = None self._guest_ws: QWebSocket | None = None
self._pty: PtySession | None = None self._pty: PtySession | None = None
self._pty_notifier: QSocketNotifier | None = None self._pty_notifier: QSocketNotifier | None = None
self._last_report = None self._guest_can_type = False
self._last_inv = None self._fs: QWidget | None = None
self._timer = QTimer(self) self._fs_state = None
self._timer.setInterval(2000)
self._timer.timeout.connect(self._stream)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -74,19 +69,19 @@ class SharePage(QWidget):
title = QLabel("Share") title = QLabel("Share")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
root.addWidget(title) root.addWidget(title)
root.addWidget(self._build_host()) root.addWidget(self._build_host(), 1)
root.addWidget(self._build_guest(), 1) root.addWidget(self._build_guest(), 1)
# ------------------------------------------------------------------ host # ------------------------------------------------------------------ host
def _build_host(self) -> QFrame: def _build_host(self) -> QFrame:
card, v = _card("Start a shared session") card, v = _card("Host a terminal session")
self._host_status = QLabel("Let someone with an account view your machine, read-only.") self._host_status = QLabel("Share a live terminal with someone who has an account.")
self._host_status.setObjectName("Muted") self._host_status.setObjectName("Muted")
self._host_status.setWordWrap(True) self._host_status.setWordWrap(True)
v.addWidget(self._host_status) v.addWidget(self._host_status)
row = QHBoxLayout() row = QHBoxLayout()
self._start_btn = QPushButton("Start shared session") self._start_btn = QPushButton("Start session")
self._start_btn.setObjectName("PrimaryButton") self._start_btn.setObjectName("PrimaryButton")
self._start_btn.clicked.connect(self._start_host) self._start_btn.clicked.connect(self._start_host)
self._stop_btn = QPushButton("Stop") self._stop_btn = QPushButton("Stop")
@@ -95,28 +90,33 @@ class SharePage(QWidget):
self._code_label = QLabel("") self._code_label = QLabel("")
self._code_label.setStyleSheet("font-weight:700; font-size:18px; color:#38bdf8; background:transparent;") self._code_label.setStyleSheet("font-weight:700; font-size:18px; color:#38bdf8; background:transparent;")
self._code_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._code_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._host_fs_btn = QPushButton("Full screen")
self._host_fs_btn.setEnabled(False)
self._host_fs_btn.clicked.connect(lambda: self._enter_fullscreen(self._host_term))
row.addWidget(self._start_btn) row.addWidget(self._start_btn)
row.addWidget(self._stop_btn) row.addWidget(self._stop_btn)
row.addSpacing(12) row.addSpacing(12)
row.addWidget(self._code_label) row.addWidget(self._code_label)
row.addStretch(1) row.addStretch(1)
row.addWidget(self._host_fs_btn)
v.addLayout(row) v.addLayout(row)
self._allow_term = QCheckBox("Allow remote terminal — the guest runs commands as your user (you read along; you can type too, e.g. a sudo password)") self._allow_input = QCheckBox(
self._allow_term.setStyleSheet("color:#fb923c; background:transparent;") "Allow the guest to type — they run commands as your user (off = they only watch)")
self._allow_term.toggled.connect(self._toggle_terminal) self._allow_input.setStyleSheet("color:#fb923c; background:transparent;")
v.addWidget(self._allow_term) self._allow_input.toggled.connect(self._send_terminal_state)
v.addWidget(self._allow_input)
self._host_term = TerminalView() self._host_term = TerminalView()
self._host_term.keys.connect(lambda b: self._pty.write(b) if self._pty else None) self._host_term.keys.connect(lambda b: self._pty.write(b) if self._pty else None)
self._host_term.resized.connect(lambda r, c: self._pty.set_size(r, c) if self._pty else None) self._host_term.resized.connect(lambda r, c: self._pty.set_size(r, c) if self._pty else None)
self._host_term.setVisible(False) self._host_term.setVisible(False)
v.addWidget(self._host_term) v.addWidget(self._host_term, 1)
return card return card
def _start_host(self) -> None: def _start_host(self) -> None:
if not load_token(): if not load_token():
self._host_status.setText("Set a Gitea access token in Setup → Account access first.") self._host_status.setText("Set a Gitea access token in Settings → Account access first.")
return return
self._host_status.setText("Connecting to the relay…") self._host_status.setText("Connecting to the relay…")
self._start_btn.setEnabled(False) self._start_btn.setEnabled(False)
@@ -135,37 +135,25 @@ class SharePage(QWidget):
if data.get("error"): if data.get("error"):
self._host_status.setText(f"Rejected: {data['error']}") self._host_status.setText(f"Rejected: {data['error']}")
return return
if "code" in data: # relay handshake if "code" in data: # relay handshake → start the terminal immediately
self._code_label.setText(data["code"]) self._code_label.setText(data["code"])
self._host_status.setText(f"Sharing as {data.get('user', '?')} — give this code to whoever should view your machine.") self._host_status.setText(
f"Sharing as {data.get('user', '?')} — give this code to whoever should connect.")
self._stop_btn.setEnabled(True) self._stop_btn.setEnabled(True)
self._host_ws.sendTextMessage(share.host_full_frame(self._sampler))
self._send_terminal_state()
if self._allow_term.isChecked():
self._start_pty() self._start_pty()
self._timer.start() self._send_terminal_state()
return return
kind = data.get("type") # frames forwarded from a guest kind = data.get("type")
if kind == "req_full": if kind == "req_full": # a guest joined — tell them their typing permission
# A guest just joined — send a full frame AND the current terminal state, so a
# guest that joins *after* the host enabled the terminal still gets access.
self._host_ws.sendTextMessage(share.host_full_frame(self._sampler))
self._send_terminal_state() self._send_terminal_state()
elif kind == "pty_in" and self._pty: elif kind == "pty_in" and self._pty and self._allow_input.isChecked():
self._pty.write(base64.b64decode(data["data"])) self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty: elif kind == "pty_resize" and self._pty and self._allow_input.isChecked():
self._pty.set_size(int(data["rows"]), int(data["cols"])) self._pty.set_size(int(data["rows"]), int(data["cols"]))
def _toggle_terminal(self, on: bool) -> None:
if on and self._host_ws and self._code_label.text():
self._start_pty()
elif not on:
self._stop_pty()
self._send_terminal_state()
def _send_terminal_state(self) -> None: def _send_terminal_state(self) -> None:
if self._host_ws and self._code_label.text(): if self._host_ws and self._code_label.text():
self._host_ws.sendTextMessage(json.dumps({"type": "terminal", "enabled": self._allow_term.isChecked()})) self._host_ws.sendTextMessage(json.dumps({"type": "terminal", "enabled": self._allow_input.isChecked()}))
def _start_pty(self) -> None: def _start_pty(self) -> None:
if self._pty: if self._pty:
@@ -176,15 +164,15 @@ class SharePage(QWidget):
self._pty_notifier.activated.connect(self._on_pty_output) self._pty_notifier.activated.connect(self._on_pty_output)
self._host_term.reset() self._host_term.reset()
self._host_term.setVisible(True) self._host_term.setVisible(True)
self._host_fs_btn.setEnabled(True)
self._host_term.setFocus()
def _on_pty_output(self) -> None: def _on_pty_output(self) -> None:
if not self._pty: if not self._pty:
return return
data = self._pty.read() data = self._pty.read()
if not data: # shell exited / EOF if not data: # shell exited
self._stop_pty() self._stop_host()
self._send_terminal_state()
self._allow_term.setChecked(False)
return return
self._host_term.feed(data) self._host_term.feed(data)
if self._host_ws: if self._host_ws:
@@ -198,13 +186,9 @@ class SharePage(QWidget):
self._pty.close() self._pty.close()
self._pty = None self._pty = None
self._host_term.setVisible(False) self._host_term.setVisible(False)
self._host_fs_btn.setEnabled(False)
def _stream(self) -> None:
if self._host_ws:
self._host_ws.sendTextMessage(share.host_snapshot_frame(self._sampler))
def _stop_host(self) -> None: def _stop_host(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
if self._host_ws: if self._host_ws:
self._host_ws.close() self._host_ws.close()
@@ -215,7 +199,6 @@ class SharePage(QWidget):
self._host_status.setText("Stopped sharing.") self._host_status.setText("Stopped sharing.")
def _host_closed(self) -> None: def _host_closed(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
self._start_btn.setEnabled(True) self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False) self._stop_btn.setEnabled(False)
@@ -225,7 +208,7 @@ class SharePage(QWidget):
# ----------------------------------------------------------------- guest # ----------------------------------------------------------------- guest
def _build_guest(self) -> QFrame: def _build_guest(self) -> QFrame:
card, v = _card("Join a shared session") card, v = _card("Join a terminal session")
row = QHBoxLayout() row = QHBoxLayout()
self._code_input = QLineEdit() self._code_input = QLineEdit()
self._code_input.setPlaceholderText("Enter share code") self._code_input.setPlaceholderText("Enter share code")
@@ -237,37 +220,31 @@ class SharePage(QWidget):
self._leave_btn = QPushButton("Leave") self._leave_btn = QPushButton("Leave")
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
self._leave_btn.clicked.connect(self._leave) self._leave_btn.clicked.connect(self._leave)
self._guest_fs_btn = QPushButton("Full screen")
self._guest_fs_btn.setEnabled(False)
self._guest_fs_btn.clicked.connect(lambda: self._enter_fullscreen(self._guest_term))
row.addWidget(self._code_input) row.addWidget(self._code_input)
row.addWidget(self._join_btn) row.addWidget(self._join_btn)
row.addWidget(self._leave_btn) row.addWidget(self._leave_btn)
row.addStretch(1) row.addStretch(1)
row.addWidget(self._guest_fs_btn)
v.addLayout(row) v.addLayout(row)
self._guest_status = QLabel("") self._guest_status = QLabel("")
self._guest_status.setObjectName("Muted") self._guest_status.setObjectName("Muted")
self._guest_status.setWordWrap(True)
v.addWidget(self._guest_status) v.addWidget(self._guest_status)
self._view = QTextEdit()
self._view.setObjectName("Report")
self._view.setReadOnly(True)
self._view.setVisible(False)
self._view.setMinimumHeight(200)
v.addWidget(self._view)
self._term_label = QLabel("")
self._term_label.setObjectName("Muted")
self._term_label.setVisible(False)
v.addWidget(self._term_label)
self._guest_term = TerminalView() self._guest_term = TerminalView()
self._guest_term.keys.connect(self._guest_key) self._guest_term.keys.connect(self._guest_key)
self._guest_term.resized.connect(self._guest_resize) self._guest_term.resized.connect(self._guest_resize)
self._guest_term.setVisible(False) self._guest_term.setVisible(False)
v.addWidget(self._guest_term) v.addWidget(self._guest_term, 1)
return card return card
def _join(self) -> None: def _join(self) -> None:
code = self._code_input.text().strip().upper() code = self._code_input.text().strip().upper()
if not load_token(): if not load_token():
self._guest_status.setText("Set a Gitea access token in Setup → Account access first.") self._guest_status.setText("Set a Gitea access token in Settings → Account access first.")
return return
if not code: if not code:
self._guest_status.setText("Enter a share code.") self._guest_status.setText("Enter a share code.")
@@ -290,46 +267,40 @@ class SharePage(QWidget):
self._guest_status.setText(data["error"]) self._guest_status.setText(data["error"])
return return
if "joined" in data: if "joined" in data:
self._guest_status.setText(f"Viewing {data.get('host', '?')}'s machine — read-only.") self._guest_status.setText(f"Connected to {data.get('host', '?')}'s terminal — watching.")
self._leave_btn.setEnabled(True) self._leave_btn.setEnabled(True)
self._view.setVisible(True) self._guest_fs_btn.setEnabled(True)
self._guest_term.reset()
self._guest_term.setVisible(True)
self._guest_ws.sendTextMessage(json.dumps({"type": "req_full"})) self._guest_ws.sendTextMessage(json.dumps({"type": "req_full"}))
return return
kind = data.get("type") kind = data.get("type")
if kind in ("full", "snapshot"): if kind == "terminal":
if kind == "full": self._guest_can_type = bool(data.get("enabled"))
self._last_report = data.get("report") self._guest_status.setText(
self._last_inv = data.get("inventory") "You can type — your keystrokes run on the host's machine."
self._view.setHtml(share.guest_html(data.get("snapshot"), self._last_report, self._last_inv)) if self._guest_can_type else "Read-only — watching the host's terminal.")
elif kind == "terminal": if self._guest_can_type:
self._set_terminal_visible(bool(data.get("enabled"))) self._guest_term.setFocus()
self._guest_resize(*self._guest_term.grid())
elif kind == "pty": elif kind == "pty":
self._guest_term.feed(base64.b64decode(data["data"])) self._guest_term.feed(base64.b64decode(data["data"]))
def _set_terminal_visible(self, enabled: bool) -> None:
self._term_label.setVisible(True)
self._term_label.setText("Terminal enabled by host — your keystrokes run on their machine. Click here and type."
if enabled else "Terminal not enabled by the host.")
self._guest_term.setVisible(enabled)
if enabled:
self._guest_term.reset()
self._guest_resize(*self._guest_term.grid())
self._guest_term.setFocus()
def _guest_key(self, data: bytes) -> None: def _guest_key(self, data: bytes) -> None:
if self._guest_ws: if self._guest_ws and self._guest_can_type:
self._guest_ws.sendTextMessage(json.dumps({"type": "pty_in", "data": _b64(data)})) self._guest_ws.sendTextMessage(json.dumps({"type": "pty_in", "data": _b64(data)}))
def _guest_resize(self, rows: int, cols: int) -> None: def _guest_resize(self, rows: int, cols: int) -> None:
if self._guest_ws: if self._guest_ws and self._guest_can_type:
self._guest_ws.sendTextMessage(json.dumps({"type": "pty_resize", "rows": rows, "cols": cols})) self._guest_ws.sendTextMessage(json.dumps({"type": "pty_resize", "rows": rows, "cols": cols}))
def _leave(self) -> None: def _leave(self) -> None:
if self._guest_ws: if self._guest_ws:
self._guest_ws.close() self._guest_ws.close()
self._guest_ws = None self._guest_ws = None
for w in (self._view, self._term_label, self._guest_term): self._guest_term.setVisible(False)
w.setVisible(False) self._guest_fs_btn.setEnabled(False)
self._guest_can_type = False
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
self._join_btn.setEnabled(True) self._join_btn.setEnabled(True)
self._guest_status.setText("Left the session.") self._guest_status.setText("Left the session.")
@@ -337,11 +308,40 @@ class SharePage(QWidget):
def _guest_closed(self) -> None: def _guest_closed(self) -> None:
self._join_btn.setEnabled(True) self._join_btn.setEnabled(True)
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
if self._view.isVisible(): if self._guest_term.isVisible():
self._guest_status.setText("Session ended (host disconnected).") self._guest_status.setText("Session ended (host disconnected).")
# --------------------------------------------------------------- full screen
def _enter_fullscreen(self, term: TerminalView) -> None:
if self._fs is not None:
return
parent_layout = term.parentWidget().layout()
self._fs_state = (parent_layout, parent_layout.indexOf(term), term)
self._fs = QWidget()
self._fs.setStyleSheet("background:#0d0f13;")
lay = QVBoxLayout(self._fs)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
hint = QLabel("Esc to exit full screen")
hint.setObjectName("Muted")
hint.setStyleSheet("padding:4px 10px; background:#15181e;")
lay.addWidget(hint)
lay.addWidget(term, 1)
QShortcut(QKeySequence(Qt.Key.Key_Escape), self._fs, activated=self._leave_fullscreen)
self._fs.showFullScreen()
term.setFocus()
def _leave_fullscreen(self) -> None:
if self._fs is None:
return
parent_layout, index, term = self._fs_state
parent_layout.insertWidget(index, term)
self._fs.close()
self._fs = None
self._fs_state = None
term.setFocus()
def shutdown(self) -> None: def shutdown(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
for ws in (self._host_ws, self._guest_ws): for ws in (self._host_ws, self._guest_ws):
if ws: if ws:
+85 -22
View File
@@ -1,30 +1,66 @@
"""A minimal terminal view: renders PTY output via pyte and emits keystrokes (M12, Tier 3). """A terminal view: renders PTY output via pyte (with colors) and emits keystrokes (M12).
Used by both sides of a shared session the host (mirrors its local PTY, can also type, e.g. Used by both sides of a shared session the host (mirrors its local PTY, can also type, e.g.
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Monochrome for a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Renders pyte's
now; cursor addressing / layout (vim, top) work via pyte. per-cell foreground/background/bold/reverse so the host's real shell (e.g. fish) keeps its
colors and theming; cursor addressing (vim, top) works via pyte. Scrollback is preserved.
""" """
from __future__ import annotations from __future__ import annotations
import html as _html
import pyte import pyte
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor from PySide6.QtGui import QFontDatabase, QFontMetrics
from PySide6.QtWidgets import QPlainTextEdit from PySide6.QtWidgets import QTextEdit
# ANSI named colors → RGB (a dark, modern palette). pyte also yields 6-hex strings for
# 256-color / truecolor, which we pass through, and "default" which maps to the theme.
_FG_DEFAULT = "#d6dae0"
_BG_DEFAULT = "#0d0f13"
_NAMED = {
"black": "#2a2f39", "red": "#f87171", "green": "#4ade80", "brown": "#e5c07b",
"yellow": "#e5c07b", "blue": "#60a5fa", "magenta": "#c084fc", "cyan": "#38bdf8",
"white": "#d6dae0",
}
_BRIGHT = { # bold brightens the standard 8
"black": "#5b626c", "red": "#fca5a5", "green": "#86efac", "brown": "#fde68a",
"yellow": "#fde68a", "blue": "#93c5fd", "magenta": "#d8b4fe", "cyan": "#7dd3fc",
"white": "#ffffff",
}
_HISTORY_RENDER = 400 # cap scrollback rows rendered per frame (perf)
class TerminalView(QPlainTextEdit): def _color(name: str, default: str, bright: bool) -> str:
if name == "default":
return default
table = _BRIGHT if bright else _NAMED
if name in table:
return table[name]
if len(name) == 6: # pyte 256/truecolor as a hex string
try:
int(name, 16)
return "#" + name
except ValueError:
pass
return default
class TerminalView(QTextEdit):
keys = Signal(bytes) # user keystrokes -> bytes for the PTY keys = Signal(bytes) # user keystrokes -> bytes for the PTY
resized = Signal(int, int) # rows, cols resized = Signal(int, int) # rows, cols
def __init__(self, rows: int = 24, cols: int = 80): def __init__(self, rows: int = 24, cols: int = 80):
super().__init__() super().__init__()
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.setUndoRedoEnabled(False) self.setUndoRedoEnabled(False)
self.setMinimumHeight(260) self.setReadOnly(False) # we capture keys ourselves; no local editing
self.setStyleSheet(f"QTextEdit {{ background: {_BG_DEFAULT}; border: none; }}")
self.setMinimumHeight(320)
self._rows, self._cols = rows, cols self._rows, self._cols = rows, cols
self._screen = pyte.HistoryScreen(cols, rows, history=1000, ratio=0.5) self._screen = pyte.HistoryScreen(cols, rows, history=2000, ratio=0.5)
self._stream = pyte.ByteStream(self._screen) self._stream = pyte.ByteStream(self._screen)
def grid(self) -> tuple[int, int]: def grid(self) -> tuple[int, int]:
@@ -38,24 +74,51 @@ class TerminalView(QPlainTextEdit):
self._screen.reset() self._screen.reset()
self._render() self._render()
def _row_text(self, row) -> str: # --- rendering ---------------------------------------------------------------------
return "".join(row[x].data for x in range(self._cols)).rstrip() def _span(self, style, text: str) -> str:
fg_name, bg_name, bold, reverse = style
fg = _color(fg_name, _FG_DEFAULT, bold)
bg = _color(bg_name, _BG_DEFAULT, False)
if reverse:
fg, bg = bg, fg
esc = _html.escape(text, quote=False).replace(" ", "&nbsp;")
weight = "font-weight:bold;" if bold else ""
return f'<span style="color:{fg};background:{bg};{weight}">{esc}</span>'
def _row_html(self, row, cursor_x) -> str:
out: list[str] = []
buf: list[str] = []
cur_style = None
for x in range(self._cols):
ch = row[x]
reverse = ch.reverse
if cursor_x is not None and x == cursor_x and self.hasFocus():
reverse = not reverse # block cursor = inverted cell
style = (ch.fg, ch.bg, ch.bold, reverse)
if style != cur_style:
if buf:
out.append(self._span(cur_style, "".join(buf)))
buf = []
cur_style = style
buf.append(ch.data or " ")
if buf:
out.append(self._span(cur_style, "".join(buf)))
return "".join(out)
def _render(self) -> None: def _render(self) -> None:
bar = self.verticalScrollBar() bar = self.verticalScrollBar()
at_bottom = bar.value() >= bar.maximum() - 2 at_bottom = bar.value() >= bar.maximum() - 2
prev = bar.value() prev = bar.value()
history = [self._row_text(r) for r in self._screen.history.top] # scrollback
self.setPlainText("\n".join(history + list(self._screen.display))) history = list(self._screen.history.top)[-_HISTORY_RENDER:]
if at_bottom: # follow output; place caret at the real (row, col) lines = [self._row_html(r, None) for r in history]
cursor = self.textCursor() cur_y = self._screen.cursor.y
cursor.movePosition(QTextCursor.MoveOperation.Start) for y in range(self._rows):
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y) cursor_x = self._screen.cursor.x if y == cur_y else None
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x) lines.append(self._row_html(self._screen.buffer[y], cursor_x))
self.setTextCursor(cursor) self.setHtml('<div style="white-space:pre;line-height:100%;">' + "<br>".join(lines) + "</div>")
self.ensureCursorVisible()
else: # user scrolled up to read — keep their place bar.setValue(bar.maximum() if at_bottom else prev)
bar.setValue(prev)
def resizeEvent(self, event): # noqa: N802 (Qt override) def resizeEvent(self, event): # noqa: N802 (Qt override)
super().resizeEvent(event) super().resizeEvent(event)
+19
View File
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
}} }}
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }} QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }} QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
QLabel#NavSection {{ color: {MUTED}; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 12px 0; }}
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }} #Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
QPushButton#CardHeader {{ QPushButton#CardHeader {{
@@ -104,6 +105,15 @@ QPushButton#PrimaryButton {{ background: {ACCENT}; color: #06222e; border: none;
QPushButton#PrimaryButton:hover {{ background: #5cc8fb; }} QPushButton#PrimaryButton:hover {{ background: #5cc8fb; }}
QPushButton#PrimaryButton:disabled {{ background: #27424f; color: #5f7c8a; }} QPushButton#PrimaryButton:disabled {{ background: #27424f; color: #5f7c8a; }}
/* Inline per-finding action buttons (Install / Apply). Outlined: bright accent text on the
dark card so it stays readable regardless of fill painting; fills accent on hover. */
QPushButton#ActionButton {{
background: transparent; color: {ACCENT}; border: 1px solid {ACCENT};
border-radius: 8px; padding: 6px 16px; font-weight: 700; min-height: 18px;
}}
QPushButton#ActionButton:hover {{ background: {ACCENT}; color: #06222e; }}
QPushButton#ActionButton:disabled {{ color: {MUTED}; border-color: {CARD_BORDER}; }}
QDoubleSpinBox, QSpinBox {{ QDoubleSpinBox, QSpinBox {{
background: #262b34; color: {TEXT}; border: 1px solid {CARD_BORDER}; background: #262b34; color: {TEXT}; border: 1px solid {CARD_BORDER};
border-radius: 6px; padding: 4px 6px; border-radius: 6px; padding: 4px 6px;
@@ -150,4 +160,13 @@ QLineEdit:focus, QPlainTextEdit:focus, QAbstractSpinBox:focus, QComboBox:focus {
border: 1px solid {ACCENT}; border: 1px solid {ACCENT};
}} }}
QLineEdit:disabled, QPlainTextEdit:disabled, QAbstractSpinBox:disabled {{ color: {MUTED}; }} QLineEdit:disabled, QPlainTextEdit:disabled, QAbstractSpinBox:disabled {{ color: {MUTED}; }}
/* The combo-box drop-down list is a separate popup view unstyled it renders
light-on-light (same Fusion trap as the closed control above). */
QComboBox QAbstractItemView {{
background: {CARD}; color: {TEXT};
border: 1px solid {CARD_BORDER}; outline: 0;
selection-background-color: {ACCENT}; selection-color: #06222e;
}}
QComboBox QAbstractItemView::item {{ padding: 5px 8px; min-height: 22px; }}
""" """
+144
View File
@@ -0,0 +1,144 @@
"""System-tray applet (M11, D13): live readouts + quick actions over the shared engine.
A QSystemTrayIcon whose menu shows at-a-glance CPU/GPU temp + memory and a status dot, led
by **Run Diagnostic** (the guided session), plus Open dashboard / Start-Stop recording /
Snapshot / Quit. It consumes the same sample stream as the dashboard (no extra sampling) and
drives the existing MainWindow flows one engine, another front-end.
"""
from __future__ import annotations
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from ..core import reccontrol
def _gpu_temp(sample):
for r in sample.readings:
if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None:
return r.value
return None
def _cpu_temp(sample):
temps = [r for r in sample.readings if r.source == "cpu" and r.metric == "temp" and r.value is not None]
for r in temps:
low = r.label.lower()
if low.startswith("package") or "tctl" in low or "tdie" in low:
return r.value
return max((r.value for r in temps), default=None)
def _memory(sample):
used = total = pct = None
for r in sample.readings:
if r.source == "memory":
if r.metric == "used":
used = r.value
elif r.metric == "total":
total = r.value
elif r.metric == "used_pct":
pct = r.value
return used, total, pct
def _gpu_lost(sample) -> bool:
return any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout"
for r in sample.readings)
class TrayIcon(QSystemTrayIcon):
def __init__(self, window, icon, gpu_alert: float = 90.0, cpu_alert: float = 95.0) -> None:
super().__init__(icon, window)
self._window = window
self._gpu_alert = gpu_alert
self._cpu_alert = cpu_alert
self._last = None
self.setToolTip("RigDoctor")
menu = QMenu()
self._status_act = self._readout(menu, "● starting…")
self._cpu_act = self._readout(menu, "CPU temp: —")
self._gpu_act = self._readout(menu, "GPU temp: —")
self._mem_act = self._readout(menu, "Memory: —")
menu.addSeparator()
self._diag_menu = menu.addMenu("Run Diagnostic")
self._diag_menu.aboutToShow.connect(self._rebuild_diag_menu)
menu.addAction("Open dashboard", self._window.show_dashboard)
self._rec_act = menu.addAction("Start recording", self._toggle_record)
menu.addAction("Snapshot (copy)", self._snapshot)
menu.addSeparator()
menu.addAction("Quit", self._window.quit_app)
menu.aboutToShow.connect(self._refresh_actions)
self.setContextMenu(menu)
self.activated.connect(self._on_activated)
@staticmethod
def _readout(menu: QMenu, text: str):
act = menu.addAction(text)
act.setEnabled(False) # display-only line
return act
def _on_activated(self, reason) -> None:
if reason in (QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick):
self._window.show_dashboard()
def update_sample(self, sample) -> None:
self._last = sample
cpu, gpu = _cpu_temp(sample), _gpu_temp(sample)
used, total, pct = _memory(sample)
self._cpu_act.setText(f"CPU temp: {cpu:.0f} °C" if cpu is not None else "CPU temp: —")
self._gpu_act.setText(f"GPU temp: {gpu:.0f} °C" if gpu is not None else "GPU temp: —")
if used is not None and total is not None:
extra = f" ({pct:.0f}%)" if pct is not None else ""
self._mem_act.setText(f"Memory: {used:.1f} / {total:.1f} GB{extra}")
else:
self._mem_act.setText("Memory: —")
if _gpu_lost(sample):
self._status_act.setText("● GPU not responding")
elif (gpu is not None and gpu >= self._gpu_alert) or (cpu is not None and cpu >= self._cpu_alert):
self._status_act.setText("● Hot — over alert threshold")
else:
self._status_act.setText("● Normal")
bits = []
if cpu is not None:
bits.append(f"CPU {cpu:.0f}°C")
if gpu is not None:
bits.append(f"GPU {gpu:.0f}°C")
self.setToolTip("RigDoctor" + ("" + " ".join(bits) if bits else ""))
def _refresh_actions(self) -> None:
self._rec_act.setText("Stop recording" if reccontrol.running_pid() else "Start recording")
def _toggle_record(self) -> None:
if reccontrol.running_pid():
reccontrol.stop_background()
else:
reccontrol.start_background()
def _rebuild_diag_menu(self) -> None:
from ..core import steam
self._diag_menu.clear()
games = steam.cached_games()
if not games:
self._diag_menu.addAction("Open Games to pick a game…",
lambda: self._window.show_page("Games"))
return
for g in games[:20]:
self._diag_menu.addAction(
g.name,
lambda _checked=False, name=g.name, appid=g.appid: self._window.run_diagnostic(name, appid),
)
def _snapshot(self) -> None:
if self._last is None:
return
from ..render import render_snapshot
QApplication.clipboard().setText(render_snapshot(self._last))
self.showMessage("RigDoctor", "Snapshot copied to clipboard.",
QSystemTrayIcon.MessageIcon.Information, 4000)
+130 -5
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QRectF, Qt from collections import deque
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QFrame, QFrame,
@@ -17,7 +19,19 @@ from PySide6.QtWidgets import (
from ..core.sample import Reading from ..core.sample import Reading
from ..render import format_value from ..render import format_value
from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT, TRACK, WARN, gauge_color, temp_color from .theme import (
ACCENT,
CRIT,
GOOD,
MUTED,
TEMP_WARN,
TEXT,
TRACK,
USAGE_WARN,
WARN,
gauge_color,
temp_color,
)
_SEV = { _SEV = {
"critical": ("CRITICAL", CRIT), "critical": ("CRITICAL", CRIT),
@@ -66,7 +80,7 @@ def finding_card(finding, on_install=None, on_apply=None) -> QFrame:
row = QHBoxLayout() row = QHBoxLayout()
row.addStretch(1) row.addStretch(1)
btn = QPushButton(f"Install {component.name}") btn = QPushButton(f"Install {component.name}")
btn.setObjectName("PrimaryButton") btn.setObjectName("ActionButton")
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(lambda: on_install(component)) btn.clicked.connect(lambda: on_install(component))
row.addWidget(btn) row.addWidget(btn)
@@ -83,7 +97,7 @@ def finding_card(finding, on_install=None, on_apply=None) -> QFrame:
combo.setCurrentText(tunable.current) combo.setCurrentText(tunable.current)
combo.setCursor(Qt.CursorShape.PointingHandCursor) combo.setCursor(Qt.CursorShape.PointingHandCursor)
apply_btn = QPushButton("Apply") apply_btn = QPushButton("Apply")
apply_btn.setObjectName("PrimaryButton") apply_btn.setObjectName("ActionButton")
apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) apply_btn.setCursor(Qt.CursorShape.PointingHandCursor)
apply_btn.clicked.connect(lambda: on_apply(tunable.id, combo.currentText())) apply_btn.clicked.connect(lambda: on_apply(tunable.id, combo.currentText()))
row.addWidget(name) row.addWidget(name)
@@ -248,6 +262,117 @@ class StatGauge(QWidget):
p.end() p.end()
class HistoryGraph(QWidget):
"""A headline metric as a trend: current value + window min/max + a history line.
Replaces the at-a-glance gauge with changes-over-time. `kind` drives the color
(temp band / usage / accent), matching StatGauge so the dashboard stays consistent.
"""
def __init__(self, title: str, unit: str = "", vmin: float = 0.0, vmax: float = 100.0,
kind: str = "accent", history: int = 180) -> None:
super().__init__()
self._title = title
self._unit = unit
self._min = vmin
self._max = vmax
self._kind = kind # "temp" | "usage" | "accent"
self._values: deque[float | None] = deque(maxlen=history)
self.setMinimumSize(160, 132)
def add_value(self, value: float | None) -> None:
self._values.append(value)
self.update()
def _fmt(self, value: float | None) -> str:
if value is None:
return ""
if self._unit == "°C":
return f"{value:.0f}°"
if self._unit == "%":
return f"{value:.0f}%"
return f"{value:.0f}{self._unit}"
def paintEvent(self, event) -> None: # noqa: N802 (Qt override)
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
pad = 10.0
present = [v for v in self._values if v is not None]
current = next((v for v in reversed(self._values) if v is not None), None)
color = QColor(gauge_color(self._kind, current))
ftitle = QFont()
ftitle.setPointSizeF(10.0)
ftitle.setBold(True)
p.setFont(ftitle)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 6, w - 2 * pad, 18),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title)
fval = QFont()
fval.setPointSizeF(21.0)
fval.setBold(True)
p.setFont(fval)
p.setPen(color if current is not None else QColor(MUTED))
p.drawText(QRectF(pad, 2, w - 2 * pad, 28),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, self._fmt(current))
if present:
fsm = QFont()
fsm.setPointSizeF(8.5)
p.setFont(fsm)
p.setPen(QColor(MUTED))
p.drawText(QRectF(pad, 27, w - 2 * pad, 14), Qt.AlignmentFlag.AlignLeft,
f"min {self._fmt(min(present))} max {self._fmt(max(present))}")
g_top, g_bot = 48.0, h - pad
g_left, g_right = pad, w - pad
span = self._max - self._min
if g_bot - g_top < 12 or g_right - g_left < 12 or span <= 0:
p.end()
return
def y_of(v: float) -> float:
frac = (max(self._min, min(self._max, v)) - self._min) / span
return g_bot - frac * (g_bot - g_top)
warn = TEMP_WARN if self._kind == "temp" else (USAGE_WARN if self._kind == "usage" else None)
if warn is not None and self._min <= warn <= self._max:
pen = QPen(QColor(TRACK))
pen.setWidthF(1.0)
pen.setStyle(Qt.PenStyle.DashLine)
p.setPen(pen)
yw = y_of(warn)
p.drawLine(QPointF(g_left, yw), QPointF(g_right, yw))
maxlen = self._values.maxlen or 1
step = (g_right - g_left) / max(1, maxlen - 1)
n = len(self._values)
# Build the line newest-at-right; break it where readings are missing.
path = QPainterPath()
drawing = False
for i, v in enumerate(self._values):
if v is None:
drawing = False
continue
x = g_right - (n - 1 - i) * step
y = y_of(v)
if drawing:
path.lineTo(x, y)
else:
path.moveTo(x, y)
drawing = True
if not path.isEmpty():
pen = QPen(color)
pen.setWidthF(2.0)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
p.setPen(pen)
p.drawPath(path)
p.end()
class MetricBar(QWidget): class MetricBar(QWidget):
"""A label + value with a thin progress bar (for 0100% metrics).""" """A label + value with a thin progress bar (for 0100% metrics)."""
+170
View File
@@ -0,0 +1,170 @@
"""Live monitor TUI (M2): a curses HWMonitor-style terminal dashboard.
Shows current / session-min / session-max per sensor, grouped by subsystem, with
temperature and utilization color bands. stdlib `curses` only; falls back to a plain
full-screen redraw when stdout isn't a TTY (piped/SSH-without-tty). Keys: q quit, r reset
the session min/max. The terminal face of the same live data the GUI dashboard graphs.
"""
from __future__ import annotations
import curses
import sys
import time
from .core.sample import Reading, Sample
from .core.sampler import Sampler
from .core.sources import available_sources
from .render import _GROUP_ORDER, _GROUP_TITLES, format_raw, metric_label, render_snapshot
# Color-band thresholds (mirror the GUI dashboard so both faces agree).
TEMP_COLD, TEMP_WARN, TEMP_CRIT = 50.0, 78.0, 88.0
USAGE_WARN, USAGE_CRIT = 85.0, 95.0
_USAGE_METRICS = {"util", "used_pct", "mem_util", "load"}
def band(r: Reading) -> str:
"""Color band for a reading: cold | good | warn | crit | normal | na."""
if r.source == "gpu" and r.metric == "status": # GPU-lost / query timeout
return "crit"
if r.value is None:
return "na"
if r.unit == "°C":
if r.value >= TEMP_CRIT:
return "crit"
if r.value >= TEMP_WARN:
return "warn"
if r.value >= TEMP_COLD:
return "good"
return "cold"
if r.unit == "%" and r.metric in _USAGE_METRICS:
if r.value >= USAGE_CRIT:
return "crit"
if r.value >= USAGE_WARN:
return "warn"
return "good"
return "normal"
def track(stats: dict[str, tuple[float, float]], sample: Sample) -> None:
"""Fold a sample's readings into {key: (min, max)} session extremes."""
for r in sample.readings:
if r.value is None:
continue
lo, hi = stats.get(r.key, (r.value, r.value))
stats[r.key] = (min(lo, r.value), max(hi, r.value))
# --- curses front-end -----------------------------------------------------------------
_BAND_PAIR = {"cold": 1, "good": 2, "warn": 3, "crit": 4}
def _init_colors() -> None:
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
curses.init_pair(3, curses.COLOR_YELLOW, -1)
curses.init_pair(4, curses.COLOR_RED, -1)
except curses.error:
pass
def _attr(band_name: str) -> int:
pair = _BAND_PAIR.get(band_name)
if not pair:
return curses.A_NORMAL
attr = curses.color_pair(pair)
return attr | curses.A_BOLD if band_name == "crit" else attr
def _draw(stdscr, sample: Sample, stats: dict, interval: float) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
def put(y: int, x: int, text: str, attr: int = curses.A_NORMAL) -> None:
if 0 <= y < height and 0 <= x < width:
try:
stdscr.addnstr(y, x, text, max(0, width - x - 1), attr)
except curses.error:
pass
put(0, 0, f"RigDoctor — live monitor every {interval:g}s", curses.A_BOLD)
put(1, 0, "q quit r reset min/max", curses.A_DIM)
groups = sample.by_source()
order = [k for k in _GROUP_ORDER if k in groups] + [k for k in groups if k not in _GROUP_ORDER]
name_w, col_w = 24, 11
y = 3
for key in order:
if y >= height:
break
put(y, 0, _GROUP_TITLES.get(key, key.title()), curses.A_BOLD)
y += 1
put(y, 2, f"{'sensor':<{name_w}}{'current':>{col_w}}{'min':>{col_w}}{'max':>{col_w}}", curses.A_DIM)
y += 1
for r in groups[key]:
if y >= height:
break
if r.metric == "name": # device identity line
put(y, 2, str(r.label), curses.A_DIM)
y += 1
continue
lo, hi = stats.get(r.key, (r.value, r.value))
put(y, 2, f"{metric_label(r):<{name_w}}")
put(y, 2 + name_w, f"{format_raw(r.value, r.unit):>{col_w}}", _attr(band(r)))
put(y, 2 + name_w + col_w, f"{format_raw(lo, r.unit):>{col_w}}", curses.A_DIM)
put(y, 2 + name_w + 2 * col_w, f"{format_raw(hi, r.unit):>{col_w}}", curses.A_DIM)
y += 1
y += 1
stdscr.refresh()
def _loop(stdscr, sampler: Sampler, interval: float) -> None:
curses.curs_set(0)
stdscr.nodelay(True)
_init_colors()
stats: dict[str, tuple[float, float]] = {}
latest = sampler.sample()
track(stats, latest)
next_sample = time.monotonic() + interval
while True:
ch = stdscr.getch()
if ch in (ord("q"), ord("Q")):
return
if ch in (ord("r"), ord("R")):
stats.clear()
track(stats, latest)
now = time.monotonic()
if now >= next_sample:
latest = sampler.sample()
track(stats, latest)
next_sample = now + interval
_draw(stdscr, latest, stats, interval)
time.sleep(0.05) # keep key handling responsive without busy-spinning
def _run_plain(sampler: Sampler, interval: float) -> int:
"""Fallback for non-TTY output: clear + reprint each tick (no curses)."""
try:
for sample in sampler.stream(interval=interval):
print("\033[2J\033[H", end="")
print(f"RigDoctor — live (every {interval:g}s, Ctrl-C to quit)\n")
print(render_snapshot(sample))
sys.stdout.flush()
except KeyboardInterrupt:
print()
return 0
def run(interval: float, plain: bool = False) -> int:
sampler = Sampler(available_sources())
if plain or not sys.stdout.isatty():
return _run_plain(sampler, interval)
try:
curses.wrapper(_loop, sampler, interval)
except curses.error: # terminal can't do curses — degrade gracefully
return _run_plain(sampler, interval)
return 0
+111
View File
@@ -0,0 +1,111 @@
"""Tests for the guided diagnostic orchestration (M3+M4 glue)."""
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import diagnostic
from rigdoctor.core.crashlog import CrashLogWriter, summarize
from rigdoctor.core.health import Finding
from rigdoctor.core.sample import Reading, Sample
def _write_log(path: str, game: str) -> None:
w = CrashLogWriter(path)
w.write_event("session-start", "interval=1s")
w.write_event("game", game)
for temp in (60.0, 72.0, 81.0):
w.write_sample(Sample(ts=time.time(), readings=[Reading("gpu", "temp", temp, "°C", "")]))
w.write_event("gpu-lost", "nvidia-smi query timed out")
w.close()
class GameRecoveryTests(unittest.TestCase):
def test_game_recovered_from_log_event(self):
with tempfile.TemporaryDirectory() as d:
log = str(Path(d) / "capture.jsonl")
_write_log(log, "Path of Exile 2")
summary = summarize(log)
self.assertEqual(diagnostic._game_from_summary(summary), "Path of Exile 2")
def test_no_game_event_returns_none(self):
with tempfile.TemporaryDirectory() as d:
log = str(Path(d) / "capture.jsonl")
w = CrashLogWriter(log)
w.write_event("session-start")
w.close()
self.assertIsNone(diagnostic._game_from_summary(summarize(log)))
class FinishTests(unittest.TestCase):
def test_finish_combines_summary_and_findings(self):
with tempfile.TemporaryDirectory() as d:
log = Path(d) / "capture.jsonl"
_write_log(str(log), "Satisfactory")
fake = [Finding("warning", "GPU", "NVIDIA Xid 79 ×1", "fell off the bus")]
with mock.patch("rigdoctor.core.health.run_health_checks", return_value=fake), \
mock.patch.object(diagnostic.reccontrol, "stop_background", return_value=False), \
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
result = diagnostic.finish(log_path=log)
self.assertEqual(result.game, "Satisfactory")
self.assertEqual(result.summary.samples, 3)
self.assertEqual(result.findings, fake)
# peak GPU temp captured in the window, GPU-lost event recorded
self.assertEqual(result.summary.maxima["gpu.temp"][0], 81.0)
self.assertTrue(any(kind == "gpu-lost" for _ts, kind, _d in result.summary.events))
class CrashDetectionTests(unittest.TestCase):
def _diag_log(self, d) -> Path:
return Path(d) / "diagnostic.jsonl"
def test_unterminated_session_is_a_pending_crash(self):
with tempfile.TemporaryDirectory() as d:
log = self._diag_log(d)
_write_log(str(log), "Tarkov") # has session-start + game, no session-stop
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
info = diagnostic.pending_crash()
self.assertIsNotNone(info)
self.assertEqual(info.game, "Tarkov")
self.assertTrue(info.gpu_lost) # _write_log writes a gpu-lost event
def test_clean_stop_is_not_a_crash(self):
with tempfile.TemporaryDirectory() as d:
log = self._diag_log(d)
w = CrashLogWriter(str(log))
w.write_event("session-start"); w.write_event("game", "X")
w.write_sample(Sample(time.time(), [Reading("gpu", "temp", 60.0, "°C", "")]))
w.write_event("session-stop", "samples=1")
w.close()
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
self.assertIsNone(diagnostic.pending_crash())
def test_acknowledge_clears_pending_crash(self):
with tempfile.TemporaryDirectory() as d:
log = self._diag_log(d)
_write_log(str(log), "Tarkov")
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
self.assertIsNotNone(diagnostic.pending_crash())
diagnostic.acknowledge_crash()
self.assertIsNone(diagnostic.pending_crash())
def test_running_capture_is_not_a_crash(self):
with tempfile.TemporaryDirectory() as d:
log = self._diag_log(d)
_write_log(str(log), "Tarkov")
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=4321):
self.assertIsNone(diagnostic.pending_crash()) # it's in-progress, not crashed
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
"""GUI smoke tests: construct the real widgets so a startup crash fails the build.
These run headless (offscreen) and skip cleanly if PySide6 isn't installed (the core/CLI
test suite stays Qt-free). Constructing MainWindow is the check that would have caught the
0.18.0 bad-import regression that broke launch.
"""
import os
import time
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication, QWidget
HAVE_QT = True
except ImportError:
HAVE_QT = False
@unittest.skipUnless(HAVE_QT, "PySide6 not installed")
class GuiSmokeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def test_main_window_constructs(self):
from unittest import mock
from rigdoctor.core import updates
from rigdoctor.gui import main_window as mw
# Avoid construction side effects: no pkexec elevation, no network update check.
with mock.patch("rigdoctor.core.elevation.available", return_value=False), \
mock.patch.object(updates, "update_state", return_value=(updates.UP_TO_DATE, None, "")):
window = mw.MainWindow()
try:
self.assertEqual(len(window._nav_buttons), len(mw._PAGES))
self.assertEqual(set(window._nav_buttons), set(mw._PAGES))
finally:
window._worker.stop()
def test_tray_readouts_update(self):
from rigdoctor.core.sample import Reading, Sample
from rigdoctor.gui.tray import TrayIcon
class StubWindow(QWidget):
def show_dashboard(self): ...
def show_page(self, name): ...
def run_diagnostic(self, name, appid): ...
def quit_app(self): ...
tray = TrayIcon(StubWindow(), QIcon())
tray.update_sample(Sample(time.time(), [
Reading("gpu", "temp", 72.0, "°C", ""),
Reading("cpu", "temp", 65.0, "°C", "Package id 0"),
Reading("memory", "used", 14.2, "GB"),
Reading("memory", "total", 31.0, "GB"),
Reading("memory", "used_pct", 46.0, "%"),
]))
self.assertIn("72", tray._gpu_act.text())
self.assertIn("65", tray._cpu_act.text())
self.assertIn("14.2 / 31.0 GB", tray._mem_act.text())
self.assertEqual(tray._status_act.text(), "● Normal")
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Tests for M6 non-Steam game detection (Lutris SQLite + Heroic JSON)."""
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import launchers
class LutrisTests(unittest.TestCase):
def test_reads_installed_games_only(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "pga.db"
con = sqlite3.connect(db)
con.execute("CREATE TABLE games (id INTEGER, name TEXT, slug TEXT, installed INTEGER)")
con.executemany(
"INSERT INTO games VALUES (?, ?, ?, ?)",
[(1, "Hades", "hades", 1), (2, "Hollow Knight", "hollow-knight", 1), (3, "Old Game", "old", 0)],
)
con.commit()
con.close()
with mock.patch.object(launchers, "LUTRIS_DB", db), \
mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"):
games = launchers.scan()
names = {g.name for g in games}
self.assertEqual(names, {"Hades", "Hollow Knight"})
self.assertTrue(all(g.launcher == "lutris" for g in games))
def test_missing_db_is_empty(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "absent.db"), \
mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"):
self.assertEqual(launchers.scan(), [])
class HeroicTests(unittest.TestCase):
def test_epic_and_gog(self):
with tempfile.TemporaryDirectory() as d:
base = Path(d) / "heroic"
(base / "legendaryConfig" / "legendary").mkdir(parents=True)
(base / "gog_store").mkdir(parents=True)
(base / "legendaryConfig" / "legendary" / "installed.json").write_text(
json.dumps({"abc123": {"title": "Control"}}))
(base / "gog_store" / "installed.json").write_text(
json.dumps({"installed": [{"appName": "777", "title": "The Witcher 3"}]}))
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \
mock.patch.object(launchers, "HEROIC_DIR", base):
names = {g.name for g in launchers.scan()}
self.assertEqual(names, {"Control", "The Witcher 3"})
def test_gog_title_falls_back_to_install_path(self):
with tempfile.TemporaryDirectory() as d:
base = Path(d) / "heroic"
(base / "gog_store").mkdir(parents=True)
(base / "gog_store" / "installed.json").write_text(
json.dumps({"installed": [{"appName": "9", "install_path": "/games/Stardew Valley"}]}))
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \
mock.patch.object(launchers, "HEROIC_DIR", base):
names = {g.name for g in launchers.scan()}
self.assertEqual(names, {"Stardew Valley"})
if __name__ == "__main__":
unittest.main()
-38
View File
@@ -1,38 +0,0 @@
"""Tests for M12 relay frames + guest HTML rendering (host/guest data shapes)."""
import json
import unittest
from rigdoctor.core import share
from rigdoctor.core.sampler import Sampler
from rigdoctor.core.sources import available_sources
class RelayFrameTests(unittest.TestCase):
def setUp(self):
self.sampler = Sampler(available_sources())
def test_full_frame_shape(self):
frame = json.loads(share.host_full_frame(self.sampler))
self.assertEqual(frame["type"], "full")
self.assertIn("groups", frame["snapshot"])
self.assertIsInstance(frame["report"], list)
self.assertIsInstance(frame["inventory"], dict)
def test_snapshot_frame_shape(self):
frame = json.loads(share.host_snapshot_frame(self.sampler))
self.assertEqual(frame["type"], "snapshot")
self.assertIn("groups", frame["snapshot"])
def test_guest_html_renders(self):
snap = {"groups": {"gpu": [{"name": "temp", "value": 51.0, "unit": "°C"}]}}
report = [{"severity": "ok", "category": "Logs", "title": "No errors"}]
inv = {"System": {"Kernel": "7.0.0"}}
html = share.guest_html(snap, report, inv)
self.assertIn("51.0 °C", html)
self.assertIn("No errors", html)
self.assertIn("Kernel", html)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -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()
-46
View File
@@ -1,46 +0,0 @@
"""Tests for M12 Tier 2 share server: token gating + endpoints."""
import json
import threading
import unittest
import urllib.error
import urllib.request
from rigdoctor.core import share
class ShareServerTests(unittest.TestCase):
def setUp(self):
self.srv, self.token = share.make_server("127.0.0.1", 0)
self.port = self.srv.server_address[1]
self.thread = threading.Thread(target=self.srv.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.srv.shutdown()
def _url(self, path, token=None):
q = f"?t={token}" if token else ""
return f"http://127.0.0.1:{self.port}{path}{q}"
def test_requires_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(self._url("/api/snapshot"), timeout=10)
self.assertEqual(cm.exception.code, 403)
def test_bad_token_rejected(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(self._url("/api/snapshot", "wrong"), timeout=10)
self.assertEqual(cm.exception.code, 403)
def test_snapshot_with_token(self):
data = json.load(urllib.request.urlopen(self._url("/api/snapshot", self.token), timeout=10))
self.assertIn("groups", data)
def test_page_served(self):
body = urllib.request.urlopen(self._url("/", self.token), timeout=10).read()
self.assertIn(b"read-only share", body)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the M2 live-monitor TUI logic (min/max tracking + color bands)."""
import unittest
from rigdoctor import tui
from rigdoctor.core.sample import Reading, Sample
def _temp(v):
return Reading("gpu", "temp", v, "°C", "")
class TrackTests(unittest.TestCase):
def test_tracks_min_and_max(self):
stats: dict = {}
for v in (60.0, 80.0, 70.0, 55.0):
tui.track(stats, Sample(0.0, [_temp(v)]))
self.assertEqual(stats["gpu.temp"], (55.0, 80.0))
def test_ignores_none_values(self):
stats: dict = {}
tui.track(stats, Sample(0.0, [_temp(None)]))
self.assertEqual(stats, {})
def test_keys_separate_by_label(self):
stats: dict = {}
tui.track(stats, Sample(0.0, [
Reading("cpu", "temp", 50.0, "°C", "Core 0"),
Reading("cpu", "temp", 70.0, "°C", "Core 1"),
]))
self.assertEqual(stats["cpu.temp.Core 0"], (50.0, 50.0))
self.assertEqual(stats["cpu.temp.Core 1"], (70.0, 70.0))
class BandTests(unittest.TestCase):
def test_temperature_bands(self):
self.assertEqual(tui.band(_temp(40.0)), "cold")
self.assertEqual(tui.band(_temp(60.0)), "good")
self.assertEqual(tui.band(_temp(80.0)), "warn")
self.assertEqual(tui.band(_temp(90.0)), "crit")
def test_usage_bands(self):
self.assertEqual(tui.band(Reading("gpu", "util", 50.0, "%")), "good")
self.assertEqual(tui.band(Reading("gpu", "util", 88.0, "%")), "warn")
self.assertEqual(tui.band(Reading("memory", "used_pct", 96.0, "%")), "crit")
def test_non_metric_percentage_is_normal(self):
self.assertEqual(tui.band(Reading("gpu", "fan", 100.0, "%")), "normal")
def test_gpu_lost_is_crit(self):
self.assertEqual(tui.band(Reading("gpu", "status", None, "", "query-timeout")), "crit")
def test_missing_value_is_na(self):
self.assertEqual(tui.band(Reading("gpu", "power", None, "W")), "na")
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -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()
+68
View File
@@ -0,0 +1,68 @@
"""Tests for the D12 Steam-launch wrapper (rigdoctor wrap %command%)."""
import unittest
from unittest import mock
from rigdoctor.core import wrap
from rigdoctor.core.steam import Game
class LaunchOptionTests(unittest.TestCase):
def test_format(self):
opt = wrap.launch_option()
self.assertTrue(opt.endswith("wrap %command%"))
self.assertIn("rigdoctor", opt)
class GameNameTests(unittest.TestCase):
def test_resolves_from_steam_appid(self):
g = Game(appid="570", name="Dota 2", library="/x", installdir="dota")
with mock.patch.dict("os.environ", {"SteamAppId": "570"}), \
mock.patch("rigdoctor.core.steam.cached_games", return_value=[g]):
self.assertEqual(wrap.game_name_from_env(), "Dota 2")
def test_unknown_appid_falls_back(self):
with mock.patch.dict("os.environ", {"SteamAppId": "999"}), \
mock.patch("rigdoctor.core.steam.cached_games", return_value=[]), \
mock.patch("rigdoctor.core.steam.scan_games", return_value=[]):
self.assertEqual(wrap.game_name_from_env(), "Steam app 999")
def test_none_without_steam_env(self):
with mock.patch.dict("os.environ", {}, clear=True):
self.assertIsNone(wrap.game_name_from_env())
class RunTests(unittest.TestCase):
def test_brackets_capture_and_returns_exit_code(self):
with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=None), \
mock.patch("rigdoctor.core.diagnostic.start", return_value=123) as start, \
mock.patch("rigdoctor.core.reccontrol.stop_background") as stop, \
mock.patch.dict("os.environ", {}, clear=True):
rc = wrap.run(["true"])
self.assertEqual(rc, 0)
start.assert_called_once()
stop.assert_called_once()
def test_propagates_game_failure(self):
with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=None), \
mock.patch("rigdoctor.core.diagnostic.start", return_value=123), \
mock.patch("rigdoctor.core.reccontrol.stop_background"), \
mock.patch.dict("os.environ", {}, clear=True):
self.assertEqual(wrap.run(["false"]), 1)
def test_does_not_touch_an_existing_capture(self):
with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=999), \
mock.patch("rigdoctor.core.diagnostic.start") as start, \
mock.patch("rigdoctor.core.reccontrol.stop_background") as stop, \
mock.patch.dict("os.environ", {}, clear=True):
rc = wrap.run(["true"])
self.assertEqual(rc, 0)
start.assert_not_called()
stop.assert_not_called()
def test_empty_command_is_usage_error(self):
self.assertEqual(wrap.run([]), 2)
if __name__ == "__main__":
unittest.main()