Compare commits

...

87 Commits

Author SHA1 Message Date
jessey fb468e83c2 Merge pull request 'feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0' (#43) from feat/displays into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #43
2026-05-22 14:56:15 +00:00
jessey b006fa6b8d feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
New core/displays.py reads connected monitors via GNOME Mutter DisplayConfig over
D-Bus (busctl --json; works on X11 + Wayland), falling back to xrandr on other X11
desktops. Inventory's Display section now lists each monitor's resolution + current
refresh (e.g. 'DP-1 · Samsung LC34G55T: 3440x1440 @ 165 Hz'). System Health
(check_displays) flags a monitor running below its max refresh AT THE CURRENT
resolution (e.g. 165 Hz panel set to 60 Hz) — never suggests lowering resolution.
+tests (Mutter JSON + xrandr parsers, health check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:55:33 +02:00
jessey b20e8dfc3a Merge pull request 'docs: public apt setup — one-line source with arch=all (no token, no notices)' (#42) from docs/public-registry into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #42
2026-05-22 14:52:02 +00:00
jessey 9fe9a6576f Merge pull request 'feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0' (#41) from feat/inventory-pcie into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #41
2026-05-22 14:51:12 +00:00
jessey 07bc722209 feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
check_pcie_links() warns when an NVMe drive negotiates fewer lanes than it
supports — almost always motherboard lane-sharing (a GPU/second card or another
M.2 stealing lanes), the case the user asked about — and reports speed-only
reductions as info (slower slot / idle ASPM). GPU is excluded: NVIDIA drops its
PCIe gen+width at idle, so a snapshot would false-alarm. Reuses inventory
read_link/nvme_controllers (refactored to public). Wired into run_health_checks;
+tests. Folded into the 0.38.0 PCIe work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:49:47 +02:00
jessey d405bf7caf Merge pull request 'feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0' (#40) from feat/inventory-pcie into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #40
2026-05-22 14:45:46 +00:00
jessey 9bb0f9a684 feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
Each NVMe drive's Inventory entry now shows its negotiated PCIe link (e.g.
'· PCIe Gen4 x4') from sysfs (current/max link speed+width), and flags drives
running below their capability ('Gen3 x4 (capable of Gen4 x4)') — so you can
confirm a Gen4 SSD is in a Gen4 slot. SATA disks show no PCIe link. Renders in
the GUI Inventory, CLI, and the Markdown/JSON export automatically. +tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:45:08 +02:00
jessey 4bbc0fa97e Merge pull request 'docs: add Dashboard/Inventory/Share screenshots to the README' (#39) from docs/readme-screenshots into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #39
2026-05-22 14:43:13 +00:00
jessey a0f8055328 docs: add Dashboard/Inventory/Share screenshots to the README
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 26s
Adds assets/screenshots/{dashboard,inventory,share}.png and a Screenshots section
(Dashboard + Inventory side by side, Share below).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:47 +02:00
jessey 323451428b Merge pull request 'fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1' (#38) from fix/updater-by-install into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #38
2026-05-22 14:40:19 +00:00
jessey 479189ee4e fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
rigdoctor update assumed a pip/venv install and ran 'python -m pip install', which
fails on a .deb (system python has no pip; you can't pip-upgrade a dpkg package).
Add updates.install_kind() (dpkg ownership / venv / source-checkout detection,
cached) and route apply_update: pip self-updates in place; apt and source installs
return guidance instead. CLI and the GUI Update button show the apt/git command.
Adds tests/test_updates.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:39:42 +02:00
jessey 51133e4042 Merge pull request 'feat(gui): scrollable pages + version footer — 0.37.0' (#37) from fix/scrollable-pages into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #37
2026-05-22 14:29:56 +00:00
jessey bcf6ac2656 feat(gui): scrollable pages + version footer — 0.37.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 31s
Wrap each page (except self-scrolling Dashboard/Health/Inventory and the Share
terminal) in a QScrollArea, so long pages scroll when too tall (Settings'
Uninstall is reachable again) and the window is no longer pinned to the tallest
page's height — min height drops from >screen to ~600px, so it can be resized
smaller. Add a bottom footer showing 'RigDoctor v<version>' bottom-right (moved
out of the sidebar); themed #Footer with a top border.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:29:14 +02:00
jessey 81c7757546 docs: public apt setup — one-line source with arch=all (no token, no notices)
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 27s
Registry is public now: drop the token/auth.conf.d, use the signed-by one-line
source with arch=all so apt doesn't emit 'doesn't support architecture amd64'
notices (our package is Architecture: all). Drop the curl|sh bootstrap idea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:05:03 +02:00
jessey d59261f021 Merge pull request 'docs: registry is public now — drop the token/auth.conf.d from apt setup' (#36) from docs/public-registry into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #36
2026-05-22 13:58:13 +00:00
jessey 44923b771a docs: registry is public now — drop the token/auth.conf.d from apt setup
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
REQUIRE_SIGNIN_VIEW is off and the repo is public, so anonymous apt works. The
apt instructions no longer need a read:package token or auth.conf.d — just the
signing key + a deb822 Signed-By source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:57:40 +02:00
jessey eaaf14c58a Merge pull request 'fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1' (#35) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #35
2026-05-22 13:49:28 +00:00
jessey 7779131cf9 Merge branch 'main' into docs/apt-proper
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
2026-05-22 13:48:36 +00:00
jessey 87fa678ccb fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 26s
rigdoctor gui suggested 'apt install python3-pyside6' (no such package on
Debian/Ubuntu). Point to the split modules instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:48:20 +02:00
jessey c5e24b3984 Merge pull request 'docs: document the proper (GPG-verified, deb822) apt setup' (#34) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 14s
Reviewed-on: #34
2026-05-22 13:46:10 +00:00
jessey 21cc6a4813 docs: document the proper (GPG-verified, deb822) apt setup
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
Replace the trusted=yes apt instructions with the proper method: read:package
token, registry signing key dearmored into /etc/apt/keyrings, credentials in
auth.conf.d, and a modern deb822 .sources file with Signed-By + Architectures:
all. Keeps the trusted=yes one-liner as a noted fallback for unsigned registries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:44:41 +02:00
jessey ee73049248 Merge pull request 'fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0' (#33) from fix/deb-pyside6-deps into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #33
2026-05-22 13:39:01 +00:00
jessey 3a8ad5bd5d fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
The old Recommends named python3-pyside6 (no such package on Debian/Ubuntu —
PySide6 is split per module), so apt skipped it and the GUI couldn't start.
Now Recommends the real modules (python3-pyside6.qt{widgets,gui,websockets,svg}
+ python3-pyte) AND the optional diagnostic/gaming tools (smartmontools,
lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud), so 'apt install rigdoctor' sets up the whole toolset automatically —
no manual installs. cpupower -> Suggests. Verified all candidates resolve in apt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:38:12 +02:00
jessey e8b84bf046 Merge pull request 'docs: rewrite README to be user-first (install + use)' (#32) from docs/readme-users into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #32
2026-05-22 13:32:41 +00:00
jessey 2342dd83aa docs: rewrite README to be user-first (install + use)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
Lead with what RigDoctor does, then install (.deb/apt incl. the private-registry
auth.conf.d + trusted=yes notes, and the .run), then usage (GUI/tray/CLI),
requirements, and privacy. Move the dev content (from-source, tests, docs links)
into a short Development section at the end. Drops the stale status/decisions/
repo-layout planning sections from the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:31:36 +02:00
jessey a028fe6d38 Merge pull request 'ci: make apt registry upload idempotent (tolerate 409)' (#31) from fix/apt-409 into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #31
2026-05-22 13:26:47 +00:00
jessey a6453335e9 ci: make apt registry upload idempotent (tolerate 409)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
Gitea's Debian registry is immutable, so re-uploading an existing version returns
409. With --fail that aborted the release on any re-run / repeat push at the same
version. Now we capture the HTTP code: 2xx = uploaded, 409 = already published
(skip), anything else = fail with the body. Also fixed the stale skip message
(REGISTRY_TOKEN, not PACKAGES_TOKEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:21:27 +02:00
jessey baec47dd4e Merge pull request 'assets: project avatar (gauge + heartbeat) for Gitea' (#30) from chore/avatar into main
release / test (push) Successful in 12s
release / release (push) Failing after 15s
Reviewed-on: #30
2026-05-22 13:18:59 +00:00
jessey 47ecb702e7 Merge branch 'main' into chore/avatar
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
2026-05-22 13:17:28 +00:00
jessey 944945ce72 Merge pull request 'feat(m9): .deb package + CI build/publish — 0.35.0' (#29) from feat/deb-packaging into main
release / test (push) Successful in 13s
release / release (push) Successful in 19s
Reviewed-on: #29
2026-05-22 13:17:19 +00:00
jessey dc719f6a89 assets: project avatar (gauge + heartbeat) for Gitea
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
512x512 PNG (assets/avatar.png) rendered from assets/avatar.svg, matching the app
icon's gauge-ring + heartbeat motif on a dark gradient. Upload as the repo avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:16:58 +02:00
jessey 78cd417d0b feat(m9): .deb package + CI build/publish — 0.35.0
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 28s
packaging/make_deb.py builds rigdoctor_<ver>_all.deb (Architecture: all) via
dpkg-deb, no debhelper: Depends python3; Recommends python3-pyside6/pyte (GUI by
default, --no-install-recommends = CLI only). Installs the package, both
launchers, desktop entry + icon; postinst refreshes the desktop database.
release.yml builds it as a release asset and optionally pushes to the Gitea apt
registry (REGISTRY_TOKEN). Verified locally: valid .deb, packaged launcher runs
'rigdoctor --version'. Docs/README/ROADMAP/MODULES updated; M9 complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:15:33 +02:00
jessey 856a3305ad Merge pull request 'feat(m8): event-based alerts — Xid/OOM/MCE/PCIe/disk from the kernel log — 0.34.0' (#28) from feat/event-alerts into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #28
2026-05-22 12:48:41 +00:00
jessey 3b1a2e7393 Merge branch 'feat/event-alerts' of ssh://jesseyvanofferen.com:2222/jessey/rigdoctor into feat/event-alerts
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 26s
2026-05-22 14:42:53 +02:00
jessey 2989e8e23e ci: run tests.yml on pull_request only (no push) to avoid double runs
A branch with an open PR triggered both the push and pull_request events, running
every job twice. Trigger on pull_request only; pushes to main are already tested
by release.yml's `test` job. No version bump (CI config only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:42:41 +02:00
jessey 670df23e06 Merge branch 'main' into feat/event-alerts
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 26s
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 26s
2026-05-22 12:41:34 +00:00
jessey 2ee7763d00 feat(m8): event-based alerts — Xid/OOM/MCE/PCIe/disk from the kernel log — 0.34.0
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 27s
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 26s
AlertMonitor now scans the kernel log (journalctl -k) every ~30s and fires
one-shot, cooldown-gated desktop alerts on critical events: NVIDIA Xid, OOM
kills, CPU machine-checks, PCIe AER, and disk I/O errors — so users are warned
the moment something goes wrong, not only on a temperature threshold. Disk I/O
errors come from the kernel log (no root needed, unlike smartctl). Edge/spam
protection reuses the existing cooldown model. syslogs.scan_critical() does the
matching; init seeds last-scan to "now" so old boot logs don't alert on launch.
Tests for the matcher + monitor gating/cooldown; Settings note updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:41:13 +02:00
jessey bd6cad5a42 Merge pull request 'feat(ai): stream explanations live (Ollama NDJSON + Claude SSE) — 0.33.0' (#27) from feat/syslogs into main
release / test (push) Successful in 12s
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 25s
release / release (push) Successful in 15s
Reviewed-on: #27
2026-05-22 12:35:11 +00:00
jessey 7fa9b63661 Merge branch 'main' into feat/syslogs
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 25s
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 28s
2026-05-22 12:28:59 +00:00
jessey c443a8b9f8 ci: add tests workflow + gate releases on tests passing
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 38s
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
- .gitea/workflows/tests.yml: run `unittest discover` on push + pull_request.
  `core` job (stdlib install, GUI tests skip) is bulletproof; `gui-smoke` job
  installs the GUI extra + offscreen Qt libs and runs the suite headless.
- release.yml: add a `test` job and `release: needs: test` so a push to main
  can't publish if the tests fail.

No version bump — CI config only; nothing in the shipped app changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:26:47 +02:00
jessey bbc22fa288 feat(ai): stream explanations live (Ollama NDJSON + Claude SSE) — 0.33.0
ai.explain_stream(findings_text, on_chunk) streams token deltas and returns
(ok, full_text). Ollama: stream=True NDJSON; Claude: stream=True SSE (parse
content_block_delta text deltas). The diagnostic dialog opens an explanation
window immediately and fills it token-by-token via a _chunk signal, then
re-renders the finished answer as Markdown — no more multi-second freeze on a
local model. Non-streaming explain() kept for the CLI. Tests for both parsers;
verified live against qwen2.5:7b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:23:15 +02:00
jessey 5502251789 Merge pull request 'feat(m15): collect session-scoped system logs (kernel + coredumps) — 0.31.0' (#26) from feat/syslogs into main
release / release (push) Successful in 15s
Reviewed-on: #26
2026-05-22 12:16:52 +00:00
jessey 4bd51a40c3 feat(m15): nvidia-smi snapshot + display logs + inventory in reports — 0.32.0
Expand diagnostic/report collection (all stored per-diagnostic, in the Report zip;
logs also fed to the AI on "Explain"):
- syslogs: nvidia-smi -q snapshot (driver/throttle/clocks/power/temps/PCIe/ECC/
  retired pages) + display-server log auto-detected — Xorg.0.log on X11, or the
  compositor user-journal slice (gnome-shell/kwin/sway/gamescope) on Wayland.
- diagstore: include the full M5 inventory (inventory.txt + .json) — invaluable
  for larger/shared debugging. inventory.collect() degrades gracefully (no root
  prompt). Best-effort throughout.
- Tests for nvidia/display + inventory in store; docs (M15/SPEC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:16:23 +02:00
jessey 984292c368 feat(m15): collect session-scoped system logs (kernel + coredumps) — 0.31.0
core/syslogs.py gathers, scoped to the diagnostic window:
- kernel-log slice (journalctl -k): Xid, OOM, MCE, PCIe AER, thermal, hung tasks
- crashed-process records (coredumpctl): exe, signal, when
Stored as syslogs.txt in the diagnostic dir, included in the Report bundle, and
fed to the AI on "Explain" alongside the game logs. Best-effort (degrades if the
tools are missing/denied); treats journalctl's "-- No entries --" as empty.
Tests + docs (M15/SPEC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:30 +02:00
jessey bffaf73ad4 Merge pull request 'fix(ai): analyse the actual session, not stale/benign logs — 0.28.1' (#25) from feat/m14-ai into main
release / release (push) Successful in 15s
Reviewed-on: #25
2026-05-22 11:57:03 +00:00
jessey 7f0ab9a635 feat(m15): opt-in logging + per-diagnostic storage + Report bundles — 0.30.0
One `logging_enabled` toggle (default off) gates everything (D25):
- core/applog.py: rotating app.log (no-op unless enabled); setup() at GUI/CLI start.
- core/diagstore.py: each diagnostic stored in DATA_DIR/diagnostics/<id>/ (capture,
  result.json, report.txt, scoped gamelogs, ai/ records of exactly what was sent to
  the model + which model + the reply). make_report() zips a diagnostic (+ app.log)
  into DATA_DIR/reports/.
- diagnostic.finish()/analyze_crash() store when enabled; DiagnosticResult.dir.
- GUI: Settings → Logging toggle; "Report" button on the diagnostic dialog; AI
  interactions recorded into the diagnostic dir on "Explain with AI".
- CLI: `rigdoctor bundle` (report is taken by the M4 health report).
- Tests for store/record_ai/make_report + applog gating; docs (D25, M15, Phase 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:56:31 +02:00
jessey 12339c3282 feat(ai): resolve Steam app IDs from the library, don't make the model guess — 0.29.0
The model guessed "Rainbow Six Siege" for appID 2694490 (Path of Exile 2). We
already know the names locally, so ground it: steam.appid_names() maps appid→name
from the scanned library, and ai.build_prompt scans the text for app IDs and
injects a resolved glossary. Only locally-known IDs are listed; no network, no
fine-tuning. Tests + verified live (2694490 = Path of Exile 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:34 +02:00
jessey c7e50ba4cb fix(ai): analyse the actual session, not stale/benign logs — 0.28.1
The user ran a game ~20s with no crash but the AI dredged up old log lines,
guessed the wrong game, and gave Windows advice. Fixes:
- Prompt now includes the real game name + capture duration + outcome (clean vs
  crash), so the model uses the known game instead of guessing from log paths.
- gamelogs.collect(since=…): scope Steam-console lines by timestamp and skip a
  stale per-app Proton log (mtime before the session) — no unrelated past run.
- ai_knowledge: flag benign Steam/Proton lines (libnvidia-ml.so.1 assertion,
  routine minidumps, "fork without exec") as non-causal.
- System prompt: Linux-only steps (no "run as administrator"); don't manufacture
  a problem on a clean run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:38:19 +02:00
jessey a3caabc0d5 Merge pull request 'feat(ai): pre-fill qwen2.5:7b when Ollama is selected — 0.27.1' (#24) from feat/m14-ai into main
release / release (push) Successful in 14s
Reviewed-on: #24
2026-05-22 11:32:59 +00:00
jessey b59f202891 feat(ai): render Markdown + feed game/Proton/Steam logs to the AI — 0.28.0
1) The explanation popup rendered raw Markdown (### / **). Switched to
   QTextEdit.setMarkdown and told the model to answer in Markdown.
2) On "Explain with AI", also collect recent Proton (~/steam-*.log) and Steam
   console logs (core/gamelogs.py — tail-read, size-bounded) and include them in
   the prompt so the model can correlate log errors with findings and pinpoint
   when things went wrong. Reference-fact matching runs over the logs too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:32:51 +02:00
jessey e6d94fbd59 feat(ai): pre-fill qwen2.5:7b when Ollama is selected — 0.27.1
Selecting the Ollama provider pre-fills the model field with the suggested
qwen2.5:7b (fits an 8 GB GPU at Q4; grounding makes a 7B sufficient). Won't
overwrite a model the user already typed. Constant ai.OLLAMA_SUGGESTED_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:25:04 +02:00
jessey 045f40c4de Merge pull request 'feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0' (#23) from feat/m14-ai into main
release / release (push) Successful in 14s
Reviewed-on: #23
2026-05-22 11:19:30 +00:00
jessey 2ff4056d89 feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0
New optional module (D24): explains the collected findings in plain language,
contacted ONLY on an explicit user action (never automatic).

- core/ai.py: provider chosen explicitly (no default) — ollama (local) or claude
  (Anthropic Messages API via stdlib urllib; key in keyring). Grounded prompt;
  HTTP error parsing; one-shot (no thinking/caching — snappy).
- core/ai_knowledge.py: curated reference KB (Xid/SMART/Proton/tunables),
  exact keyword/code match ("RAG-lite", no embeddings) injected into the prompt —
  lifts local models, sharpens Claude. No fine-tuning.
- config: ai_provider/ai_model/ai_endpoint + keyring-backed AI key (generalized
  the token keyring helpers).
- GUI: Settings → AI assistant (provider radios, model/endpoint/key, Save/Test);
  "Explain with AI" button on the diagnostic dialog (consent prompt for cloud).
- CLI: `rigdoctor ai status|test|explain`.
- Docs: D24, SPEC/MODULES/ROADMAP (Phase 7); tests for providers/grounding/parse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:19:11 +02:00
jessey 2fe03269e4 Merge pull request 'fix(gui): style radio buttons + checkbox states in the setup wizard — 0.26.1' (#22) from feat/share-terminal into main
release / release (push) Successful in 14s
Reviewed-on: #22
2026-05-22 08:28:34 +00:00
jessey ac2a3981fc fix(gui): style radio buttons + checkbox states in the setup wizard — 0.26.1
QRadioButton was unstyled, so the selected trigger option was invisible on the
dark theme. Added QRadioButton::indicator styling (accent ring + center dot when
checked) and explicit QCheckBox :checked/:disabled states. Bundle checkboxes stay
selectable even when already installed so the page isn't dead when all deps are
present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:27:44 +02:00
jessey 2684e5c8ab Merge pull request 'feat(m9): graphical first-run setup wizard — 0.26.0' (#21) from feat/share-terminal into main
release / release (push) Successful in 13s
Reviewed-on: #21
2026-05-22 08:18:32 +00:00
jessey 4386838b69 feat(m9): graphical first-run setup wizard — 0.26.0
The full installer experience as a GUI wizard (gui/setup_wizard.py): environment
summary → pick dependency bundles (from the catalog, grouped) → install missing
apt packages → choose recording trigger → readiness summary.

- Shown on first launch (config setup_done) and via `rigdoctor-gui --setup`;
  re-runnable from Settings → Run setup wizard.
- install.sh launches it after a fresh install when a desktop session is present.
- catalog.by_bundle() groups components; config gains setup_done.
- Tests: by_bundle grouping + wizard construction smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:13:51 +02:00
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
76 changed files with 6291 additions and 872 deletions
+39
View File
@@ -11,7 +11,20 @@ on:
branches: [main] branches: [main]
jobs: jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (core only)
run: python -m pip install -e .
- name: Run tests
run: python -m unittest discover -s tests -v
release: release:
needs: test # don't publish a release if the tests fail
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -30,6 +43,9 @@ jobs:
- name: Build self-extracting installer (.run) - name: Build self-extracting installer (.run)
run: python packaging/make_run.py run: python packaging/make_run.py
- name: Build .deb
run: python packaging/make_deb.py
- name: Read version - name: Read version
id: ver id: ver
run: | run: |
@@ -90,3 +106,26 @@ jobs:
"${API}/releases/${rid}/assets?name=$(basename "$f")" >/dev/null "${API}/releases/${rid}/assets?name=$(basename "$f")" >/dev/null
done done
echo "Published ${TAG}." echo "Published ${TAG}."
- name: Publish .deb to the Gitea apt registry (optional — needs REGISTRY_TOKEN)
env:
PKG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "${PKG_TOKEN:-}" ]; then
echo "REGISTRY_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
exit 0
fi
OWNER="${{ github.repository_owner }}"
URL="${{ github.server_url }}/api/packages/${OWNER}/debian/pool/stable/main/upload"
for f in dist/*.deb; do
echo "Uploading $(basename "$f") to the apt registry…"
code=$(curl -sS -o /tmp/apt_upload.txt -w '%{http_code}' \
--user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL" || true)
case "$code" in
2*) echo " uploaded ($code)";;
409) echo " already published ($code) — skipping (registry versions are immutable)";;
*) echo " upload failed ($code):"; cat /tmp/apt_upload.txt || true; exit 1;;
esac
done
echo "apt source: deb ${{ github.server_url }}/api/packages/${OWNER}/debian stable main"
+44
View File
@@ -0,0 +1,44 @@
name: tests
run-name: Run test suite
# Runs the unittest suite on pull requests (once per PR). Pushes to main are covered by the
# `test` job in release.yml, so we don't trigger on push here — that would double every run.
# Two jobs:
# core — stdlib-only install; the GUI tests skip (@skipUnless HAVE_QT). Bulletproof.
# gui-smoke — installs the GUI extra + offscreen Qt libs and runs the same suite headless,
# exercising the MainWindow/SetupWizard/DiagnosticDialog construction tests.
# Make `tests / core (pull_request)` a required status check on `main` so a PR can't merge red.
on:
pull_request:
jobs:
core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (core only — no PySide6)
run: python -m pip install -e .
- name: Run tests (GUI tests skip without PySide6)
run: python -m unittest discover -s tests -v
gui-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: System libraries for offscreen Qt
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0
- name: Install (with GUI extra)
run: python -m pip install -e ".[gui]"
- name: Run tests (headless)
env:
QT_QPA_PLATFORM: offscreen
run: python -m unittest discover -s tests -v
+339
View File
@@ -5,6 +5,345 @@ 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.39.0] - 2026-05-22
### Added
- **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its
resolution and current/max refresh — e.g. `DP-1 · Samsung LC34G55T → 3440x1440 @ 165 Hz`. Reads
GNOME's Mutter `DisplayConfig` over D-Bus (works on X11 *and* Wayland), falling back to `xrandr`
on other X11 desktops.
- **System Health flags monitors below their max refresh.** If a monitor supports a higher refresh
at its current resolution (e.g. a 165 Hz panel set to 60 Hz — an easily-missed gaming setting),
Health reports it with the fix (raise it in Display settings). Max is computed at the *current*
resolution, so it never suggests dropping resolution.
## [0.38.0] - 2026-05-22
### Added
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
model — e.g. `Samsung SSD 980 PRO 1TB (931.5G) · PCIe Gen4 x4` — read from sysfs
(`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2
slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you
can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.)
- **System Health flags downtrained NVMe links.** A new check warns when an NVMe drive negotiates
fewer PCIe lanes than it supports (almost always motherboard **lane-sharing** — a GPU/second
card or another M.2 stealing lanes) and notes speed-only reductions as info (a slower slot or
idle ASPM). The GPU is deliberately excluded — NVIDIA drops its PCIe gen/width at idle, so a
snapshot would false-alarm.
## [0.37.1] - 2026-05-22
### Fixed
- **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects
apt (`.deb`), pip (venv/`.run`), or source installs (`updates.install_kind()`); only pip
installs self-update in place. An apt install no longer fails with "No module named pip" —
it (and the GUI Update button) shows `sudo apt update && sudo apt install --only-upgrade
rigdoctor`; a source checkout points to `git pull`.
## [0.37.0] - 2026-05-22
### Added
- **Version footer** — a footer across the bottom of the window shows `RigDoctor v<version>` in
the bottom-right (moved out of the sidebar).
### Fixed
- **Pages scroll when content doesn't fit, and the window is no longer pinned to the tallest
page's height.** Long pages (Settings, Tuning, …) get a scrollbar when too tall — so controls
like Uninstall are always reachable — and the window can now be resized smaller than the screen
(min height dropped from "taller than the screen" to ~600px). Pages that manage their own
scroll/fill (Dashboard, System Health, Inventory, Share) are unchanged.
## [0.36.1] - 2026-05-22
### Fixed
- `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent
`python3-pyside6` package. Now it names the real split modules
(`python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`).
## [0.36.0] - 2026-05-22
### Fixed
- **`.deb` now installs all dependencies automatically — no manual tool install.** The previous
`Recommends: python3-pyside6` named a package that doesn't exist on Debian/Ubuntu (PySide6 is
split per module), so apt silently skipped it and the GUI wouldn't start. Now it Recommends the
actual modules the GUI imports — `python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`.
### Changed
- **`apt install rigdoctor` sets up the whole toolset.** The `.deb` also Recommends the optional
diagnostic/gaming tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin,
libsecret-tools, gamemode, mangohud) so they install by default — users never hand-install
tools. `cpupower` is a Suggests (kernel-tied); `--no-install-recommends` still gives CLI-only.
## [0.35.0] - 2026-05-22
### Added
- **`.deb` package (M9 / D8)** — `packaging/make_deb.py` builds a `rigdoctor_<version>_all.deb`
(pure-Python, `Architecture: all`) via `dpkg-deb`: `Depends: python3`, with the GUI deps
(`python3-pyside6`, `python3-pyte`) as **Recommends** so `sudo apt install ./rigdoctor_*.deb`
gives the full app and `--no-install-recommends` gives CLI-only. Installs the package, both
launchers, the desktop entry, and the icon. CI (`release.yml`) builds it as a **release asset**
every release, and optionally publishes it to the Gitea **apt registry** (set a `REGISTRY_TOKEN`
secret) for `sudo apt install rigdoctor`. **M9 is now complete.**
## [0.34.0] - 2026-05-22
### Added
- **Event-based alerts (M8).** Beyond temperature + GPU-lost, RigDoctor now notifies on
**critical kernel events** — Xid (GPU error), out-of-memory kills, CPU machine-checks, PCIe
AER errors, and disk I/O errors — scanned from the kernel log every ~30s while monitoring and
fired one-shot (cooldown-gated, so no spam). A proactive warning the moment something goes
wrong, not just on a temperature threshold. Included whenever desktop notifications are on.
## [0.33.0] - 2026-05-22
### Added
- **AI explanations stream live.** "Explain with AI" now fills token-by-token as the model
generates (Ollama NDJSON + Claude SSE, both via stdlib `urllib`) instead of a multi-second
freeze, then re-renders the finished answer as Markdown. `core/ai.explain_stream()`.
## [0.32.0] - 2026-05-22
### Added
- **More for diagnostics & reports:**
- **`nvidia-smi -q` snapshot** — driver, throttle/clock-event reasons, clocks, power, temps,
PCIe link, ECC + retired pages (point-in-time at diagnostic time).
- **Display-server log** — auto-detected: `Xorg.0.log` on X11, or the compositor's user-journal
slice (gnome-shell/kwin/sway/gamescope) on Wayland.
- **Full system inventory** (M5 hardware/OS) is now included in each stored diagnostic and the
**Report** bundle — invaluable for larger/shared debugging.
These join the kernel log + coredump records in `syslogs.txt`/`inventory.*`, are saved per
diagnostic, included in the Report zip, and (logs) fed to the AI on "Explain".
## [0.31.0] - 2026-05-22
### Added
- **Diagnostics now collect session-scoped system logs** (`core/syslogs.py`): a kernel-log
slice (`journalctl -k` — Xid, OOM-killer, MCE, PCIe AER, thermal, hung tasks) and
**crashed-process records** (`coredumpctl` — which executable, signal, and when). They're saved
to the diagnostic directory (`syslogs.txt`), included in the **Report** bundle, and fed to the
AI on "Explain" alongside the game logs. Best-effort — degrades quietly if the tools are
missing or access is denied; scoped to the session window so it doesn't drag in old noise.
## [0.30.0] - 2026-05-22
### Added
- **Logging & report bundles (M15, D25)** — opt-in via one **Settings → Logging** toggle
(default off). When on: the app logs to a rotating `app.log`, and **each diagnostic is stored
in its own folder** (`~/.local/share/rigdoctor/diagnostics/<id>/`) with the capture log, a
structured `result.json`, a readable `report.txt`, a session-scoped game-log snapshot, and an
`ai/` record of every AI interaction — **the exact data sent, which model, and its reply**.
- **Report** — a button on the diagnostic dialog (and `rigdoctor bundle`) zips a diagnostic's
folder plus `app.log` into `~/.local/share/rigdoctor/reports/<id>.zip` for sharing. Everything
stays local; the zip only leaves your machine if you share it. Available only when logging is on.
## [0.29.0] - 2026-05-22
### Added
- **AI now resolves Steam app IDs from your library instead of guessing.** When app IDs appear
in the logs/findings, RigDoctor looks them up in your scanned games (`steam.appid_names()`) and
injects an "App IDs (resolved from your installed games)" glossary into the prompt — so the
model names games correctly (e.g. `2694490 = Path of Exile 2`) rather than hallucinating. Only
IDs it can resolve locally are listed; no network, no model "training" needed.
## [0.28.1] - 2026-05-22
### Fixed
- **AI explanations were misreading stale/benign logs.** Three fixes so the model analyses the
*actual* session: (1) the prompt now states the **real game name, capture duration, and
outcome** (clean vs. crash) so the model stops guessing the game from log paths; (2) game logs
are **scoped to the session window** (Steam-console lines filtered by timestamp; a stale
per-app Proton log from an earlier game is skipped); (3) the reference KB flags common
**benign** Steam/Proton lines (`libnvidia-ml.so.1` assertion, routine minidump uploads, "fork
without exec") so they aren't reported as the cause. The system prompt also forbids
Windows-only advice (no "run as administrator") and tells the model not to invent a problem
when the run was clean.
## [0.28.0] - 2026-05-22
### Added
- **AI explanations now include recent game logs.** When you press "Explain with AI" on a
diagnostic, RigDoctor also gathers recent **Proton** (`~/steam-<appid>.log`) and **Steam**
console logs (`core/gamelogs.py`, tail-read + size-bounded) and passes them to the model, so
it can correlate log errors with the sensor findings and pinpoint *when* something went wrong.
### Fixed
- The AI explanation popup now **renders Markdown** (headings, bold, lists) instead of showing
raw `###`/`**``QTextEdit.setMarkdown`, and the model is told to answer in Markdown.
## [0.27.1] - 2026-05-22
### Changed
- AI assistant: selecting **Ollama** now pre-fills the model field with **`qwen2.5:7b`** (a
strong 7B that fits an 8 GB GPU; our grounding makes a 7B sufficient). It won't overwrite a
model you've already entered, and you can change it freely.
## [0.27.0] - 2026-05-22
### Added
- **AI assistant (M14, D24)** — optional, **strictly opt-in, never automatic**. Explains your
diagnostics in plain language only when you press **"Explain with AI"** on the diagnostic
dialog (or run `rigdoctor ai explain`). You choose a provider explicitly (no default):
**Ollama** (local, private, no key) or **Claude** (Anthropic; key stored in the keyring, with
a consent prompt before any data is sent). Configure in **Settings → AI assistant**.
- Answers are **grounded**: RigDoctor passes the actual findings plus matched reference facts
from a curated knowledge base (`core/ai_knowledge.py` — exact keyword/code match, no
embeddings, stdlib only), so even a small local model gets the domain facts it needs. Stdlib
`urllib` only — no new core dependency. Output is advisory (D9).
- CLI: `rigdoctor ai status|test|explain`.
## [0.26.1] - 2026-05-22
### Fixed
- **Setup wizard contrast.** The **radio buttons** (Recording trigger) were unstyled, so the
selected option was invisible on the dark theme — now styled with a clear accent ring + dot.
Bundle **checkboxes** got explicit checked/disabled states, and stay selectable even when a
bundle is already installed (the page no longer looks dead when everything's present).
## [0.26.0] - 2026-05-22
### Added
- **Graphical setup wizard (M9).** A first-run GUI wizard (`gui/setup_wizard.py`) walks through:
environment summary → pick **dependency bundles** (Diagnostics / Monitoring / Gaming / Updates,
from the component catalog) → install the missing apt packages → choose the **recording
trigger** → a readiness summary. It shows automatically on first launch (until done), is
re-runnable from **Settings → Run setup wizard** or `rigdoctor-gui --setup`, and `install.sh`
launches it after a fresh install when a desktop session is present.
## [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 ## [0.11.0] - 2026-05-22
### Added ### Added
- **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose - **Guided diagnostic session (CLI) — the seed use case, end to end.** `rigdoctor diagnose
+102 -98
View File
@@ -1,132 +1,136 @@
# RigDoctor # RigDoctor
A **modular diagnostics, monitoring, and health-check toolkit for Linux gamers.** **Hardware monitoring & crash diagnostics for Linux gamers.** Live sensors, crash-safe
logging, plain-language health reports, per-game diagnostics, and optional AI explanations —
in a desktop app, a tray applet, or the terminal. Ubuntu/Debian + NVIDIA first.
> **Status:** 🟢 Phase 1 (MVP) complete. The **sensor core (M1)**, **crash-capture logger Linux gaming faults are hard to pin down — GPUs falling off the PCIe bus, black screens
> (M3)**, and **health report (M4)** all work — live `snapshot`/`monitor`, crash-safe `record` mid-game, silent thermal/VRAM throttling, driver/Proton mismatches. The useful data is
> with a post-crash report, and `report` to scan logs/SMART/driver for likely causes. A scattered across `nvidia-smi`, `/sys`, `journalctl`, and SMART, and the readings right before a
> desktop GUI (M10) ties them together (dashboard, recording, health). See `docs/ROADMAP.md`. freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
## Why this exists ## Features
Linux gaming hardware faults are hard to diagnose: GPUs falling off the PCIe bus, the screen - **Live monitoring** — a dark desktop **dashboard** (history graphs + per-subsystem cards), a
suddenly going black mid-game, silent thermal/VRAM throttling, power transients, **tray applet** with at-a-glance status, and a terminal view (`rigdoctor monitor`).
driver/library mismatches, Proton quirks, and CPU governor / power-profile misconfiguration. - **Crash-safe recording** — background logger that `fsync`s every sample, so the state right
The data needed to diagnose them is scattered across `nvidia-smi`, `/sys/class/hwmon`, before a hard freeze survives. Manual, always-on, or auto-start when a game launches.
`journalctl`, SMART, and more — and the most useful readings (the ones right before a hard - **Health report** — scans `journalctl`/SMART/driver for likely causes (Xid, OOM, disk
freeze) are usually lost because nothing flushed them to disk. errors, throttling…) and explains them with suggested fixes.
- **Per-game diagnostics** — pick a game, capture while you play, get a focused report; hard
crashes are detected and analysed on next launch.
- **Gaming tune-ups** — flags risky settings (CPU governor, PCIe ASPM, persistence mode…) with
**one-click, reversible fixes**.
- **Proactive alerts** — desktop notifications on overheating and critical kernel events
(GPU-lost, Xid, out-of-memory, disk I/O).
- **AI explanations** *(optional, opt-in)* — explain a diagnostic in plain language with a
**local model (Ollama)** or **Claude**. Never automatic; only when you press the button.
- **Shareable reports** — zip a diagnostic (logs, inventory, AI transcript) to hand to someone,
or share a live **terminal session** for remote help.
- **Self-updating** — `apt upgrade`, or the in-app updater.
RigDoctor pulls all of that into one modular tool: live monitoring, crash-safe logging, a ## Screenshots
one-shot health report, and an interactive installer that only sets up the modules a given
user actually needs for their hardware.
**Seed use cases:** an RTX 3070 that intermittently "falls off the bus" under heavy GPU load | Dashboard | Inventory |
(Path of Exile on Linux, Escape from Tarkov on Windows), and a monitor going black mid-game. |---|---|
See `docs/SPEC.md` §1. | ![Dashboard — live sensors](assets/screenshots/dashboard.png) | ![Inventory — hardware/OS](assets/screenshots/inventory.png) |
## How you run it **Share** — a read-only or interactive terminal session over the relay, for remote help:
RigDoctor is **GUI-first** — the desktop app is the primary way in — but every feature is ![Share — shared terminal session](assets/screenshots/share.png)
also available headless:
- **Desktop GUI** — graphical dashboard, recording controls, log browser, reports. The
default interface for most users.
- **Tray applet** — a small top-menu-bar applet with quick actions and at-a-glance status.
- **CLI** — full functionality from the terminal; works over SSH and in scripts.
The GUI/tray are optional modules; a headless (CLI-only) install loses no capability. ## Install
## Key decisions (settled) ### Debian / Ubuntu — `.deb`
| Topic | Decision | The simplest path: grab the latest **`rigdoctor_<version>_all.deb`** from the
|-------|----------| [releases page](https://git.jesseyvanofferen.com/jessey/rigdoctor/releases) and install it —
| Name | **RigDoctor** | apt pulls the GUI dependencies (PySide6, pyte) automatically:
| Language / stack | **Python 3 + Qt (PySide6)** — core/CLI/daemon stdlib-only; Qt only for GUI/tray |
| Primary distro | **Ubuntu** (Debian via apt); others best-effort later |
| Primary GPU | **NVIDIA** first; AMD, then Intel later |
| MVP | **Sensor core + crash logger + health report** (NVIDIA-only, CLI-first) |
| Distribution | **User-local install** (self-updating from the public repo, no root); **`.deb`** optional |
| Scope of action | **Read-only + suggestions** (no auto-apply yet) |
| Stress tests | **Out of scope** |
Full rationale and the still-open questions are in `docs/DECISIONS.md`.
## Repo layout
| Path | Purpose |
|------|---------|
| `docs/SPEC.md` | Product specification — vision, requirements, modules (the main planning doc) |
| `docs/ARCHITECTURE.md` | Technical design — core engine, front-ends, daemon, installer |
| `docs/MODULES.md` | Catalog of modules with scope, dependencies, status |
| `docs/ROADMAP.md` | Phased milestones |
| `docs/DECISIONS.md` | Decision log + remaining open questions |
| `src/rigdoctor/` | Source code — `core/` engine + sources, `cli.py`, `render.py` |
| `installer/` | Installer / `.deb` packaging (empty until Phase 4) |
| `tests/` | Tests (stdlib `unittest`) |
## Install (user-local, no root)
RigDoctor installs into a private venv under `~/.local` — no root, self-updating:
```bash ```bash
./install.sh # from a source checkout or the self-extracting .run sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
./install.sh --ref v0.0.6 # install a specific released tag (needs a token)
./install.sh --uninstall # remove it
``` ```
This adds `rigdoctor` / `rigdoctor-gui` to `~/.local/bin` and a desktop entry. Each release **Or add the apt repository** for `apt install` + automatic updates (the registry is public and
also ships a one-file **`.run`** installer (download, `chmod +x`, run). Updates are gated to GPG-signed — no token needed):
accounts on the Git server (a Personal Access Token); save one via the GUI **Setup → Update
access** panel or `rigdoctor login`, then `rigdoctor update` (or the sidebar button).
## Run it (dev)
Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14):
```bash ```bash
PYTHONPATH=src python3 -m rigdoctor snapshot # one-shot sensor read sudo curl https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key -o /etc/apt/keyrings/gitea-jessey.asc
PYTHONPATH=src python3 -m rigdoctor snapshot --json echo "deb [arch=all signed-by=/etc/apt/keyrings/gitea-jessey.asc] https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" | sudo tee /etc/apt/sources.list.d/gitea.list
PYTHONPATH=src python3 -m rigdoctor monitor -n 1 # live view (Ctrl-C to quit) sudo apt update
PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources sudo apt install rigdoctor
PYTHONPATH=src python3 -m unittest discover -s tests
``` ```
### Crash-capture logger (M3) Then `sudo apt upgrade` keeps it current.
A crash-safe background logger (JSONL, `fsync` per sample, bounded by rotation) for catching Then `sudo apt upgrade` keeps it current.
the state right before a freeze:
### Any distro — self-extracting `.run` (no root)
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
into a private virtualenv under `~/.local` (no root), adds the launchers + desktop entry, and
opens the first-run setup wizard:
```bash ```bash
rigdoctor record start # start logging in the background sh rigdoctor-*-installer.run
rigdoctor record status # is it running? latest readings, sample count
rigdoctor record stop # stop it
rigdoctor record report # post-crash summary: peaks, events, last samples
rigdoctor record run # run in the foreground (the systemd-ready entrypoint)
``` ```
Logs live in `~/.local/share/rigdoctor/logs/`. It detects GPU "lost"/hang (nvidia-smi query ### Updating & removing
timeout) and writes an event marker. Trigger modes (always-on / game-launch) and the
`systemd --user` service arrive in Phase 4.
### Desktop GUI (M10) - **`.deb`:** `sudo apt upgrade` (or reinstall a newer `.deb`).
- **`.run` / user-local:** the in-app **Update** button, or `rigdoctor update`.
- **Remove:** `sudo apt remove rigdoctor`, or `rigdoctor uninstall` for the user-local install.
The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep: ## Using it
Launch **RigDoctor** from your app menu, or:
```bash ```bash
pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui` rigdoctor-gui # desktop app (+ tray)
rigdoctor gui # or: rigdoctor-gui rigdoctor --help # everything from the terminal (works over SSH)
``` ```
It opens a dark-themed window with sidebar navigation and a **live dashboard** over the Handy CLI commands:
same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
The **Logs** and **Health** sections are full pages (recording controls + post-crash report;
and the kernel-log / SMART / driver scan). **Inventory** is a placeholder until M5 lands.
Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI. ```bash
rigdoctor snapshot # one-shot reading of every sensor
rigdoctor monitor # live terminal dashboard
rigdoctor report # health report (logs / SMART / driver)
rigdoctor diagnose start|finish # capture while gaming, then analyse
rigdoctor gameenv # flag risky gaming settings + fixes
rigdoctor inventory # hardware/OS inventory
rigdoctor ai explain # AI explanation of the current findings (opt-in)
rigdoctor bundle # zip the latest diagnostic into a shareable report
```
## Start here ## Requirements
1. Read `docs/SPEC.md` for what we're building. - **Linux** — Ubuntu/Debian first-class (the `.deb`); the `.run` works on any distro with
2. Read `docs/ROADMAP.md` for the build order (Phase 1 = the MVP). Python ≥ 3.11.
3. Read `docs/DECISIONS.md` for the settled decisions (D1D15). - **GPU** — NVIDIA fully supported (via `nvidia-smi`); AMD/Intel sensors are best-effort.
</content> - **CLI/daemon** need only Python 3 (stdlib). The **GUI/tray** add **PySide6** (`python3-pyside6`).
- Optional tools unlock more: `smartmontools`, `lm-sensors`, `gamemode`, `mangohud`. The setup
wizard offers to install them.
## Privacy
Everything stays on your machine — no telemetry, no phone-home. The AI assistant is **off by
default** and runs only when you explicitly trigger it; with Ollama nothing leaves the machine,
and the Claude option asks before sending. Reports are local files; they leave only if you share
the zip.
## Development
RigDoctor's core is stdlib-only Python; the GUI/tray use PySide6.
```bash
git clone https://git.jesseyvanofferen.com/jessey/rigdoctor && cd rigdoctor
pip install -e ".[gui]" # core + GUI; omit [gui] for CLI-only
python -m unittest discover -s tests # run the test suite
PYTHONPATH=src python3 -m rigdoctor snapshot # run without installing
```
Design docs live in `docs/``SPEC.md` (vision/requirements), `ARCHITECTURE.md`,
`MODULES.md` (module catalog), `ROADMAP.md`, and `DECISIONS.md` (the decision log).
Contributions: branch off `main`, keep tests green (CI runs them on PRs), and bump the version
+ `CHANGELOG.md` for shipped changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="78%">
<stop offset="0%" stop-color="#1b2230"/>
<stop offset="100%" stop-color="#0d0f13"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- gauge ring -->
<circle cx="256" cy="256" r="168" fill="none" stroke="#2a2f39" stroke-width="28"/>
<!-- accent sweep -->
<path d="M256 88 a168 168 0 1 1 -118.8 49.2" fill="none" stroke="#38bdf8"
stroke-width="28" stroke-linecap="round"/>
<!-- heartbeat / monitoring trace -->
<path d="M120 264 H200 L232 192 L280 336 L312 264 H392" fill="none" stroke="#e6e8eb"
stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+38 -1
View File
@@ -239,9 +239,46 @@ 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.
### D24 — AI assistant module (M14) — *DECIDED 2026-05-22; adds to D14*
A new optional module that **explains the collected diagnostics in plain language** (likely
root cause + suggested next steps). Adds M14 to the D14 set.
- **Strictly opt-in, never automatic.** The model is contacted **only** on an explicit user
action (an "Explain with AI" button / `rigdoctor ai explain`) — never on launch, after a
diagnostic, in the sample/record loop, or in the background. **Configuring** a provider does
not trigger any call.
- **Local-first.** Defaults to a local **Ollama** server (data never leaves the machine, no
key, stdlib `urllib`). An **OpenAI-compatible** endpoint (cloud or local) can be used with a
key (stored in the keyring like the update token). Cloud use shows a "this sends your data to
X" consent before the first call.
- **Grounded & advisory.** The prompt carries only the findings we collected; output is framed
as suggestions (consistent with D9 — it explains/recommends, applying fixes stays
consent-gated). No new runtime dependency (HTTP via stdlib).
### D25 — Logging & report bundles (M15) — *DECIDED 2026-05-22*
Opt-in logging + shareable diagnostic reports.
- **One combined `logging_enabled` toggle** (default off) controls both application logging
(rotating `app.log`) and per-diagnostic storage. Kept as a single switch for simplicity.
- **Each diagnostic is stored in its own directory** (`DATA_DIR/diagnostics/<id>/`): capture
log, structured `result.json`, human-readable `report.txt`, a scoped game-log snapshot, and an
`ai/` folder recording each AI interaction (**exact data sent, provider+model, and the reply**).
- **"Report"** zips one diagnostic directory (plus `app.log`) into `DATA_DIR/reports/` —
auto-saved there (no save dialog), shown with its path. Available only when logging is on
(nothing is stored otherwise). CLI: `rigdoctor bundle`.
- Everything stays local; the report only leaves the machine if the user shares the zip.
## Open ## Open
None currently — all tracked decisions (D1D22) are resolved. New questions will be added None currently — all tracked decisions (D1D25) 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`).
+75 -33
View File
@@ -2,24 +2,27 @@
Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
> Module set per D14, plus **M12 (session sharing, D16)** and **M13 (auto-update, D18)**. > Module set per D14, plus **M12 (session sharing, D16)**, **M13 (auto-update, D18)**,
> **M14 (AI assistant, D24)**, and **M15 (logging & reports, D25)**.
> **M7 (stress/repro) was dropped (D7).** M10/M11 are the GUI and tray modules (D10/D11). > **M7 (stress/repro) was dropped (D7).** M10/M11 are the GUI and tray modules (D10/D11).
> GPU scope reads "all (NVIDIA first)" — NVIDIA first, others via the vendor abstraction (D4). > GPU scope reads "all (NVIDIA first)" — NVIDIA first, others via the vendor abstraction (D4).
| 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 (+ `.deb`) | (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 | |
| M14 | AI assistant (explain diagnostics) | (optional) | none (stdlib urllib; Ollama or Claude) | all | P3 | ✅ |
| M15 | Logging & report bundles | (core) | none (stdlib logging + zip) | all | P3 | ✅ |
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
## Notes per module ## Notes per module
@@ -31,15 +34,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 +64,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 +99,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`
@@ -98,6 +120,25 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no
telemetry), opt-out-able. Surfaced in the GUI; `rigdoctor update` in the CLI. (`.deb` users telemetry), opt-out-able. Surfaced in the GUI; `rigdoctor update` in the CLI. (`.deb` users
update via apt instead.) update via apt instead.)
- **M14 AI assistant** (D24) — optional, **strictly opt-in, never automatic**: explains the
collected diagnostics in plain language only when the user presses **"Explain with AI"**
(`core/ai.py`, GUI button on the diagnostic dialog, `rigdoctor ai explain`). The user picks a
provider explicitly (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic
Messages API, key in the keyring; consent prompt before sending). Answers are **grounded**
we pass the actual findings plus matched reference facts from a curated knowledge base
(`core/ai_knowledge.py`, "RAG-lite": exact keyword/code match, no embeddings, stdlib only),
which lifts a small local model and sharpens Claude. Stdlib `urllib` (no pip deps); output is
advisory (D9). Configure in **Settings → AI assistant**.
- **M15 Logging & report bundles** (D25) — opt-in via one `logging_enabled` toggle (default off):
application logging to a rotating `app.log` (`core/applog.py`) and **per-diagnostic storage**
(`core/diagstore.py`) — each diagnostic gets its own `DATA_DIR/diagnostics/<id>/`: capture,
`result.json`, `report.txt`, the full **inventory** (M5: hardware/OS), scoped **game logs**
(`core/gamelogs.py`), scoped **system logs** (`core/syslogs.py``journalctl -k`,
`coredumpctl`, an `nvidia-smi -q` snapshot, and the X11/Wayland display-server log), and an
`ai/` record of every AI interaction (exact data sent, model, reply). **"Report"** zips one
into `DATA_DIR/reports/` (GUI button on the diagnostic dialog; CLI `rigdoctor bundle`). Logs
are session-scoped and fed to the AI on "Explain". Stays local; shareable on demand.
## Bundles (final — D14) ## Bundles (final — D14)
- **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)* - **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)*
@@ -105,6 +146,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
- **Diagnostics:** M5 + M6 - **Diagnostics:** M5 + M6
- **Desktop UI:** M10 + M11 *(adds PySide6)* - **Desktop UI:** M10 + M11 *(adds PySide6)*
- **Sharing:** M12 *(session sharing / remote assist — D16)* - **Sharing:** M12 *(session sharing / remote assist — D16)*
- **AI:** M14 *(optional AI explanations — D24)*
## MVP candidate — *confirmed (D5)* ## MVP candidate — *confirmed (D5)*
**M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the **M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the
+57 -24
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,26 +34,45 @@ 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
line, Run Diagnostic submenu per game, Open dashboard / Start-Stop recording / Snapshot /
Quit — D13; close-to-tray, `--tray` autostart). Needs a tray host (AppIndicator on GNOME).
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings), - [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
shared by tray/GUI/CLI — *core + CLI done* (`core/diagnostic.py`, `rigdoctor diagnose shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
start/status/finish`): tags a focused capture with the chosen game (own diagnostic log, diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
window-scoped report) and combines the capture summary with the M4 findings. *Pending:* page → recording banner → results dialog with the capture summary + findings). Tags a
the GUI/tray "Run Diagnostic" button, and auto start/stop via the D12 wrapper/watcher. focused capture with the chosen game (own diagnostic log, window-scoped report) and
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first: combines the capture summary with the M4 findings. **Auto start/stop** via the D12
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher wrapper is wired in, and a **hard-crash is detected** (capture left without a clean stop)
(Steam RunningAppID + /proc) and GameMode hook follow) → 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
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI Settings "Recording trigger") incl. the zero-config **game-launch watcher**
(`core/watcher.py`, `rigdoctor watch`); and a **graphical first-run setup wizard**
(`gui/setup_wizard.py`): environment → dependency-bundle selection → install → recording
trigger → readiness, auto-launched by install.sh and re-runnable from Settings; and a
**`.deb`** (`packaging/make_deb.py`, `Architecture: all`, `Depends: python3`,
`Recommends: python3-pyside6/pyte`) built + published in CI (release asset + optional
Gitea apt registry). **M9 complete.**
- [x] `.deb` packaging (D8) — built via `dpkg-deb` (no debhelper); GUI deps as Recommends so
`apt install rigdoctor` includes the Desktop UI, `--no-install-recommends` = CLI only.
## Phase 5 — Breadth (later) ## Phase 5 — Breadth (later)
- [ ] AMD GPU support in M1 (Steam Deck / Radeon) - [ ] AMD GPU support in M1 (Steam Deck / Radeon)
@@ -65,14 +85,27 @@ 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). ## Phase 7 — AI assistant (M14, D24)
- [x] **Explain diagnostics with AI** — opt-in, never automatic (`core/ai.py`, "Explain with AI"
button + `rigdoctor ai explain`). Provider chosen explicitly: **Ollama** (local) or
**Claude** (Anthropic). Grounded with a curated reference KB (`core/ai_knowledge.py`,
RAG-lite, exact match — no embeddings); stdlib `urllib`. Settings → AI assistant.
- [ ] *Possible follow-ups:* interactive chat grounded in the data; more reference-KB entries;
an "Explain" button on the System Health page.
## Phase 8 — Logging & report bundles (M15, D25)
- [x] **Opt-in logging** (one `logging_enabled` toggle): rotating `app.log` (`core/applog.py`)
+ **per-diagnostic storage** in its own directory (`core/diagstore.py`) — capture,
result, report, scoped game logs, and AI-interaction records.
- [x] **Report** bundle — zip a diagnostic (incl. exactly what was sent to the AI, the model,
and its reply) into the reports folder. GUI button + `rigdoctor bundle`.
> **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.
+29 -9
View File
@@ -144,15 +144,35 @@ 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. ### M14 — AI assistant (D24)
Optional module that explains the collected diagnostics in plain language. **Strictly opt-in and
never automatic** — the model is contacted only when the user presses "Explain with AI" (GUI) or
runs `rigdoctor ai explain`; configuring it contacts nothing. The user explicitly chooses a
provider (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic Messages
API, key in the keyring, with a consent prompt before sending data). Answers are **grounded** in
the actual findings plus matched reference facts from a curated, exact-match knowledge base
("RAG-lite" — no embeddings/vector store, stdlib only); no fine-tuning. HTTP via stdlib `urllib`
(no new core dependency); output is advisory (consistent with D9).
### M15 — Logging & report bundles (D25)
Opt-in (one `logging_enabled` toggle, default off). When on: the application logs to a rotating
`app.log`, and **each diagnostic is stored in its own directory** (capture log, structured
result, human-readable report, the full **inventory** (M5 hardware/OS), session-scoped **game
logs** (Proton/Steam) and **system logs** (`journalctl -k`, `coredumpctl`, an `nvidia-smi -q`
snapshot, and the X11/Wayland display-server log), and a record of every AI interaction — the
exact data sent, the model, and its reply). The collected logs are also fed to the AI on
"Explain". Collection is best-effort (degrades if tools are missing/denied). A **Report** action zips one diagnostic's directory
(plus the app log) into a shareable bundle saved under the reports folder (GUI button; CLI
`rigdoctor bundle`). Everything stays local — a report only leaves the machine if the user
shares the zip. Stdlib only (`logging` + `zipfile`).
## 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
+8
View File
@@ -115,3 +115,11 @@ case ":$PATH:" in
*":$BIN_DIR:"*) ;; *":$BIN_DIR:"*) ;;
*) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";; *) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";;
esac esac
# Launch the graphical setup wizard if a desktop session is available (first run shows it).
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && [ -x "$VENV/bin/rigdoctor-gui" ]; then
echo " Opening the setup wizard…"
("$VENV/bin/rigdoctor-gui" --setup >/dev/null 2>&1 &)
else
echo " Run 'rigdoctor-gui' to finish setup."
fi
+121
View File
@@ -0,0 +1,121 @@
"""Build a `.deb` for RigDoctor (M9 / D8) — dependency-light, no debhelper.
Pure-Python app, so it's `Architecture: all`: we stage the package into dist-packages, drop the
two launchers in /usr/bin, install the desktop entry + icon, write a DEBIAN/control, and call
`dpkg-deb`. The core is stdlib (`Depends: python3`); everything else is **Recommends** so a
plain `apt install rigdoctor` sets up the whole toolset automatically (users never hand-install
deps) — the GUI modules (Debian/Ubuntu split PySide6 per module, so we name
`python3-pyside6.qt{widgets,gui,websockets,svg}`) + `python3-pyte`, plus the diagnostic/gaming
tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud). `--no-install-recommends` still yields a CLI-only install; `cpupower` is a Suggests
(kernel-tied/heavy).
Run: `python packaging/make_deb.py` → `dist/rigdoctor_<version>_all.deb`.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
MAINTAINER = "Jessey van Offeren <jjvanofferen@gmail.com>"
HOMEPAGE = "https://git.jesseyvanofferen.com/jessey/rigdoctor"
def _version() -> str:
text = (ROOT / "src" / "rigdoctor" / "__init__.py").read_text(encoding="utf-8")
for line in text.splitlines():
if line.startswith("__version__"):
return line.split('"')[1]
raise SystemExit("could not read __version__")
_LAUNCHER = """\
#!/usr/bin/python3
import sys
from {module} import main
sys.exit(main())
"""
_DESKTOP = """\
[Desktop Entry]
Type=Application
Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec=rigdoctor-gui
Icon=rigdoctor
Terminal=false
Categories=System;Monitor;Utility;
StartupWMClass=rigdoctor
"""
_CONTROL = """\
Package: rigdoctor
Version: {version}
Architecture: all
Maintainer: {maintainer}
Section: utils
Priority: optional
Depends: python3 (>= 3.11)
Recommends: python3-pyside6.qtwidgets, python3-pyside6.qtgui, python3-pyside6.qtwebsockets, python3-pyside6.qtsvg, python3-pyte, smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode, mangohud
Suggests: linux-tools-generic
Homepage: {homepage}
Description: Hardware monitoring & crash diagnostics for Linux gamers
RigDoctor monitors GPU/CPU temperatures, load, and sensors, captures crash
diagnostics while gaming, scans logs (Xid/SMART/kernel) for problems, and can
explain them in plain language. The CLI and background daemon are pure Python
(stdlib only); the optional desktop GUI and system-tray applet use PySide6,
pulled in via Recommends. Install with --no-install-recommends for CLI only.
"""
def _write(path: Path, text: str, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
path.chmod(mode)
def build() -> Path:
version = _version()
DIST.mkdir(exist_ok=True)
stage = DIST / f"rigdoctor_{version}_all"
if stage.exists():
shutil.rmtree(stage)
# Python package → dist-packages (importable system-wide), minus bytecode.
pkg_dst = stage / "usr/lib/python3/dist-packages/rigdoctor"
shutil.copytree(ROOT / "src" / "rigdoctor", pkg_dst,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
# Launchers.
_write(stage / "usr/bin/rigdoctor", _LAUNCHER.format(module="rigdoctor.cli"), 0o755)
_write(stage / "usr/bin/rigdoctor-gui", _LAUNCHER.format(module="rigdoctor.gui.app"), 0o755)
# Desktop entry + icon.
_write(stage / "usr/share/applications/rigdoctor.desktop", _DESKTOP)
icon = ROOT / "src" / "rigdoctor" / "gui" / "assets" / "rigdoctor.svg"
_write(stage / "usr/share/icons/hicolor/scalable/apps/rigdoctor.svg",
icon.read_text(encoding="utf-8"))
# Refresh the desktop database on install/remove (best-effort).
_write(stage / "DEBIAN/postinst",
"#!/bin/sh\nset -e\nupdate-desktop-database -q 2>/dev/null || true\n", 0o755)
_write(stage / "DEBIAN/postrm",
"#!/bin/sh\nset -e\nupdate-desktop-database -q 2>/dev/null || true\n", 0o755)
_write(stage / "DEBIAN/control",
_CONTROL.format(version=version, maintainer=MAINTAINER, homepage=HOMEPAGE))
out = DIST / f"rigdoctor_{version}_all.deb"
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", str(stage), str(out)], check=True)
shutil.rmtree(stage)
return out
if __name__ == "__main__":
path = build()
print(f"built {path}")
sys.exit(0)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.11.0" version = "0.39.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.11.0" __version__ = "0.39.0"
+156 -47
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:
@@ -62,8 +55,9 @@ def cmd_gui(args) -> int:
from .gui.app import main as gui_main from .gui.app import main as gui_main
except ImportError as exc: except ImportError as exc:
print("The GUI needs PySide6, which isn't installed.") print("The GUI needs PySide6, which isn't installed.")
print(" Install it with: pip install 'rigdoctor[gui]'") print(" Ubuntu/Debian: sudo apt install python3-pyside6.qtwidgets "
print(" or on Ubuntu: sudo apt install python3-pyside6") "python3-pyside6.qtgui python3-pyside6.qtwebsockets python3-pyside6.qtsvg python3-pyte")
print(" pip: pip install 'rigdoctor[gui]'")
print(f" ({exc})") print(f" ({exc})")
return 2 return 2
return gui_main([sys.argv[0]]) return gui_main([sys.argv[0]])
@@ -269,6 +263,10 @@ def cmd_update(args) -> int:
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n") print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check: if args.check:
return 0 return 0
kind = updates.install_kind()
if kind != "pip": # apt/source installs aren't pip-updatable — show the right command
print(updates.update_hint(kind))
return 0
print(f"Installing {tag}") print(f"Installing {tag}")
rc, out = updates.apply_update(tag) rc, out = updates.apply_update(tag)
print(out[-2000:]) print(out[-2000:])
@@ -296,12 +294,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
@@ -417,6 +409,91 @@ def cmd_diagnose(args) -> int:
return 0 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_ai(args) -> int:
"""AI assistant (M14) — opt-in; only contacts a provider on `test`/`explain`."""
from .core import ai
sub = args.ai_cmd or "status"
if sub == "status":
print(f"Provider: {ai.provider() or 'not configured'}")
if ai.provider():
print(f" {ai.provider_label()}")
print(f" ready: {'yes' if ai.is_configured() else 'no'}")
else:
print(" Configure it in the GUI: Settings → AI assistant.")
return 0
if not ai.is_configured():
print("AI is not configured. Set it up in the GUI (Settings → AI assistant).")
return 1
if sub == "test":
ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.")
print(msg)
return 0 if ok else 1
# explain: gather the current health findings and ask the provider to explain them.
from .core import health
findings = health.run_health_checks()
text = ai.format_findings(findings)
print(f"Asking {ai.provider_label()} to explain the current health findings…\n")
ok, msg = ai.explain(text)
print(msg)
return 0 if ok else 1
def cmd_bundle(args) -> int:
"""Zip the latest stored diagnostic into a report bundle (M15) — needs logging enabled."""
from .core import diagstore
if not diagstore.enabled():
print("Logging is off. Enable it (Settings → Logging, or set logging_enabled) so "
"diagnostics are stored and can be reported.")
return 1
directory = diagstore.latest_dir()
if directory is None:
print("No stored diagnostics yet — run a diagnostic first.")
return 1
out = diagstore.make_report(directory)
print(f"Report written: {out}")
return 0
def cmd_gameenv(args) -> int: def cmd_gameenv(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -432,34 +509,41 @@ def cmd_gameenv(args) -> int:
def cmd_games(args) -> int: def cmd_games(args) -> int:
from .core import steam from dataclasses import asdict
from .core import launchers, steam
selected = steam.selected_library_paths() selected = steam.selected_library_paths()
if not selected: result = steam.rescan() if selected else None
print("No Steam libraries selected to scan.") steam_games = result.games if result else []
print(" See them with: rigdoctor games libraries") extra = launchers.scan() # non-Steam (Lutris/Heroic)
print(" Then enable one: rigdoctor games libraries --enable <path> (or --all)") all_games = list(steam_games) + list(extra)
return 1
result = steam.rescan()
if args.json:
from dataclasses import asdict
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
@@ -510,8 +594,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)
@@ -565,13 +650,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)")
@@ -605,10 +683,41 @@ def build_parser() -> argparse.ArgumentParser:
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show") diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
diag_finish.set_defaults(func=cmd_diagnose) diag_finish.set_defaults(func=cmd_diagnose)
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10) 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)
ai_p = sub.add_parser("ai", help="AI assistant (M14): explain diagnostics — opt-in, never automatic")
ai_sub = ai_p.add_subparsers(dest="ai_cmd")
ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai)
ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai)
ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai)
ai_p.set_defaults(func=cmd_ai, ai_cmd=None)
bundle_p = sub.add_parser("bundle", help="zip the latest stored diagnostic into a report bundle (M15)")
bundle_p.set_defaults(func=cmd_bundle)
return p return p
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
from .core import applog
applog.setup() # opt-in app logging (M15); no-op unless logging_enabled
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
return args.func(args) return args.func(args)
+83 -37
View File
@@ -26,6 +26,9 @@ LOG_FILE = LOG_DIR / "capture.jsonl"
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on # 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. # crash log, so its report covers only that session's window.
DIAG_LOG = LOG_DIR / "diagnostic.jsonl" 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"
@@ -34,12 +37,23 @@ SPAWN_LOG = STATE_DIR / "recorder.out"
# not config: refreshed by the background scan on every launch). # not config: refreshed by the background scan on every launch).
GAMES_FILE = STATE_DIR / "games.json" GAMES_FILE = STATE_DIR / "games.json"
# Logging & reports (opt-in via `logging_enabled`). App log: rotating file of app events.
# Each diagnostic is stored under DIAGNOSTICS_DIR/<id>/; "Report" zips one into REPORTS_DIR.
APP_LOG = STATE_DIR / "app.log"
DIAGNOSTICS_DIR = DATA_DIR / "diagnostics"
REPORTS_DIR = DATA_DIR / "reports"
# Update access token (M13) — gates updates to Gitea account holders (D18). # Update access token (M13) — gates updates to Gitea account holders (D18).
# Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when # Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when
# available — encrypted at rest, unlocked with the login session — else a 0600 file. # available — encrypted at rest, unlocked with the login session — else a 0600 file.
TOKEN_FILE = CONFIG_DIR / "token" TOKEN_FILE = CONFIG_DIR / "token"
_SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"] _SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"]
# AI assistant (M14, D24) — API key for the Claude provider, stored in the keyring like the
# update token (Ollama is local and needs none). Separate keyring entry + file fallback.
AI_KEY_FILE = CONFIG_DIR / "ai-key"
_AI_SECRET_ATTRS = ["application", "rigdoctor", "type", "ai-key"]
def _secret_tool() -> str | None: def _secret_tool() -> str | None:
return shutil.which("secret-tool") return shutil.which("secret-tool")
@@ -50,27 +64,27 @@ def keyring_available() -> bool:
return _secret_tool() is not None return _secret_tool() is not None
def _keyring_store(token: str) -> bool: def _keyring_store(value: str, attrs: list[str], label: str) -> bool:
tool = _secret_tool() tool = _secret_tool()
if not tool: if not tool:
return False return False
try: try:
proc = subprocess.run( proc = subprocess.run(
[tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS], [tool, "store", "--label", label, *attrs],
input=token, text=True, capture_output=True, timeout=20, input=value, text=True, capture_output=True, timeout=20,
) )
return proc.returncode == 0 return proc.returncode == 0
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
return False return False
def _keyring_lookup() -> str | None: def _keyring_lookup(attrs: list[str]) -> str | None:
tool = _secret_tool() tool = _secret_tool()
if not tool: if not tool:
return None return None
try: try:
proc = subprocess.run( proc = subprocess.run(
[tool, "lookup", *_SECRET_ATTRS], text=True, capture_output=True, timeout=20 [tool, "lookup", *attrs], text=True, capture_output=True, timeout=20
) )
if proc.returncode == 0 and proc.stdout.strip(): if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip() return proc.stdout.strip()
@@ -79,54 +93,67 @@ def _keyring_lookup() -> str | None:
return None return None
def _keyring_clear() -> None: def _keyring_clear(attrs: list[str]) -> None:
tool = _secret_tool() tool = _secret_tool()
if not tool: if not tool:
return return
try: try:
subprocess.run([tool, "clear", *_SECRET_ATTRS], capture_output=True, timeout=20) subprocess.run([tool, "clear", *attrs], capture_output=True, timeout=20)
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
pass pass
def _load_secret(env_var: str | None, attrs: list[str], file: Path) -> str | None:
if env_var:
env = os.environ.get(env_var)
if env and env.strip():
return env.strip()
from_keyring = _keyring_lookup(attrs)
if from_keyring:
return from_keyring
try:
value = file.read_text().strip()
return value or None
except OSError:
return None
def _save_secret(value: str, attrs: list[str], label: str, file: Path) -> None:
value = value.strip()
if _keyring_store(value, attrs, label):
try: # don't leave a plaintext copy once it's in the keyring
file.unlink()
except OSError:
pass
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
file.write_text(value + "\n")
try:
file.chmod(0o600)
except OSError:
pass
def _clear_secret(attrs: list[str], file: Path) -> None:
_keyring_clear(attrs)
try:
file.unlink()
except OSError:
pass
def load_token() -> str | None: def load_token() -> str | None:
"""Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file.""" """Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file."""
env = os.environ.get("RIGDOCTOR_TOKEN") return _load_secret("RIGDOCTOR_TOKEN", _SECRET_ATTRS, TOKEN_FILE)
if env and env.strip():
return env.strip()
from_keyring = _keyring_lookup()
if from_keyring:
return from_keyring
try:
token = TOKEN_FILE.read_text().strip()
return token or None
except OSError:
return None
def save_token(token: str) -> None: def save_token(token: str) -> None:
"""Save to the OS keyring if possible (encrypted); else a 0600 file.""" """Save to the OS keyring if possible (encrypted); else a 0600 file."""
token = token.strip() _save_secret(token, _SECRET_ATTRS, "RigDoctor update token", TOKEN_FILE)
if _keyring_store(token):
try: # don't leave a plaintext copy once it's in the keyring
TOKEN_FILE.unlink()
except OSError:
pass
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(token + "\n")
try:
TOKEN_FILE.chmod(0o600)
except OSError:
pass
def clear_token() -> None: def clear_token() -> None:
_keyring_clear() _clear_secret(_SECRET_ATTRS, TOKEN_FILE)
try:
TOKEN_FILE.unlink()
except OSError:
pass
def token_backend() -> str: def token_backend() -> str:
@@ -134,12 +161,25 @@ def token_backend() -> str:
env = os.environ.get("RIGDOCTOR_TOKEN") env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip(): if env and env.strip():
return "env" return "env"
if _keyring_lookup() is not None: if _keyring_lookup(_SECRET_ATTRS) is not None:
return "keyring" return "keyring"
if TOKEN_FILE.exists(): if TOKEN_FILE.exists():
return "file" return "file"
return "none" return "none"
def load_ai_key() -> str | None:
"""Claude API key from $RIGDOCTOR_AI_KEY, then the OS keyring, then a 0600 file (M14)."""
return _load_secret("RIGDOCTOR_AI_KEY", _AI_SECRET_ATTRS, AI_KEY_FILE)
def save_ai_key(key: str) -> None:
_save_secret(key, _AI_SECRET_ATTRS, "RigDoctor AI key", AI_KEY_FILE)
def clear_ai_key() -> None:
_clear_secret(_AI_SECRET_ATTRS, AI_KEY_FILE)
DEFAULTS: dict = { DEFAULTS: dict = {
"interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR) "interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR)
"log_max_bytes": 20_000_000, # rotate a log segment past this size "log_max_bytes": 20_000_000, # rotate a log segment past this size
@@ -151,6 +191,12 @@ 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
"setup_done": False, # first-run GUI setup wizard completed (M9)
"ai_provider": "", # AI assistant (M14, D24): "" (unset) | "ollama" | "claude"
"ai_model": "", # model name (e.g. "llama3.1" for Ollama; blank = Claude default)
"ai_endpoint": "http://localhost:11434", # Ollama server base URL (Claude uses a fixed endpoint)
"logging_enabled": False, # opt-in: app logging + per-diagnostic storage + Report (M15)
} }
+288
View File
@@ -0,0 +1,288 @@
"""AI assistant (M14, D24): explain the collected diagnostics in plain language.
**Strictly opt-in and never automatic** — the model is contacted ONLY from a direct user
action ("Explain with AI" / ``rigdoctor ai explain``), never on launch, after a diagnostic, or
in any loop. Choosing/configuring a provider does not contact anything. The user must pick a
provider explicitly (there is no default).
Two providers, both over stdlib ``urllib`` (no pip deps in core):
* **ollama** — a local server (data stays on the machine, no key).
* **claude** — the Anthropic Messages API (key in the keyring).
Answers are *grounded*: we pass the actual findings plus matched reference facts
(:mod:`ai_knowledge`) and ask the model to reason over them. Output is advisory (D9).
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from .. import config
from . import ai_knowledge
_APPID_RE = re.compile(r"\b\d{5,7}\b") # Steam app IDs are 57 digits
PROVIDERS = ("ollama", "claude")
OLLAMA_DEFAULT_ENDPOINT = "http://localhost:11434"
# Suggested Ollama model — strong instruction-following that fits an 8 GB GPU at Q4. Because we
# ground the prompt with reference facts, a 7B model is sufficient here.
OLLAMA_SUGGESTED_MODEL = "qwen2.5:7b"
CLAUDE_ENDPOINT = "https://api.anthropic.com/v1/messages"
CLAUDE_DEFAULT_MODEL = "claude-opus-4-7"
CLAUDE_MAX_TOKENS = 2000
ANTHROPIC_VERSION = "2023-06-01"
SYSTEM_PROMPT = (
"You are RigDoctor's hardware-diagnostics assistant for Linux gamers (Ubuntu + NVIDIA, games "
"via Steam/Proton). You are given session context, the structured findings RigDoctor "
"collected — which may include recent game/Proton/system log excerpts scoped to this session "
"— plus reference facts. Use the GAME NAME from the session context; never guess the game "
"from log paths or app IDs. Correlate log errors with the findings to pinpoint WHEN and WHY "
"things went wrong, identify the most likely root cause, and give concrete, ordered next "
"steps with exact Linux commands where useful.\n"
"Rules: Base your reasoning ONLY on the data and reference facts provided — never invent "
"readings, hardware, or log lines. This is LINUX: never suggest Windows-only steps (e.g. "
"'run as administrator', registry edits, toggling antivirus). Treat log lines flagged BENIGN "
"in the reference facts as non-causal. If no crash was recorded and there are no warning or "
"critical findings, say plainly that the session looks healthy and do NOT manufacture a "
"problem. Be concise. Present fixes as suggestions and warn before anything that risks data "
"loss or instability. Format your answer in Markdown."
)
def provider() -> str:
return config.load_config().get("ai_provider", "")
def model() -> str:
m = config.load_config().get("ai_model", "").strip()
if m:
return m
return CLAUDE_DEFAULT_MODEL if provider() == "claude" else ""
def endpoint() -> str:
ep = config.load_config().get("ai_endpoint", OLLAMA_DEFAULT_ENDPOINT).strip()
return ep or OLLAMA_DEFAULT_ENDPOINT
def is_local() -> bool:
return provider() == "ollama"
def is_configured() -> bool:
"""Whether the chosen provider is ready (does NOT contact anything)."""
p = provider()
if p == "claude":
return bool(config.load_ai_key())
if p == "ollama":
return bool(model()) # a model name is required; endpoint has a default
return False # no provider chosen
def provider_label() -> str:
p = provider()
if p == "claude":
return f"Claude ({model()})"
if p == "ollama":
return f"Ollama ({model() or '?'} @ {endpoint()})"
return "not configured"
def appid_glossary(text: str) -> str:
"""Resolve Steam app IDs that appear in `text` against the user's scanned library.
We don't teach the model app IDs — we look them up locally and hand it the mapping, so it
names games correctly instead of guessing. Only IDs we can resolve are listed.
"""
candidates = set(_APPID_RE.findall(text))
if not candidates:
return ""
try:
from . import steam
names = steam.appid_names()
except Exception: # never let a glossary lookup break an explanation
return ""
known = sorted((i, names[i]) for i in candidates if i in names)
if not known:
return ""
return "App IDs (resolved from your installed games):\n" + "\n".join(
f"- {appid} = {name}" for appid, name in known)
def build_prompt(findings_text: str) -> str:
"""The user-message content: app-ID glossary + matched reference facts + the findings."""
parts = []
glossary = appid_glossary(findings_text)
if glossary:
parts.append(glossary)
parts.append("")
facts = ai_knowledge.relevant(findings_text)
if facts:
parts.append("Reference facts (use these to interpret the findings):")
parts += [f"- {f}" for f in facts]
parts.append("")
parts.append("Collected findings:")
parts.append(findings_text.strip() or "(no findings provided)")
return "\n".join(parts)
def explain(findings_text: str, timeout: float = 120.0) -> tuple[bool, str]:
"""Contact the configured provider to explain the findings. Returns (ok, text | error).
The caller MUST be a direct user action (D24) — this never runs automatically.
"""
content = build_prompt(findings_text)
try:
if provider() == "claude":
return _claude(content, timeout)
if provider() == "ollama":
return _ollama(content, timeout)
return False, "No AI provider is configured (Settings → AI assistant)."
except urllib.error.HTTPError as exc:
return False, _http_error(exc)
except (urllib.error.URLError, OSError, TimeoutError) as exc:
return False, f"Couldn't reach the AI provider: {exc}"
except (ValueError, KeyError, IndexError) as exc:
return False, f"Unexpected response from the AI provider: {exc}"
def explain_stream(findings_text: str, on_chunk, timeout: float = 180.0) -> tuple[bool, str]:
"""Like :func:`explain`, but calls ``on_chunk(text_delta)`` as tokens arrive and returns
``(ok, full_text)`` at the end. Caller MUST be a direct user action (D24)."""
content = build_prompt(findings_text)
try:
if provider() == "claude":
return _claude_stream(content, on_chunk, timeout)
if provider() == "ollama":
return _ollama_stream(content, on_chunk, timeout)
return False, "No AI provider is configured (Settings → AI assistant)."
except urllib.error.HTTPError as exc:
return False, _http_error(exc)
except (urllib.error.URLError, OSError, TimeoutError) as exc:
return False, f"Couldn't reach the AI provider: {exc}"
except (ValueError, KeyError, IndexError) as exc:
return False, f"Unexpected response from the AI provider: {exc}"
def _post(url: str, payload: dict, headers: dict, timeout: float) -> dict:
req = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp)
def _ollama(content: str, timeout: float) -> tuple[bool, str]:
if not model():
return False, "No Ollama model is set (Settings → AI assistant)."
payload = {"model": model(), "system": SYSTEM_PROMPT, "prompt": content, "stream": False}
out = _post(endpoint().rstrip("/") + "/api/generate", payload, {}, timeout)
return True, (out.get("response") or "").strip() or "(the model returned an empty response)"
def _claude(content: str, timeout: float) -> tuple[bool, str]:
key = config.load_ai_key()
if not key:
return False, "No Claude API key is set (Settings → AI assistant)."
# One-shot call: no prompt caching (single request, short system prompt) and no thinking
# (keeps a button-press snappy). Sampling params are omitted (removed on current Opus).
payload = {
"model": model(),
"max_tokens": CLAUDE_MAX_TOKENS,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": content}],
}
headers = {"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION}
out = _post(CLAUDE_ENDPOINT, payload, headers, timeout)
text = "\n".join(b.get("text", "") for b in out.get("content", []) if b.get("type") == "text")
return True, text.strip() or "(the model returned no text)"
def _stream_request(url: str, payload: dict, headers: dict, timeout: float):
req = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers})
return urllib.request.urlopen(req, timeout=timeout)
def _ollama_stream(content: str, on_chunk, timeout: float) -> tuple[bool, str]:
if not model():
return False, "No Ollama model is set (Settings → AI assistant)."
payload = {"model": model(), "system": SYSTEM_PROMPT, "prompt": content, "stream": True}
parts: list[str] = []
with _stream_request(endpoint().rstrip("/") + "/api/generate", payload, {}, timeout) as resp:
for raw in resp: # newline-delimited JSON objects
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
obj = json.loads(line)
chunk = obj.get("response", "")
if chunk:
parts.append(chunk)
on_chunk(chunk)
if obj.get("done"):
break
return True, "".join(parts).strip() or "(the model returned an empty response)"
def _claude_stream(content: str, on_chunk, timeout: float) -> tuple[bool, str]:
key = config.load_ai_key()
if not key:
return False, "No Claude API key is set (Settings → AI assistant)."
payload = {
"model": model(), "max_tokens": CLAUDE_MAX_TOKENS, "system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": content}], "stream": True,
}
headers = {"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION}
parts: list[str] = []
with _stream_request(CLAUDE_ENDPOINT, payload, headers, timeout) as resp:
for raw in resp: # SSE: parse `data:` lines, accumulate text deltas
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
try:
event = json.loads(line[5:].strip())
except ValueError:
continue
etype = event.get("type")
if etype == "content_block_delta" and event.get("delta", {}).get("type") == "text_delta":
chunk = event["delta"].get("text", "")
if chunk:
parts.append(chunk)
on_chunk(chunk)
elif etype == "error":
return False, event.get("error", {}).get("message", "stream error")
elif etype == "message_stop":
break
return True, "".join(parts).strip() or "(the model returned no text)"
def _http_error(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
body = exc.read().decode("utf-8", "replace")
detail = json.loads(body).get("error", {}).get("message", "") or ""
except (ValueError, OSError):
pass
hint = " — check your API key in Settings → AI assistant." if exc.code in (401, 403) else ""
return f"AI request failed (HTTP {exc.code}){hint}{(': ' + detail) if detail else ''}"
def format_findings(findings, header: str = "") -> str:
"""Render M4 Finding objects (or similar) into the plain-text block we send the model."""
lines = [header] if header else []
for f in findings:
severity = str(getattr(f, "severity", "")).upper()
category = getattr(f, "category", "")
title = getattr(f, "title", "")
detail = getattr(f, "detail", "")
line = f"- [{severity}] {category}: {title}".rstrip()
if detail:
line += f"{detail}"
lines.append(line)
return "\n".join(lines) if lines else "No findings."
+91
View File
@@ -0,0 +1,91 @@
"""Curated reference knowledge for the AI assistant (M14, D24) — "RAG-lite".
A small, hand-written set of domain facts (Xid codes, SMART attributes, common Linux-gaming
error signatures, tunable meanings). At explain-time we select the entries whose triggers
appear in the collected findings and inject them into the prompt, so even a small local model
gets the relevant facts instead of having to recall them. Provider-agnostic — it sharpens
Claude too.
Retrieval is exact keyword/substring matching, not embeddings: the keys here (``Xid 79``,
``SMART 197``, ``fallen off the bus``) are precise, so a vector store would be overkill and
would break the stdlib-only rule. Each entry is ``(triggers, fact)``; a trigger matches
case-insensitively against the findings text.
"""
from __future__ import annotations
# (triggers, fact). Keep facts short, factual, and cause-oriented — they go into the prompt.
ENTRIES: list[tuple[tuple[str, ...], str]] = [
(("xid 79", "fallen off the bus", "gpu has fallen"),
"NVIDIA Xid 79 / 'GPU has fallen off the bus' = the driver lost PCIe contact with the GPU "
"mid-operation. Usual causes, in order: insufficient/unstable PSU power or a bad power "
"cable, an unstable overclock/undervolt, PCIe link or riser issues, or overheating. Often "
"fatal to the session (hard freeze)."),
(("xid 13", "graphics engine exception"),
"NVIDIA Xid 13 = graphics engine exception, frequently an unstable GPU overclock or a "
"faulty application shader; revert any OC/UV and test."),
(("xid 31", "fifo: mmu fault", "mmu fault"),
"NVIDIA Xid 31 = MMU fault (illegal memory access by the app/driver) — often a game/driver "
"bug or unstable VRAM overclock."),
(("xid 8", "xid 62", "xid 63", "xid 64"),
"These Xid codes commonly indicate VRAM/ECC or memory-training problems — suspect failing "
"VRAM or an unstable memory overclock."),
(("smart 197", "current_pending_sector", "pending sector"),
"SMART 197 (Current Pending Sector) > 0 = sectors the drive can't read and is waiting to "
"reallocate — early sign of a failing disk. Back up now and run an extended self-test."),
(("smart 198", "offline_uncorrectable", "uncorrectable"),
"SMART 198 (Offline Uncorrectable) > 0 = sectors that failed to read/write — the drive is "
"degrading; back up immediately."),
(("smart 5", "reallocated_sector", "reallocated sector"),
"SMART 5 (Reallocated Sectors) climbing over time = the drive is using spares for bad "
"sectors; a rising count predicts failure."),
(("media and data integrity errors", "percentage used", "available spare"),
"NVMe health: 'Media and Data Integrity Errors' > 0 is concerning; 'Percentage Used' near "
"or over 100% and 'Available Spare' below the threshold mean the SSD is near end-of-life."),
(("thermal throttling", "throttle", "tjmax", "package id 0"),
"Sustained CPU/GPU temperatures at the thermal limit cause throttling (clocks drop to shed "
"heat) — check cooling, fan curves, paste, and case airflow."),
(("oom", "out of memory", "oom-killer", "killed process"),
"The kernel OOM-killer terminates processes when RAM (and swap) are exhausted — a freeze "
"or a game crashing to desktop under memory pressure points here; check swap and "
"vm.swappiness, and watch for a memory leak."),
(("segfault", "general protection fault", "segmentation fault"),
"A segfault/GP-fault is a process accessing invalid memory — for games under Proton it's "
"often a Proton/Wine or anticheat incompatibility, or unstable RAM (run memtest)."),
(("proton", "wine", "d3d", "vkd3d", "dxvk"),
"Proton/Wine issues: mismatched Proton version, missing vkd3d/DXVK, or shader-cache "
"corruption are common. Try a known-good Proton version and clear the shader cache."),
(("pcie_aspm", "aspm"),
"PCIe ASPM (Active State Power Management) can cause GPU/NVMe instability on some boards; "
"setting pcie_aspm=off is a common stability fix at a small idle-power cost."),
(("cpu_governor", "powersave", "schedutil", "performance governor"),
"The CPU frequency governor sets the clock policy; 'performance' avoids latency spikes from "
"ramp-up at a higher power draw, while 'powersave'/'schedutil' favor efficiency."),
(("nvidia persistence", "persistence mode"),
"NVIDIA persistence mode keeps the driver loaded when no app is using the GPU, avoiding "
"re-init stalls — harmless to enable."),
(("libnvidia-ml.so", "interface.h", "failed to load \"libnvidia-ml"),
"BENIGN: a Steam log assertion 'Failed to load libnvidia-ml.so.1' (from interface.h) is "
"logged on many normal launches — the Steam runtime sandbox can't see the host NVML library. "
"It is NOT by itself a crash cause. Only investigate the driver if the GPU is genuinely "
"undetected (nvidia-smi fails)."),
(("minidump", ".dmp", "uploading minidump"),
"BENIGN-by-default: a minidump upload line means a crash handler ran AND that the game/engine "
"routinely uploads dumps; it is not proof that THIS session crashed unless a hard freeze or "
"non-zero exit was also recorded. Don't treat a routine minidump line as the root cause."),
(("fork without exec", "skipping destruction"),
"BENIGN: 'pid X != Y, skipping destruction (fork without exec?)' is routine Steam/Proton "
"process bookkeeping, not an error."),
]
def relevant(findings_text: str, limit: int = 8) -> list[str]:
"""Reference facts whose triggers appear in the findings text (case-insensitive)."""
haystack = findings_text.lower()
hits: list[str] = []
for triggers, fact in ENTRIES:
if any(t in haystack for t in triggers):
hits.append(fact)
if len(hits) >= limit:
break
return hits
+41 -5
View File
@@ -1,8 +1,9 @@
"""Desktop alerts (M8): notify on overheat / GPU-lost / new version via notify-send. """Desktop alerts (M8): notify on overheat / GPU-lost / critical kernel events / new version.
Edge-triggered: an alert fires when a condition becomes true (not every sample), and Edge-triggered: a sustained condition (hot GPU, GPU-lost) fires once when it becomes true and
can fire again only after it has cleared and a cooldown has passed — so a hot GPU or a can re-fire only after it clears + a cooldown; momentary **kernel events** (Xid, OOM-kill, MCE,
1-Hz sample loop doesn't spam notifications. Degrades to a no-op if notify-send is absent. PCIe AER, disk I/O errors) are scanned from the kernel log every `event_interval` seconds and
fire one-shot (cooldown-gated). So a 1-Hz sample loop never spams. No-op if notify-send absent.
""" """
from __future__ import annotations from __future__ import annotations
@@ -57,13 +58,16 @@ def notify(title: str, message: str, urgency: str = "normal") -> bool:
class AlertMonitor: class AlertMonitor:
"""Evaluate samples and raise edge-triggered desktop alerts.""" """Evaluate samples and raise edge-triggered desktop alerts."""
def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0): def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0,
event_interval: float = 30.0):
self.gpu_temp = gpu_temp self.gpu_temp = gpu_temp
self.cpu_temp = cpu_temp self.cpu_temp = cpu_temp
self.cooldown = cooldown self.cooldown = cooldown
self.event_interval = event_interval # how often to scan the kernel log
self.enabled = True self.enabled = True
self._active: dict[str, bool] = {} self._active: dict[str, bool] = {}
self._last: dict[str, float] = {} self._last: dict[str, float] = {}
self._last_kernel_scan = time.time() # only alert on events after the monitor starts
def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None: def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None:
if self._active.get(key): if self._active.get(key):
@@ -75,9 +79,39 @@ class AlertMonitor:
self._last[key] = now self._last[key] = now
notify(title, message, urgency) notify(title, message, urgency)
def _notify_once(self, key: str, title: str, message: str, urgency: str = "critical") -> None:
"""One-shot alert for a momentary event (cooldown-gated, no active latch)."""
now = time.time()
if now - self._last.get(key, 0.0) < self.cooldown:
return
self._last[key] = now
notify(title, message, urgency)
def _clear(self, key: str) -> None: def _clear(self, key: str) -> None:
self._active[key] = False self._active[key] = False
def _scan_kernel_events(self) -> None:
"""Periodically scan the kernel log for new critical events (Xid/OOM/MCE/PCIe/disk)."""
now = time.time()
if now - self._last_kernel_scan < self.event_interval:
return
since = self._last_kernel_scan
self._last_kernel_scan = now
try:
from . import syslogs
text = syslogs.kernel_log(since=since)
except Exception: # alerting must never crash the sample loop
return
if not text:
return
seen: set[str] = set()
for label, line in syslogs.scan_critical(text):
if label in seen: # one alert per category per scan
continue
seen.add(label)
self._notify_once(f"kernel:{label}", label, line[:180])
def check(self, sample: Sample) -> None: def check(self, sample: Sample) -> None:
if not self.enabled: if not self.enabled:
return return
@@ -107,3 +141,5 @@ class AlertMonitor:
self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped") self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped")
else: else:
self._clear("gpu_lost") self._clear("gpu_lost")
self._scan_kernel_events() # Xid / OOM / MCE / PCIe / disk I/O from the kernel log
+63
View File
@@ -0,0 +1,63 @@
"""Application logging (M15) — opt-in via the `logging_enabled` setting.
When enabled, app events/errors are written to a rotating file (`config.APP_LOG`); when
disabled, nothing is written (no file is created). All RigDoctor code logs through
``applog.get_logger(__name__)``; the handler is attached once at startup by :func:`setup`.
Stdlib ``logging`` only.
"""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from .. import config
_ROOT = "rigdoctor"
_configured = False
def setup(force: bool = False) -> bool:
"""Attach the file handler if logging is enabled. Idempotent. Returns whether it's on."""
global _configured
logger = logging.getLogger(_ROOT)
enabled = bool(config.load_config().get("logging_enabled", False))
if not enabled:
if force: # toggled off at runtime — detach so we stop writing
for h in list(logger.handlers):
logger.removeHandler(h)
h.close()
_configured = False
return False
if _configured and not force:
return True
for h in list(logger.handlers): # avoid duplicate handlers on re-setup
logger.removeHandler(h)
h.close()
try:
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(config.APP_LOG, maxBytes=2_000_000, backupCount=3,
encoding="utf-8")
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_configured = True
logger.info("logging started (rigdoctor %s)", _version())
except OSError:
return False
return True
def get_logger(name: str) -> logging.Logger:
"""A child logger. Safe to call before setup — it just won't write until enabled."""
short = name.split(".")[-1]
return logging.getLogger(f"{_ROOT}.{short}")
def _version() -> str:
from .. import __version__
return __version__
+8
View File
@@ -65,3 +65,11 @@ COMPONENTS: tuple[Component, ...] = (
def by_id(component_id: str) -> Component | None: def by_id(component_id: str) -> Component | None:
"""Look up a catalog component by its id (None if unknown).""" """Look up a catalog component by its id (None if unknown)."""
return next((c for c in COMPONENTS if c.id == component_id), None) return next((c for c in COMPONENTS if c.id == component_id), None)
def by_bundle() -> dict[str, list[Component]]:
"""Components grouped by bundle, preserving catalog order (for the setup wizard)."""
groups: dict[str, list[Component]] = {}
for c in COMPONENTS:
groups.setdefault(c.bundle, []).append(c)
return groups
+123 -2
View File
@@ -11,13 +11,16 @@ The capture is **manually bracketed** (start/finish) for now; auto start/stop on
from __future__ import annotations from __future__ import annotations
import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from .. import config from .. import config
from . import reccontrol from . import reccontrol
from .crashlog import Summary, summarize from .crashlog import Summary, summarize
from .health import Finding from .health import CRITICAL, OK, WARNING, Finding
_SEV_ORDER = {CRITICAL: 0, WARNING: 1, "info": 2, OK: 3}
@dataclass @dataclass
@@ -25,6 +28,15 @@ class DiagnosticResult:
game: str | None game: str | None
summary: Summary # capture window: peak temps/power, events, last samples (M3) summary: Summary # capture window: peak temps/power, events, last samples (M3)
findings: list[Finding] # health findings: Xid/SMART/driver/etc. (M4) findings: list[Finding] # health findings: Xid/SMART/driver/etc. (M4)
dir: str | None = None # storage directory when logging is on (M15); else None
@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: def _clear_diag_log() -> None:
@@ -42,6 +54,11 @@ def start(game: str | None = None, interval: float | None = None) -> int | None:
Returns the pid, or None if a capture is already running.""" Returns the pid, or None if a capture is already running."""
if reccontrol.running_pid(): if reccontrol.running_pid():
return None 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() _clear_diag_log()
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game) return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
@@ -81,4 +98,108 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
summary = summarize(path, last_n=last_n) summary = summarize(path, last_n=last_n)
game = _game_from_summary(summary) or (reccontrol.read_status() or {}).get("game") game = _game_from_summary(summary) or (reccontrol.read_status() or {}).get("game")
findings = run_health_checks() findings = run_health_checks()
return DiagnosticResult(game=game, summary=summary, findings=findings) result = DiagnosticResult(game=game, summary=summary, findings=findings)
_store(result, path, summary)
return result
def _store(result: DiagnosticResult, capture_path, summary: Summary) -> None:
"""Persist the diagnostic to its own directory when logging is enabled (M15)."""
try:
from . import diagstore
since = (summary.start - 60) if summary.start else None
directory = diagstore.store(result, capture_path, since=since)
if directory:
result.dir = str(directory)
except Exception: # storage must never break a diagnostic
pass
# --- 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))
result = DiagnosticResult(game=_game_from_summary(summary), summary=summary, findings=findings)
_store(result, _crash_path(), summary)
return result
+152
View File
@@ -0,0 +1,152 @@
"""Per-diagnostic storage + Report bundles (M15) — opt-in via `logging_enabled`.
When logging is on, each finished diagnostic is persisted to its own directory under
``config.DIAGNOSTICS_DIR/<id>/`` (capture log, structured result, human-readable report, a
game-log snapshot, and any AI interactions). "Report" zips one directory — including exactly
**what was sent to the AI, which model, and its reply** — into ``config.REPORTS_DIR``.
"""
from __future__ import annotations
import json
import shutil
import time
import zipfile
from dataclasses import asdict, is_dataclass
from pathlib import Path
from .. import config
def enabled() -> bool:
return bool(config.load_config().get("logging_enabled", False))
def _slug(name: str | None) -> str:
s = "".join(c if c.isalnum() else "-" for c in (name or "session").lower())
return s.strip("-")[:40] or "session"
def _new_dir(game: str | None) -> Path:
base = config.DIAGNOSTICS_DIR
stamp = time.strftime("%Y%m%d-%H%M%S")
name = f"{stamp}-{_slug(game)}"
target = base / name
n = 1
while target.exists():
target = base / f"{name}-{n}"
n += 1
target.mkdir(parents=True, exist_ok=True)
return target
def _as_dict(obj):
if is_dataclass(obj):
return asdict(obj)
return getattr(obj, "__dict__", {}) or str(obj)
def store(result, capture_path=None, since: float | None = None) -> Path | None:
"""Persist a finished diagnostic to its own directory. Returns the dir, or None if off."""
if not enabled():
return None
from ..render import render_summary
from . import ai, gamelogs, syslogs
target = _new_dir(getattr(result, "game", None))
if capture_path and Path(capture_path).exists():
try:
shutil.copyfile(capture_path, target / "capture.jsonl")
except OSError:
pass
payload = {
"game": getattr(result, "game", None),
"stored_at": time.time(),
"summary": _as_dict(result.summary),
"findings": [_as_dict(f) for f in result.findings],
}
_write(target / "result.json", json.dumps(payload, indent=2, default=str))
report = [f"Game: {getattr(result, 'game', None) or 'unknown'}", "",
render_summary(result.summary), "",
ai.format_findings(result.findings, header="Findings:")]
_write(target / "report.txt", "\n".join(report))
try:
logs = gamelogs.collect(since=since)
if logs:
_write(target / "gamelogs.txt", logs)
except OSError:
pass
try:
sys_logs = syslogs.collect(since=since)
if sys_logs:
_write(target / "syslogs.txt", sys_logs)
except OSError:
pass
try: # full hardware/OS inventory (M5) — invaluable for larger debugging in a shared report
from . import inventory
sections = inventory.collect()
_write(target / "inventory.txt", inventory.render_text(sections))
_write(target / "inventory.json", inventory.render_json(sections))
except Exception: # inventory probes vary by machine; never let it break storage
pass
return target
def record_ai(diag_dir, *, provider: str, model: str, system: str, prompt: str, response: str) -> None:
"""Save one AI interaction (exact data sent, model, reply) into the diagnostic's `ai/` dir."""
if not diag_dir:
return
out = Path(diag_dir) / "ai"
try:
out.mkdir(parents=True, exist_ok=True)
except OSError:
return
stamp = time.strftime("%Y%m%d-%H%M%S")
record = {
"timestamp": time.time(), "provider": provider, "model": model,
"system_prompt": system, "data_sent_to_model": prompt, "model_reply": response,
}
_write(out / f"explain-{stamp}.json", json.dumps(record, indent=2, default=str))
readable = (
f"Provider: {provider}\nModel: {model}\n\n"
f"=== System prompt ===\n{system}\n\n"
f"=== Data sent to the model ===\n{prompt}\n\n"
f"=== Model reply ===\n{response}\n"
)
_write(out / f"explain-{stamp}.txt", readable)
def make_report(diag_dir) -> Path:
"""Zip a diagnostic directory (plus the app log) into REPORTS_DIR; return the zip path."""
diag_dir = Path(diag_dir)
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
out = config.REPORTS_DIR / f"report-{diag_dir.name}.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(diag_dir.rglob("*")):
if path.is_file():
zf.write(path, arcname=str(Path(diag_dir.name) / path.relative_to(diag_dir)))
if config.APP_LOG.exists(): # the application log, for context around the session
zf.write(config.APP_LOG, arcname=str(Path(diag_dir.name) / "app.log"))
return out
def latest_dir() -> Path | None:
try:
dirs = [d for d in config.DIAGNOSTICS_DIR.iterdir() if d.is_dir()]
except OSError:
return None
return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None
def _write(path: Path, text: str) -> None:
try:
path.write_text(text, encoding="utf-8")
except OSError:
pass
+148
View File
@@ -0,0 +1,148 @@
"""Connected displays (M5): resolution + current/max refresh per monitor.
GNOME exposes the authoritative data over D-Bus (Mutter `DisplayConfig.GetCurrentState`),
which works on both X11 and Wayland — read via `busctl --json`. Plain X11 desktops fall back
to `xrandr`. Other Wayland compositors (sway/KDE) aren't covered yet and degrade to empty.
Stdlib only; every probe fails soft. Max refresh is computed at the *current* resolution, so
"can go faster" never suggests dropping resolution.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
# A few common PNP monitor-vendor IDs → friendly names (best-effort; unknown codes pass through).
_PNP = {
"SAM": "Samsung", "DEL": "Dell", "GSM": "LG", "LGD": "LG", "AUS": "ASUS", "ACR": "Acer",
"BNQ": "BenQ", "MSI": "MSI", "AOC": "AOC", "VSC": "ViewSonic", "HWP": "HP", "HPN": "HP",
"PHL": "Philips", "GBT": "Gigabyte", "APP": "Apple", "DGC": "Dell",
}
@dataclass
class Monitor:
connector: str # e.g. "DP-1"
name: str # e.g. "Samsung LC34G55T" ("" if unknown, e.g. xrandr)
width: int
height: int
refresh: float # current Hz
max_refresh: float # max Hz available at the current resolution
@property
def can_go_faster(self) -> bool:
"""True if a meaningfully higher refresh is available at the current resolution."""
return self.max_refresh - self.refresh > 1.0
def label(self) -> str:
return f"{self.connector} · {self.name}".rstrip(" ·") if self.name else self.connector
def _run(cmd: list[str], timeout: float = 8.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
return proc.stdout
except (subprocess.SubprocessError, OSError):
pass
return ""
def _parse_mutter(out: str) -> list[Monitor]:
"""Parse `busctl --json` output of Mutter DisplayConfig.GetCurrentState.
data = [serial, monitors, logical_monitors, props]; each monitor is
[[connector, vendor, product, serial], [modes], props]; each mode is
[id, width, height, refresh, scale, [scales], {props}] where props may hold is-current.
"""
try:
data = json.loads(out)["data"]
raw_monitors = data[1]
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
return []
monitors: list[Monitor] = []
for mon in raw_monitors:
try:
connector, vendor, product = mon[0][0], mon[0][1], mon[0][2]
modes = mon[1]
except (IndexError, TypeError):
continue
current = None
for m in modes:
props = m[6] if len(m) > 6 and isinstance(m[6], dict) else {}
if (props.get("is-current") or {}).get("data"):
current = m
break
if current is None:
continue
w, h, r = int(current[1]), int(current[2]), float(current[3])
max_r = max((float(m[3]) for m in modes if int(m[1]) == w and int(m[2]) == h), default=r)
name = f"{_PNP.get(vendor, vendor)} {product}".strip()
monitors.append(Monitor(connector, name, w, h, r, max_r))
return monitors
def _parse_xrandr(out: str) -> list[Monitor]:
"""Parse `xrandr --query`: an output line with the active WxH+x+y, then indented mode lines
whose rates carry `*` for the current one."""
monitors: list[Monitor] = []
out_re = re.compile(r"^(\S+) connected.*?(\d+)x(\d+)\+\d+\+\d+")
mode_re = re.compile(r"^\s+(\d+)x(\d+)\s+(.+)$")
name = ""
cw = ch = 0
cur_r = max_r = 0.0
def flush() -> None:
if name and cw and cur_r:
monitors.append(Monitor(name, "", cw, ch, cur_r, max_r or cur_r))
for line in out.splitlines():
mo = out_re.match(line)
if mo:
flush()
name, cw, ch = mo.group(1), int(mo.group(2)), int(mo.group(3))
cur_r = max_r = 0.0
continue
mm = mode_re.match(line)
if mm and name and int(mm.group(1)) == cw and int(mm.group(2)) == ch:
for tok in mm.group(3).split():
try:
rate = float(tok.rstrip("*+"))
except ValueError:
continue
max_r = max(max_r, rate)
if "*" in tok:
cur_r = rate
flush()
return monitors
def _mutter() -> list[Monitor]:
exe = shutil.which("busctl")
if not exe:
return []
out = _run([exe, "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig",
"GetCurrentState"])
return _parse_mutter(out) if out.strip() else []
def _xrandr() -> list[Monitor]:
if not shutil.which("xrandr"):
return []
return _parse_xrandr(_run(["xrandr", "--query"]))
def collect() -> list[Monitor]:
"""Connected monitors, via the first backend that returns any (Mutter, then xrandr)."""
for backend in (_mutter, _xrandr):
try:
monitors = backend()
except Exception:
monitors = []
if monitors:
return monitors
return []
+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
+116
View File
@@ -0,0 +1,116 @@
"""Collect recent game / Proton / Steam logs to enrich an AI diagnostic (M14).
Reads logs that already exist on disk — no change to how the game is launched. Two reliable
sources: Proton's per-app log (``~/steam-<appid>.log``, written when ``PROTON_LOG=1``) and
Steam's own console log. Each is tail-read and size-bounded so the AI prompt stays small. The
text is fed to the AI alongside the findings so it can see *when* something went wrong (a
vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summary.
"""
from __future__ import annotations
import os
import re
import time
from pathlib import Path
# Steam keeps logs under its install root; ~/.steam/steam usually symlinks to the real one.
_STEAM_LOG_DIRS = ("~/.steam/steam/logs", "~/.local/share/Steam/logs", "~/.steam/root/logs")
_STEAM_LOG_FILES = ("console-linux.txt", "console_log.txt", "stderr.txt")
_TS = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]")
def _line_epoch(line: str) -> float | None:
m = _TS.match(line)
if not m:
return None
try:
return time.mktime(time.strptime(m.group(1), "%Y-%m-%d %H:%M:%S"))
except ValueError:
return None
def _since_filter(text: str, since: float) -> str:
"""Keep lines from the first timestamp >= `since` onward (logs are chronological).
Untimestamped lines before the window are dropped; once inside the window every line is
kept (so multi-line entries survive). This scopes a long-lived Steam log to one session.
"""
out: list[str] = []
including = False
for line in text.splitlines():
epoch = _line_epoch(line)
if epoch is not None and epoch >= since:
including = True
if including:
out.append(line)
return "\n".join(out)
def _tail(path: Path, max_bytes: int) -> str:
"""Last ``max_bytes`` of a file, decoded leniently (empty string on error)."""
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
return fh.read().decode("utf-8", "replace")
except OSError:
return ""
def _proton_logs() -> list[Path]:
try:
logs = list(Path.home().glob("steam-*.log"))
except OSError:
return []
return sorted(logs, key=lambda p: p.stat().st_mtime, reverse=True)
def _steam_console() -> Path | None:
for directory in _STEAM_LOG_DIRS:
base = Path(os.path.expanduser(directory))
for name in _STEAM_LOG_FILES:
candidate = base / name
if candidate.exists():
return candidate
return None
def available() -> bool:
return bool(_proton_logs() or _steam_console())
def collect(since: float | None = None, max_bytes: int = 8000) -> str:
"""Recent Proton + Steam log tails as one labelled text block ('' if none).
With ``since`` (epoch), scope to that session: skip a Proton log not written during/after
the session (a stale per-app log from an earlier game), and keep only Steam-console lines
timestamped at/after ``since`` — so we don't feed the model an unrelated past session.
"""
sections: list[str] = []
protons = _proton_logs()
if protons:
log = protons[0]
fresh = since is None or _mtime(log) >= since
tail = _tail(log, max_bytes).strip() if fresh else ""
if tail:
sections.append(f"--- Proton log ({log.name}) ---\n{tail}")
console = _steam_console()
if console:
raw = _tail(console, 40000 if since else max_bytes)
if since is not None:
raw = _since_filter(raw, since)
raw = raw.strip()[-max_bytes:].strip()
if raw:
sections.append(f"--- Steam log ({console.name}) ---\n{raw}")
return "\n\n".join(sections)
def _mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
+73 -2
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,17 +251,70 @@ def check_live_temps() -> list[Finding]:
)] )]
def run_health_checks() -> list[Finding]: def check_pcie_links() -> list[Finding]:
"""Flag NVMe drives linked below their PCIe capability — a slower slot or, most often,
motherboard lane-sharing where a GPU/second card or another M.2 steals lanes from the slot.
Width reductions are reliable (reported as warnings); speed-only reductions are info (they can
also be normal link power management at idle). The GPU is intentionally not checked here:
NVIDIA drops its PCIe gen *and* width at idle, so a point-in-time snapshot is misleading.
"""
from . import inventory
findings: list[Finding] = []
for name, dev in inventory.nvme_controllers():
cur_g, cur_w, max_g, max_w = inventory.read_link(dev)
if not cur_g or not max_g:
continue
if max_w and cur_w and cur_w != max_w: # fewer lanes → almost always lane-sharing
findings.append(Finding(
WARNING, "PCIe", f"{name} linked at x{cur_w} (supports x{max_w})",
f"{name} negotiated PCIe Gen{cur_g} x{cur_w}, but the drive supports "
f"Gen{max_g} x{max_w}. Fewer lanes is usually motherboard lane-sharing — a GPU or a "
"second card in a PCIe slot, or another populated M.2, can steal lanes from this slot.",
"Check your board manual's lane-sharing table; move the drive to a full-x4 "
"(often CPU-attached) M.2 slot."))
elif cur_g < max_g: # full width but a lower generation → slower slot or idle ASPM
findings.append(Finding(
INFO, "PCIe", f"{name} linked at Gen{cur_g} (supports Gen{max_g})",
f"{name} negotiated PCIe Gen{cur_g} but supports Gen{max_g}. This can be a slower "
"(chipset or older) M.2 slot, or normal link power management (ASPM) at idle.",
"If you expect full speed, check the slot and the BIOS PCIe/ASPM settings."))
return findings
def check_displays() -> list[Finding]:
"""Flag monitors running below their max refresh rate at the current resolution — e.g. a
165 Hz panel set to 60 Hz, a common and easily-missed gaming setting (read-only suggestion)."""
from . import displays
findings: list[Finding] = []
for m in displays.collect():
if m.can_go_faster:
findings.append(Finding(
INFO, "Display",
f"{m.connector} at {round(m.refresh)} Hz (supports {round(m.max_refresh)} Hz)",
f"{m.name or m.connector} is running at {round(m.refresh)} Hz at "
f"{m.width}x{m.height}, but supports {round(m.max_refresh)} Hz at that resolution.",
"Raise the refresh rate in your desktop's Display settings (GNOME: Settings → Displays)."))
return findings
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()
findings += check_journal() if include_journal:
findings += check_journal()
findings += check_journal_persistence() findings += check_journal_persistence()
priv = elevation.privileged() priv = elevation.privileged()
if priv is not None and priv.get("smart") is not None: if priv is not None and priv.get("smart") is not None:
@@ -253,5 +322,7 @@ def run_health_checks() -> list[Finding]:
else: else:
findings += check_smart() findings += check_smart()
findings += check_live_temps() findings += check_live_temps()
findings += check_pcie_links()
findings += check_displays()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings return findings
+74 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import os import os
import platform import platform
import re
import shutil import shutil
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
@@ -123,6 +124,64 @@ def _gpu() -> Section:
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")]) return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")])
# PCIe link speed (GT/s) → generation.
_PCIE_GEN = {"2.5": 1, "5": 2, "5.0": 2, "8": 3, "8.0": 3, "16": 4, "16.0": 4, "32": 5, "32.0": 5}
def _gen(speed: str) -> int | None:
"""Map a sysfs link speed like '16.0 GT/s PCIe' to its PCIe generation (4)."""
tok = speed.strip().split()[0] if speed.strip() else ""
return _PCIE_GEN.get(tok)
def read_link(dev: Path) -> tuple[int | None, str, int | None, str]:
"""Negotiated/max PCIe link for a PCI device dir: (cur_gen, cur_width, max_gen, max_width).
Widths are the raw sysfs strings (e.g. '4'); gens are ints (4) or None when unreadable.
"""
def rd(name: str) -> str:
try:
return (dev / name).read_text().strip()
except OSError:
return ""
return (_gen(rd("current_link_speed")), rd("current_link_width"),
_gen(rd("max_link_speed")), rd("max_link_width"))
def _link_desc(dev: Path) -> str:
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
slower slot.
"""
cur_g, cur_w, max_g, max_w = read_link(dev)
if not cur_g or not cur_w:
return ""
desc = f"PCIe Gen{cur_g} x{cur_w}"
if max_g and (cur_g < max_g or (max_w and cur_w != max_w)):
desc += f" (capable of Gen{max_g} x{max_w})"
return desc
def nvme_controllers() -> list[tuple[str, Path]]:
"""Each NVMe controller as (name, pci-device-dir), e.g. ('nvme0', /sys/.../device)."""
base = Path("/sys/class/nvme")
try:
entries = [p for p in base.iterdir() if re.fullmatch(r"nvme\d+", p.name)]
except OSError:
return []
return sorted((p.name, p / "device") for p in entries)
def _nvme_link(block_name: str) -> str:
"""PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe."""
m = re.match(r"(nvme\d+)", block_name)
if not m:
return ""
return _link_desc(Path("/sys/class/nvme") / m.group(1) / "device")
def _storage() -> Section: def _storage() -> Section:
items: list[tuple[str, str]] = [] items: list[tuple[str, str]] = []
# TYPE first so MODEL (which can contain spaces) is the trailing field. # TYPE first so MODEL (which can contain spaces) is the trailing field.
@@ -133,15 +192,27 @@ def _storage() -> Section:
continue continue
name, size = parts[1], parts[2] name, size = parts[1], parts[2]
model = parts[3] if len(parts) > 3 else "" model = parts[3] if len(parts) > 3 else ""
items.append((name, f"{model} ({size})".strip())) desc = f"{model} ({size})".strip()
link = _nvme_link(name) # NVMe PCIe gen/width (e.g. Gen4 x4), flags downtrains
if link:
desc += f" · {link}"
items.append((name, desc))
return Section("Storage", items or [("Disks", "unknown")]) return Section("Storage", items or [("Disks", "unknown")])
def _display() -> Section: def _display() -> Section:
return Section("Display", [ from . import displays
items = [
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")), ("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")), ("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
]) ]
for m in displays.collect():
val = f"{m.width}x{m.height} @ {round(m.refresh)} Hz"
if m.can_go_faster:
val += f" (supports {round(m.max_refresh)} Hz)"
items.append((m.label(), val))
return Section("Display", items)
def _dmidecode() -> dict: def _dmidecode() -> dict:
+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())
+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
+44 -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,13 @@ 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 appid_names() -> dict[str, str]:
"""{appid: name} for the user's scanned games — lets us resolve IDs seen in logs (M14)."""
return {g.appid: g.name for g in cached_games() if g.appid and g.name}
def rescan(cfg: dict | None = None) -> ScanResult: def rescan(cfg: dict | None = None) -> ScanResult:
@@ -351,6 +360,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 ""
+165
View File
@@ -0,0 +1,165 @@
"""Session-scoped system logs for diagnostics (M15): kernel, coredumps, NVIDIA, display.
Covers what the *system* logged when something went wrong, so the report bundle and the AI both
see it:
* kernel ring-buffer slice (`journalctl -k`) Xid, OOM-killer, MCE, PCIe AER, thermal, hung tasks
* systemd-coredump records (`coredumpctl`) did the game/wine dump core (SIGSEGV/ABRT), when
* an `nvidia-smi -q` snapshot driver, throttle/clock-event reasons, clocks, power, temps, PCIe,
ECC + retired pages (point-in-time at diagnostic time)
* the display-server log `Xorg.0.log` on X11, or the compositor's user-journal slice on Wayland
Best-effort and size-bounded: degrades silently if a tool is missing or access is denied. Stdlib only.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
_MAX = 8000 # cap each log section so the prompt/report stays small
_NV_MAX = 10000 # nvidia-smi -q is structured + valuable; allow a bit more (head-truncated)
# Compositors whose user-journal entries are the "Wayland log" (OR-matched by journalctl).
_COMPOSITORS = ("gnome-shell", "mutter", "kwin_wayland", "Xwayland", "sway", "gamescope")
_XORG_LOGS = ("~/.local/share/xorg/Xorg.0.log", "/var/log/Xorg.0.log")
def _since_arg(since: float | None) -> str | None:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(since)) if since else None
def _run(cmd: list[str], timeout: float = 15.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return ""
return (proc.stdout or "").strip()
def kernel_log(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("journalctl"):
return ""
cmd = ["journalctl", "-k", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or out.strip().lower() == "-- no entries --": # journalctl's empty marker
return ""
return out[-max_bytes:]
def coredumps(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("coredumpctl"):
return ""
cmd = ["coredumpctl", "list", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or "no coredumps" in out.lower():
return ""
return out[-max_bytes:]
def nvidia_snapshot(max_bytes: int = _NV_MAX) -> str:
"""Point-in-time `nvidia-smi -q` (head-truncated — driver/temps/clocks/ECC sit near the top)."""
if not shutil.which("nvidia-smi"):
return ""
out = _run(["nvidia-smi", "-q"])
return out[:max_bytes] if out else ""
def _xorg_log() -> Path | None:
for cand in _XORG_LOGS:
path = Path(os.path.expanduser(cand))
if path.exists():
return path
return None
def _session_type() -> str:
declared = os.environ.get("XDG_SESSION_TYPE", "").lower()
if declared in ("x11", "wayland"):
return declared
if os.environ.get("WAYLAND_DISPLAY"):
return "wayland"
return "x11" if _xorg_log() else "unknown"
def _tail_file(path: Path, max_bytes: int) -> str:
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
return fh.read().decode("utf-8", "replace")
except OSError:
return ""
def display_log(since: float | None = None, max_bytes: int = _MAX) -> str:
"""Xorg.0.log on X11, or the compositor's user-journal slice on Wayland ('' if none)."""
if _session_type() == "wayland":
if not shutil.which("journalctl"):
return ""
cmd = ["journalctl", "--user", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
cmd += [f"_COMM={comp}" for comp in _COMPOSITORS] # OR-matched
out = _run(cmd)
if not out or out.strip().lower() == "-- no entries --":
return ""
return out[-max_bytes:]
log = _xorg_log() # X11: Xorg log isn't wall-clock-timestamped, so tail rather than scope
return _tail_file(log, max_bytes) if log else ""
# Kernel-log patterns worth alerting on in real time (M8 event alerts). (label, regex).
_CRITICAL = [
("GPU error (Xid)", re.compile(r"NVRM:\s*Xid", re.I)),
("Out of memory", re.compile(r"out of memory|oom-kill|killed process \d+", re.I)),
("CPU machine-check", re.compile(r"\bmce:|machine check", re.I)),
("PCIe error", re.compile(r"\bAER:|pcie bus error", re.I)),
("Disk I/O error", re.compile(
r"buffer i/o error|\bi/o error\b|critical medium error|ext4-fs error|"
r"blk_update_request:.*error|ata\d+.*(?:failed|error)", re.I)),
]
def scan_critical(text: str) -> list[tuple[str, str]]:
"""(label, line) for kernel lines matching a critical pattern (first match per line)."""
events: list[tuple[str, str]] = []
for line in text.splitlines():
for label, pat in _CRITICAL:
if pat.search(line):
events.append((label, line.strip()))
break
return events
def available() -> bool:
return bool(shutil.which("journalctl") or shutil.which("coredumpctl")
or shutil.which("nvidia-smi") or _xorg_log())
def collect(since: float | None = None) -> str:
"""Kernel + coredumps + NVIDIA snapshot + display log as one labelled block ('' if none)."""
sections: list[str] = []
kern = kernel_log(since)
if kern:
sections.append(f"--- Kernel log (journalctl -k) ---\n{kern}")
cores = coredumps(since)
if cores:
sections.append(f"--- Crashed processes (coredumpctl) ---\n{cores}")
nvidia = nvidia_snapshot()
if nvidia:
sections.append(f"--- NVIDIA snapshot (nvidia-smi -q) ---\n{nvidia}")
display = display_log(since)
if display:
sections.append(f"--- Display server log ({_session_type()}) ---\n{display}")
return "\n\n".join(sections)
+55 -3
View File
@@ -8,11 +8,14 @@ state for the UI; `apply_update` performs the no-root self-update.
from __future__ import annotations from __future__ import annotations
import functools
import json import json
import shutil
import subprocess import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path
from .. import __version__ from .. import __version__
from ..config import load_token from ..config import load_token
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
AVAILABLE = "available" AVAILABLE = "available"
APT_PACKAGE = "rigdoctor"
def _dpkg_owns(path: Path) -> bool:
"""True if dpkg reports `path` belongs to a package (i.e. an apt/.deb install)."""
if not shutil.which("dpkg"):
return False
try:
r = subprocess.run(["dpkg", "-S", str(path)], capture_output=True, text=True, timeout=5)
except (subprocess.SubprocessError, OSError):
return False
return r.returncode == 0 and APT_PACKAGE in r.stdout
@functools.lru_cache(maxsize=1)
def install_kind() -> str:
"""How RigDoctor was installed: 'apt' (.deb), 'pip' (venv/.run), or 'dev' (source checkout).
Decides which updater to use: only 'pip' can self-update in place; apt is root/dpkg-managed
and source is VCS-managed, so those are guided rather than auto-applied.
"""
pkg = Path(__file__).resolve().parents[1] # .../rigdoctor
if _dpkg_owns(pkg / "__init__.py"):
return "apt"
if sys.prefix != sys.base_prefix: # inside a venv → the pip/.run install
return "pip"
if (pkg.parents[1] / "pyproject.toml").exists(): # repo checkout
return "dev"
if str(pkg).startswith("/usr/") or "/dist-packages/" in str(pkg):
return "apt" # system-managed but no dpkg record — still don't pip
return "pip"
def update_hint(kind: str | None = None) -> str:
"""Human guidance for installs that can't self-update via pip (apt / source)."""
kind = kind or install_kind()
if kind == "apt":
return ("Installed via apt — update with:\n"
f" sudo apt update && sudo apt install --only-upgrade {APT_PACKAGE}")
if kind == "dev":
return "Running from a source checkout — update with `git pull`."
return ""
def _parse(version: str) -> tuple[int, ...]: def _parse(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit()) return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit())
@@ -100,11 +147,16 @@ def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str
def apply_update(tag: str) -> tuple[int, str]: def apply_update(tag: str) -> tuple[int, str]:
"""Self-update the current (user-local) install to `tag` via authenticated pip. """Update to `tag` using the method matching how RigDoctor was installed.
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>` into Only pip/venv installs are upgraded in place (authenticated pip install of
the running environment. Returns (exit_code, output) with the token scrubbed. `rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>`). apt and source
installs can't be (root/dpkg- or VCS-managed), so they return guidance instead of
attempting pip. Returns (exit_code, output) with the token scrubbed.
""" """
kind = install_kind()
if kind != "pip":
return (1, update_hint(kind))
token = load_token() token = load_token()
if not token: if not token:
return (1, "No update token configured. Run `rigdoctor login`.") return (1, "No update token configured. Run `rigdoctor login`.")
+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
+18 -2
View File
@@ -17,6 +17,10 @@ ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
from ..core import applog
applog.setup() # opt-in app logging (M15); no-op unless logging_enabled
applog.get_logger(__name__).info("GUI starting")
desktop.ensure() # self-register icon + .desktop so updates show it without re-installing desktop.ensure() # self-register icon + .desktop so updates show it without re-installing
app = QApplication(argv if argv is not None else sys.argv) app = QApplication(argv if argv is not None else sys.argv)
app.setApplicationName("RigDoctor") app.setApplicationName("RigDoctor")
@@ -28,9 +32,21 @@ def main(argv: list[str] | None = None) -> int:
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
interval = float(load_config().get("interval", 1.0)) cfg = load_config()
interval = float(cfg.get("interval", 1.0))
window = MainWindow(interval=interval) window = MainWindow(interval=interval)
window.show() # `--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()
# First run (or `--setup`): the graphical setup wizard (M9).
if "--setup" in args or not cfg.get("setup_done", False):
from .setup_wizard import SetupWizard
SetupWizard(window).exec()
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
+229
View File
@@ -0,0 +1,229 @@
"""Results view for a guided diagnostic session (M6/D12): capture summary + findings."""
from __future__ import annotations
import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCursor
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ..render import render_summary
from .widgets import finding_card
class DiagnosticDialog(QDialog):
_chunk = Signal(str) # streamed token delta (worker thread -> GUI)
_explained = Signal(object) # (ok, full_text) when the AI stream finishes
def __init__(self, result, parent=None) -> None:
super().__init__(parent)
self._result = result
self._stream_view = None
self._stream_status = None
self._chunk.connect(self._on_chunk)
self._explained.connect(self._on_explained)
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()
self._explain_btn = QPushButton("Explain with AI")
self._explain_btn.clicked.connect(self._explain_with_ai)
from ..core import ai
self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up
buttons.addWidget(self._explain_btn)
self._report_btn = QPushButton("Report") # zip this diagnostic's logs (M15)
self._report_btn.clicked.connect(self._make_report)
self._report_btn.setVisible(bool(result.dir)) # only when logging stored the session
buttons.addWidget(self._report_btn)
buttons.addStretch(1)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(self.accept)
buttons.addWidget(close)
root.addLayout(buttons)
# --- AI explanation (M14, D24) — streamed; runs only on this button press ----------
def _explain_with_ai(self) -> None:
from ..core import ai
if not ai.is_local(): # cloud provider → explicit consent before sending data
confirm = QMessageBox.question(
self, "Send to AI provider",
f"This sends your diagnostic findings to {ai.provider_label()}.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self._explain_btn.setEnabled(False)
dialog = self._open_stream_dialog()
threading.Thread(target=self._work_explain, daemon=True).start()
dialog.exec() # streaming fills the view live via signals during this nested loop
self._stream_view = self._stream_status = None
self._explain_btn.setEnabled(True)
def _work_explain(self) -> None:
from ..core import ai, gamelogs, syslogs
result = self._result
summary = result.summary
events = {kind for _ts, kind, _detail in summary.events}
clean = "session-stop" in events
gpu_lost = "gpu-lost" in events
lines = [f"Game: {result.game or 'unknown'}"]
if summary.start and summary.end:
lines.append(f"Capture duration: ~{int(summary.end - summary.start)}s")
outcome = "ended cleanly (no crash detected)" if clean else \
"ended without a clean stop (possible crash/freeze)"
if gpu_lost:
outcome += "; a GPU-lost event was recorded"
lines.append(f"Outcome: {outcome}")
lines.append("")
lines.append(ai.format_findings(result.findings, header="Findings:"))
lines.append("\nCapture summary:\n" + render_summary(summary))
since = (summary.start - 60) if summary.start else None
logs = gamelogs.collect(since=since) # scoped to this session
if logs:
lines.append("\nGame/Proton/Steam logs for this session:\n" + logs)
sys_logs = syslogs.collect(since=since) # kernel log + crashed-process records
if sys_logs:
lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs)
text = "\n".join(lines)
ok, reply = ai.explain_stream(text, on_chunk=lambda d: self._chunk.emit(d))
if result.dir: # record exactly what was sent, the model, and the reply (M15)
from ..core import diagstore
diagstore.record_ai(
result.dir, provider=ai.provider(), model=ai.model(),
system=ai.SYSTEM_PROMPT, prompt=ai.build_prompt(text),
response=reply if ok else f"[error] {reply}")
self._explained.emit((ok, reply))
def _on_chunk(self, delta: str) -> None:
if self._stream_view is None:
return
self._stream_view.moveCursor(QTextCursor.MoveOperation.End)
self._stream_view.insertPlainText(delta) # live plain text as tokens arrive
self._stream_view.ensureCursorVisible()
def _on_explained(self, result) -> None:
ok, text = result
if self._stream_view is not None:
if ok:
self._stream_view.setMarkdown(text) # re-render the finished answer as Markdown
else:
self._stream_view.setPlainText(f"AI explanation failed:\n\n{text}")
if self._stream_status is not None:
self._stream_status.setText(
"AI-generated suggestions — verify before acting, especially anything that changes "
"settings or data." if ok else "The request failed.")
# --- Report bundle (M15) ------------------------------------------------------
def _make_report(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from ..core import diagstore
self._report_btn.setEnabled(False)
try:
out = diagstore.make_report(self._result.dir)
except OSError as exc:
self._report_btn.setEnabled(True)
QMessageBox.warning(self, "Report failed", str(exc))
return
self._report_btn.setEnabled(True)
box = QMessageBox(self)
box.setWindowTitle("Report created")
box.setText(f"Saved report:\n{out}\n\nIt contains this diagnostic's logs and any AI "
"interaction (data sent, model, and reply).")
open_btn = box.addButton("Open folder", QMessageBox.ButtonRole.ActionRole)
box.addButton("OK", QMessageBox.ButtonRole.AcceptRole)
box.exec()
if box.clickedButton() is open_btn:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(out.parent)))
def _open_stream_dialog(self) -> QDialog:
"""A live dialog the AI streams into; finalized to rendered Markdown when done."""
from ..core import ai
dlg = QDialog(self)
dlg.setWindowTitle(f"AI explanation — {ai.provider_label()}")
dlg.resize(620, 520)
lay = QVBoxLayout(dlg)
view = QTextEdit()
view.setObjectName("Report")
view.setReadOnly(True)
lay.addWidget(view)
status = QLabel("Streaming from the model…")
status.setObjectName("Muted")
status.setWordWrap(True)
lay.addWidget(status)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(dlg.accept)
lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)
self._stream_view = view
self._stream_status = status
return dlg
+1 -1
View File
@@ -46,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)
+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)}")
+191 -31
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,
@@ -18,7 +20,9 @@ from PySide6.QtWidgets import (
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea,
QStackedWidget, QStackedWidget,
QSystemTrayIcon,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -31,14 +35,28 @@ 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]
# Pages that manage their own scrolling (pinned header + inner scroll) or must fill the
# viewport (the Share terminal) — these are added to the stack as-is; every other page is
# wrapped in a QScrollArea so it scrolls when too tall and doesn't pin the window's height.
_NO_WRAP = {"Dashboard", "System Health", "Inventory", "Share"}
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -55,7 +73,11 @@ class MainWindow(QMainWindow):
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
layout = QHBoxLayout(central) outer = QVBoxLayout(central)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
body = QWidget()
layout = QHBoxLayout(body)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
@@ -71,22 +93,30 @@ 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:
page = self._pages[name]
self._stack.addWidget(page if name in _NO_WRAP else self._scrollable(page))
content_layout.addWidget(self._stack) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
layout.addWidget(content, 1) layout.addWidget(content, 1)
outer.addWidget(body, 1)
outer.addWidget(self._build_footer())
self._worker = SamplerWorker(interval=interval) self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
@@ -124,6 +154,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,28 +192,42 @@ 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:
btn = QPushButton(name) header = QLabel(section.upper())
btn.setObjectName("NavButton") header.setObjectName("NavSection")
btn.setCheckable(True) v.addSpacing(8)
btn.setCursor(Qt.CursorShape.PointingHandCursor) v.addWidget(header)
btn.setChecked(i == 0) for name in names:
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx)) idx = _PAGES.index(name)
group.addButton(btn, i) btn = QPushButton(name)
v.addWidget(btn) btn.setObjectName("NavButton")
self._nav_buttons[name] = btn btn.setCheckable(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.setChecked(idx == 0)
btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
group.addButton(btn, idx)
v.addWidget(btn)
self._nav_buttons[name] = btn
v.addStretch(1) v.addStretch(1)
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>') live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
v.addWidget(live) v.addWidget(live)
version = QLabel(f"v{__version__}")
version.setObjectName("Muted")
v.addWidget(version)
changelog_btn = QPushButton("Changelog") changelog_btn = QPushButton("Changelog")
changelog_btn.setObjectName("LinkButton") changelog_btn.setObjectName("LinkButton")
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor) changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -189,6 +257,27 @@ class MainWindow(QMainWindow):
v.addWidget(self._restart_btn) v.addWidget(self._restart_btn)
return bar return bar
def _scrollable(self, page: QWidget) -> QScrollArea:
"""Wrap a page so it scrolls when taller than the window — and so the window can shrink
below the page's natural height instead of being pinned to it."""
area = QScrollArea()
area.setWidget(page)
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return area
def _build_footer(self) -> QFrame:
bar = QFrame()
bar.setObjectName("Footer")
h = QHBoxLayout(bar)
h.setContentsMargins(14, 5, 16, 5)
h.addStretch(1)
version = QLabel(f"RigDoctor v{__version__}")
version.setObjectName("Muted")
h.addWidget(version)
return bar
def _restart(self) -> None: def _restart(self) -> None:
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui") gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
if os.path.exists(gui): if os.path.exists(gui):
@@ -200,6 +289,9 @@ class MainWindow(QMainWindow):
def _apply_update(self) -> None: def _apply_update(self) -> None:
if not self._latest_tag: if not self._latest_tag:
return return
if updates.install_kind() != "pip": # apt/source: can't pip-update — show the command
QMessageBox.information(self, "Update RigDoctor", updates.update_hint())
return
box = QMessageBox(self) box = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}") box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor to {self._latest_tag}?") box.setText(f"Update RigDoctor to {self._latest_tag}?")
@@ -234,9 +326,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")
@@ -310,7 +457,7 @@ class MainWindow(QMainWindow):
self._update_label.setText("update check unavailable") self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE: elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>') self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}") self._update_btn.setText(f"Update to {tag}" if updates.install_kind() == "pip" else "How to update")
self._update_btn.setVisible(True) self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag: if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll self._notified_update_tag = tag # once per version, not every poll
@@ -319,6 +466,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))
+272 -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,12 +8,18 @@ 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,
QButtonGroup,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame, QFrame,
QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QRadioButton,
QSizePolicy, QSizePolicy,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
@@ -21,7 +27,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import installer, sysenv, uninstall, updates from ..core import ai, alerts, installer, service, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -49,18 +55,23 @@ _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
_ai_tested = Signal(object) # (ok, message) from an AI connectivity test
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)
self._ai_tested.connect(self._on_ai_tested)
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 +81,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)
@@ -80,12 +91,148 @@ class SetupPage(QWidget):
self._install_btn.clicked.connect(self._install) self._install_btn.clicked.connect(self._install)
self._refresh_btn = QPushButton("Re-check") self._refresh_btn = QPushButton("Re-check")
self._refresh_btn.clicked.connect(self._refresh) self._refresh_btn.clicked.connect(self._refresh)
wizard_btn = QPushButton("Run setup wizard")
wizard_btn.clicked.connect(self._run_wizard)
controls.addWidget(self._install_btn) controls.addWidget(self._install_btn)
controls.addWidget(self._refresh_btn) controls.addWidget(self._refresh_btn)
controls.addWidget(wizard_btn)
controls.addStretch(1) controls.addStretch(1)
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, critical kernel events (Xid, out-of-memory, disk I/O, PCIe), "
"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)
# AI assistant (M14, D24): explain diagnostics. Strictly opt-in — the model is only
# contacted when the user presses "Explain with AI"; this panel just configures it.
ai_card, ai_layout = _panel("AI assistant")
ai_desc = QLabel(
"Optionally let an AI explain your diagnostics in plain language. It runs <b>only</b> "
"when you press “Explain with AI” — never automatically. Choose a provider:\n"
"• Ollama — a local model on your machine (private, no key; needs Ollama running).\n"
"• Claude — Anthropic's API (higher quality; sends findings to Anthropic; needs a key)."
)
ai_desc.setObjectName("Muted")
ai_desc.setWordWrap(True)
ai_layout.addWidget(ai_desc)
prov_row = QHBoxLayout()
self._ai_group = QButtonGroup(self)
self._ai_ollama = QRadioButton("Ollama (local)")
self._ai_claude = QRadioButton("Claude (Anthropic)")
self._ai_group.addButton(self._ai_ollama)
self._ai_group.addButton(self._ai_claude)
self._ai_ollama.toggled.connect(self._on_ai_provider_changed)
prov_row.addWidget(self._ai_ollama)
prov_row.addWidget(self._ai_claude)
prov_row.addStretch(1)
ai_layout.addLayout(prov_row)
self._ai_model = QLineEdit()
self._ai_model.setPlaceholderText(
f"Model (e.g. {ai.OLLAMA_SUGGESTED_MODEL} for Ollama; blank = Claude default)")
ai_layout.addWidget(self._ai_model)
self._ai_endpoint = QLineEdit()
self._ai_endpoint.setPlaceholderText("Ollama server URL (default http://localhost:11434)")
ai_layout.addWidget(self._ai_endpoint)
self._ai_key = QLineEdit()
self._ai_key.setEchoMode(QLineEdit.EchoMode.Password)
self._ai_key.setPlaceholderText("Claude API key (stored in your keyring)")
ai_layout.addWidget(self._ai_key)
ai_btn_row = QHBoxLayout()
ai_save = QPushButton("Save")
ai_save.setObjectName("PrimaryButton")
ai_save.clicked.connect(self._save_ai)
self._ai_test_btn = QPushButton("Test")
self._ai_test_btn.clicked.connect(self._test_ai)
ai_btn_row.addWidget(ai_save)
ai_btn_row.addWidget(self._ai_test_btn)
ai_btn_row.addStretch(1)
ai_layout.addLayout(ai_btn_row)
self._ai_status = QLabel("")
self._ai_status.setObjectName("Muted")
self._ai_status.setWordWrap(True)
ai_layout.addWidget(self._ai_status)
root.addWidget(ai_card)
# Logging (M15): opt-in app logging + per-diagnostic storage (enables the Report bundle).
log_card, log_layout = _panel("Logging")
log_desc = QLabel(
"Save application logs and store each diagnostic in its own folder so you can review "
"or <b>Report</b> it. Off by default; everything stays on your machine.\n"
f"• Diagnostics: {config.DIAGNOSTICS_DIR}\n"
f"• Reports: {config.REPORTS_DIR}"
)
log_desc.setObjectName("Muted")
log_desc.setWordWrap(True)
log_layout.addWidget(log_desc)
self._logging = QCheckBox("Enable logging (application + diagnostics)")
self._logging.setChecked(config.load_config().get("logging_enabled", False))
self._logging.toggled.connect(self._toggle_logging)
log_layout.addWidget(self._logging)
root.addWidget(log_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 +262,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 +276,128 @@ 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._load_ai()
self._refresh_update_status() self._refresh_update_status()
# --- AI assistant (M14) ---------------------------------------------------
def _load_ai(self) -> None:
cfg = config.load_config()
prov = cfg.get("ai_provider", "")
self._ai_claude.setChecked(prov == "claude")
self._ai_ollama.setChecked(prov == "ollama")
self._ai_model.setText(cfg.get("ai_model", ""))
self._ai_endpoint.setText(cfg.get("ai_endpoint", "http://localhost:11434"))
if config.load_ai_key():
self._ai_key.setPlaceholderText("Claude API key saved — type to replace")
self._on_ai_provider_changed()
def _ai_provider(self) -> str:
if self._ai_claude.isChecked():
return "claude"
if self._ai_ollama.isChecked():
return "ollama"
return ""
def _on_ai_provider_changed(self) -> None:
prov = self._ai_provider()
self._ai_endpoint.setVisible(prov == "ollama")
self._ai_key.setVisible(prov == "claude")
self._ai_test_btn.setEnabled(prov != "")
if prov == "ollama" and not self._ai_model.text().strip():
self._ai_model.setText(ai.OLLAMA_SUGGESTED_MODEL) # suggested default; user can change
def _save_ai(self) -> None:
prov = self._ai_provider()
config.update_config(
ai_provider=prov,
ai_model=self._ai_model.text().strip(),
ai_endpoint=self._ai_endpoint.text().strip() or "http://localhost:11434",
)
if prov == "claude" and self._ai_key.text().strip():
config.save_ai_key(self._ai_key.text().strip())
self._ai_key.clear()
self._ai_key.setPlaceholderText("Claude API key saved — type to replace")
self._ai_status.setText("Saved." if prov else "Saved — no provider selected (AI stays off).")
def _test_ai(self) -> None:
self._save_ai()
self._ai_status.setText("Testing… contacting the provider.")
self._ai_test_btn.setEnabled(False)
threading.Thread(target=self._work_test_ai, daemon=True).start()
def _work_test_ai(self) -> None:
from ..core import ai
ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.")
self._ai_tested.emit((ok, msg))
def _on_ai_tested(self, result) -> None:
ok, msg = result
self._ai_test_btn.setEnabled(True)
self._ai_status.setText(("" if ok else "") + (msg[:200] if msg else ""))
def _toggle_logging(self, on: bool) -> None:
from ..core import applog
config.update_config(logging_enabled=on)
applog.setup(force=True) # attach/detach the file handler immediately
def _run_wizard(self) -> None:
from .setup_wizard import SetupWizard
SetupWizard(self).exec()
self._refresh()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
# --- 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)
+259
View File
@@ -0,0 +1,259 @@
"""First-run GUI setup wizard (M9): the full graphical installer/setup.
Bootstrap (Python venv + PySide6) is done by install.sh/.run; this wizard handles the rest
graphically environment summary pick dependency bundles install the missing apt packages
choose the recording trigger readiness summary. Shown automatically on first launch (until
`setup_done`), re-runnable from Settings, and launched by install.sh after a fresh install.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from .. import config
from ..core import catalog, installer, service, sysenv
class SetupWizard(QDialog):
_installed = Signal(int, str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("RigDoctor Setup")
self.resize(620, 560)
self.setObjectName("Page")
self._installed.connect(self._on_installed)
self._bundle_checks: dict[str, QCheckBox] = {}
self._installing = False
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 16)
root.setSpacing(14)
self._stack = QStackedWidget()
self._stack.addWidget(self._page_welcome()) # 0
self._stack.addWidget(self._page_bundles()) # 1
self._stack.addWidget(self._page_install()) # 2
self._stack.addWidget(self._page_trigger()) # 3
self._stack.addWidget(self._page_finish()) # 4
root.addWidget(self._stack, 1)
nav = QHBoxLayout()
self._skip_btn = QPushButton("Skip")
self._skip_btn.clicked.connect(self._skip)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(lambda: self._go(-1))
self._next_btn = QPushButton("Next")
self._next_btn.setObjectName("PrimaryButton")
self._next_btn.clicked.connect(lambda: self._go(1))
nav.addWidget(self._skip_btn)
nav.addStretch(1)
nav.addWidget(self._back_btn)
nav.addWidget(self._next_btn)
root.addLayout(nav)
self._index = 0
self._update_nav()
# --- pages -----------------------------------------------------------------
def _page(self, title: str, subtitle: str = "") -> tuple[QWidget, QVBoxLayout]:
page = QWidget()
v = QVBoxLayout(page)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(10)
head = QLabel(title)
head.setObjectName("PageTitle")
v.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Muted")
sub.setWordWrap(True)
v.addWidget(sub)
return page, v
def _page_welcome(self) -> QWidget:
page, v = self._page(
"Welcome to RigDoctor",
"Let's set up monitoring and diagnostics for your machine. This takes a minute and "
"needs no root for the app itself — only installing optional tools may ask for your "
"password.",
)
env = QLabel(
f"Detected:\n"
f" • Distro: {sysenv.distro_name()}\n"
f" • Package manager: {sysenv.package_manager() or 'none (apt required for extras)'}\n"
f" • GPU: {', '.join(sysenv.gpu_vendors()) or 'unknown'}"
)
env.setObjectName("Muted")
v.addWidget(env)
v.addStretch(1)
return page
def _page_bundles(self) -> QWidget:
page, v = self._page(
"Choose what to set up",
"Pick the optional tool bundles to install. Core monitoring, crash capture, and the "
"health report work without any of these — they just add capability.",
)
present = {c.id: ok for c, ok in installer.component_status()}
for bundle, comps in catalog.by_bundle().items():
missing = [c for c in comps if not present.get(c.id)]
names = ", ".join(c.name for c in comps)
tag = " — all installed ✓" if not missing else f"{len(missing)} to install"
cb = QCheckBox(f"{bundle}: {names}{tag}")
cb.setChecked(bool(missing)) # default-check bundles with something to add
cb.setEnabled(sysenv.package_manager() == "apt") # selectable even if already installed
self._bundle_checks[bundle] = cb
v.addWidget(cb)
if sysenv.package_manager() != "apt":
note = QLabel("Only apt is supported for installing tools, so these are read-only here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_install(self) -> QWidget:
page, v = self._page("Install tools", "Installing the selected packages…")
self._install_status = QLabel("")
self._install_status.setObjectName("Muted")
self._install_status.setWordWrap(True)
v.addWidget(self._install_status)
self._install_output = QTextEdit()
self._install_output.setObjectName("Report")
self._install_output.setReadOnly(True)
v.addWidget(self._install_output, 1)
return page
def _page_trigger(self) -> QWidget:
page, v = self._page(
"Recording trigger",
"When the crash logger runs. You can change this any time in Settings.",
)
self._trigger_group = QButtonGroup(self)
labels = {
"manual": "Manual — start/stop recording yourself.",
"always-on": "Always-on — a background service records continuously.",
"game-launch": "Game-launch — auto-record while a Steam game runs.",
}
for i, (mode, text) in enumerate(labels.items()):
rb = QRadioButton(text)
rb.setProperty("mode", mode)
rb.setChecked(mode == config.load_config().get("trigger_mode", "manual"))
self._trigger_group.addButton(rb, i)
v.addWidget(rb)
if not service.available():
note = QLabel("systemd --user isn't available, so always-on / game-launch can't be enabled here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_finish(self) -> QWidget:
page, v = self._page("You're all set", "")
self._finish_summary = QLabel("")
self._finish_summary.setObjectName("Muted")
self._finish_summary.setWordWrap(True)
v.addWidget(self._finish_summary)
v.addStretch(1)
return page
# --- navigation ------------------------------------------------------------
def _go(self, delta: int) -> None:
if self._installing:
return
new = self._index + delta
if new < 0:
return
if new >= self._stack.count(): # past the last page → finish
self._finish()
return
self._index = new
self._stack.setCurrentIndex(new)
self._update_nav()
if new == 2: # entering the install page
self._run_install()
elif new == 4: # entering the finish page
self._fill_summary()
def _update_nav(self) -> None:
self._back_btn.setEnabled(self._index > 0 and not self._installing)
last = self._index == self._stack.count() - 1
self._next_btn.setText("Finish" if last else "Next")
self._skip_btn.setVisible(not last)
def _selected_components(self):
present = {c.id: ok for c, ok in installer.component_status()}
chosen = []
for bundle, comps in catalog.by_bundle().items():
if self._bundle_checks.get(bundle) and self._bundle_checks[bundle].isChecked():
chosen += [c for c in comps if not present.get(c.id)]
return chosen
def _run_install(self) -> None:
packages = installer.missing_packages(self._selected_components())
if not packages:
self._install_status.setText("Nothing to install — your selected tools are already present.")
self._install_output.setVisible(False)
return
self._installing = True
self._update_nav()
self._next_btn.setEnabled(False)
self._install_status.setText("Installing… you may be asked for your password.")
self._install_output.setVisible(True)
self._install_output.setPlainText(f"Installing: {' '.join(packages)}\n")
threading.Thread(target=lambda: self._installed.emit(*installer.install_packages(packages)), daemon=True).start()
def _on_installed(self, rc: int, out: str) -> None:
self._installing = False
self._install_output.setPlainText(out[-4000:])
self._install_status.setText("Done." if rc == 0 else "Some packages may not have installed — see the log.")
self._next_btn.setEnabled(True)
self._update_nav()
def _fill_summary(self) -> None:
from ..core.sources import available_sources
status = installer.component_status()
present = sum(1 for _c, ok in status if ok)
sources = len(available_sources())
mode = self._chosen_mode()
self._finish_summary.setText(
f"• Optional tools present: {present}/{len(status)}\n"
f"• Sensor sources detected: {sources}\n"
f"• Recording trigger: {mode}\n\n"
"You can re-run this wizard or change anything from Settings."
)
def _chosen_mode(self) -> str:
btn = self._trigger_group.checkedButton()
return btn.property("mode") if btn else "manual"
def _finish(self) -> None:
mode = self._chosen_mode()
if service.available():
service.apply_mode(mode)
else:
config.update_config(trigger_mode=mode)
config.update_config(setup_done=True)
self.accept()
def _skip(self) -> None:
config.update_config(setup_done=True)
self.reject()
+100 -100
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._timer.start()
return
kind = data.get("type") # frames forwarded from a guest
if kind == "req_full":
# 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()
elif kind == "pty_in" and self._pty:
self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty:
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() self._start_pty()
elif not on: self._send_terminal_state()
self._stop_pty() return
self._send_terminal_state() kind = data.get("type")
if kind == "req_full": # a guest joined — tell them their typing permission
self._send_terminal_state()
elif kind == "pty_in" and self._pty and self._allow_input.isChecked():
self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty and self._allow_input.isChecked():
self._pty.set_size(int(data["rows"]), int(data["cols"]))
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)
+21
View File
@@ -68,6 +68,8 @@ QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
QLabel {{ background: transparent; }} QLabel {{ background: transparent; }}
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }} #Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
#Footer {{ background: {SIDEBAR}; border-top: 1px solid {CARD_BORDER}; }}
#Footer QLabel {{ font-size: 11px; }}
#AppTitle {{ font-size: 17px; font-weight: 800; }} #AppTitle {{ font-size: 17px; font-weight: 800; }}
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }} #AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
@@ -77,6 +79,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 {{
@@ -143,6 +146,24 @@ QCheckBox::indicator:hover {{ border-color: {ACCENT}; }}
QCheckBox::indicator:checked {{ QCheckBox::indicator:checked {{
background: {ACCENT}; border-color: {ACCENT}; image: url("{_CHECK}"); background: {ACCENT}; border-color: {ACCENT}; image: url("{_CHECK}");
}} }}
QCheckBox::indicator:disabled {{ border-color: #3a414d; background: #1c2026; }}
QCheckBox::indicator:checked:disabled {{ background: #2a6175; border-color: #2a6175; }}
QCheckBox:disabled {{ color: {MUTED}; }}
/* Radio buttons same dark treatment as checkboxes; the selected one gets a clear
accent dot (Fusion leaves these unstyled = the selection is invisible on dark). */
QRadioButton {{ spacing: 8px; background: transparent; }}
QRadioButton::indicator {{
width: 17px; height: 17px; border-radius: 9px;
border: 1px solid {MUTED}; background: #262b34;
}}
QRadioButton::indicator:hover {{ border-color: {ACCENT}; }}
QRadioButton::indicator:checked {{
border: 1px solid {ACCENT};
background: qradialgradient(cx:0.5, cy:0.5, radius:0.5, fx:0.5, fy:0.5,
stop:0 {ACCENT}, stop:0.5 {ACCENT}, stop:0.55 #262b34, stop:1 #262b34);
}}
QRadioButton:disabled {{ color: {MUTED}; }}
/* Dialogs (update prompt, changelog) match the dark theme so text is readable. */ /* Dialogs (update prompt, changelog) match the dark theme so text is readable. */
QDialog {{ background: {BG}; }} QDialog {{ background: {BG}; }}
+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)
+128 -3
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),
@@ -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
+164
View File
@@ -0,0 +1,164 @@
"""Tests for the M14 AI assistant: provider selection, grounding, parsing (no network)."""
import unittest
from unittest import mock
from rigdoctor.core import ai, ai_knowledge
class KnowledgeTests(unittest.TestCase):
def test_matches_xid_and_smart(self):
facts = ai_knowledge.relevant("Kernel: NVRM: Xid 79: GPU has fallen off the bus")
self.assertTrue(any("fallen off the bus" in f for f in facts))
def test_matches_smart_pending(self):
facts = ai_knowledge.relevant("SMART 197 Current_Pending_Sector = 8")
self.assertTrue(any("Pending Sector" in f for f in facts))
def test_no_match_returns_empty(self):
self.assertEqual(ai_knowledge.relevant("everything is fine"), [])
class ConfigStateTests(unittest.TestCase):
def _cfg(self, **over):
base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"}
base.update(over)
return base
def test_unconfigured_by_default(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg()):
self.assertFalse(ai.is_configured())
def test_ollama_needs_model(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="ollama")):
self.assertFalse(ai.is_configured())
with mock.patch.object(ai.config, "load_config",
return_value=self._cfg(ai_provider="ollama", ai_model="llama3.1")):
self.assertTrue(ai.is_configured())
def test_claude_needs_key(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \
mock.patch.object(ai.config, "load_ai_key", return_value=None):
self.assertFalse(ai.is_configured())
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \
mock.patch.object(ai.config, "load_ai_key", return_value="sk-ant-x"):
self.assertTrue(ai.is_configured())
def test_claude_default_model(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")):
self.assertEqual(ai.model(), ai.CLAUDE_DEFAULT_MODEL)
class PromptTests(unittest.TestCase):
def test_build_prompt_includes_facts_and_findings(self):
prompt = ai.build_prompt("Xid 79: GPU has fallen off the bus")
self.assertIn("Reference facts", prompt)
self.assertIn("Collected findings", prompt)
self.assertIn("fallen off the bus", prompt)
def test_format_findings(self):
class F:
severity, category, title, detail = "warn", "GPU", "Hot", "92C"
text = ai.format_findings([F()])
self.assertIn("[WARN] GPU: Hot — 92C", text)
def test_appid_glossary_resolves_known_ids(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"2694490": "Path of Exile 2"}):
glossary = ai.appid_glossary("Steam log: removed AppID 2694490 ... pid 130544")
self.assertIn("2694490 = Path of Exile 2", glossary)
def test_appid_glossary_ignores_unknown_ids(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"570": "Dota 2"}):
self.assertEqual(ai.appid_glossary("pid 130544 used 8192 MiB"), "") # not in library
def test_build_prompt_includes_glossary(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"2694490": "Path of Exile 2"}):
prompt = ai.build_prompt("AppID 2694490 launched")
self.assertIn("Path of Exile 2", prompt)
class ExplainTests(unittest.TestCase):
def _cfg(self, **over):
base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"}
base.update(over)
return base
def test_no_provider(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg()):
ok, msg = ai.explain("x")
self.assertFalse(ok)
self.assertIn("No AI provider", msg)
def test_ollama_parses_response(self):
with mock.patch.object(ai.config, "load_config",
return_value=self._cfg(ai_provider="ollama", ai_model="llama3.1")), \
mock.patch.object(ai, "_post", return_value={"response": "It's the PSU."}) as post:
ok, msg = ai.explain("Xid 79")
self.assertTrue(ok)
self.assertEqual(msg, "It's the PSU.")
self.assertIn("/api/generate", post.call_args[0][0])
def test_claude_parses_content_blocks(self):
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \
mock.patch.object(ai.config, "load_ai_key", return_value="sk-ant-x"), \
mock.patch.object(ai, "_post", return_value={"content": [
{"type": "text", "text": "Likely a failing disk."}]}) as post:
ok, msg = ai.explain("SMART 197")
self.assertTrue(ok)
self.assertEqual(msg, "Likely a failing disk.")
headers = post.call_args[0][2]
self.assertEqual(headers["anthropic-version"], ai.ANTHROPIC_VERSION)
self.assertEqual(headers["x-api-key"], "sk-ant-x")
class _FakeResp:
"""A context-managed iterable of byte lines, like urlopen() returns."""
def __init__(self, lines):
self._lines = [l.encode("utf-8") for l in lines]
def __enter__(self):
return iter(self._lines)
def __exit__(self, *a):
return False
class StreamTests(unittest.TestCase):
def _cfg(self, **over):
base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"}
base.update(over)
return base
def test_ollama_stream_accumulates_and_callbacks(self):
lines = ['{"response": "It is ", "done": false}',
'{"response": "the PSU.", "done": false}',
'{"response": "", "done": true}']
chunks = []
with mock.patch.object(ai.config, "load_config",
return_value=self._cfg(ai_provider="ollama", ai_model="qwen2.5:7b")), \
mock.patch.object(ai, "_stream_request", return_value=_FakeResp(lines)):
ok, full = ai.explain_stream("Xid 79", on_chunk=chunks.append)
self.assertTrue(ok)
self.assertEqual(full, "It is the PSU.")
self.assertEqual(chunks, ["It is ", "the PSU."])
def test_claude_stream_parses_sse(self):
lines = [
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Failing "}}',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"disk."}}',
'data: {"type":"message_stop"}',
]
chunks = []
with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \
mock.patch.object(ai.config, "load_ai_key", return_value="sk-ant-x"), \
mock.patch.object(ai, "_stream_request", return_value=_FakeResp(lines)):
ok, full = ai.explain_stream("SMART 197", on_chunk=chunks.append)
self.assertTrue(ok)
self.assertEqual(full, "Failing disk.")
self.assertEqual(chunks, ["Failing ", "disk."])
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -34,5 +34,35 @@ class AlertTests(unittest.TestCase):
m.assert_called_once() m.assert_called_once()
class KernelEventAlertTests(unittest.TestCase):
@mock.patch.object(alerts, "notify")
def test_kernel_event_fires_once_within_cooldown(self, m):
mon = alerts.AlertMonitor(cooldown=300.0, event_interval=0.0)
mon._last_kernel_scan = 0.0 # force a scan
with mock.patch("rigdoctor.core.syslogs.kernel_log",
return_value="NVRM: Xid (PCI:0000:01:00): 79, GPU has fallen off the bus"):
mon._scan_kernel_events()
mon._last_kernel_scan = 0.0 # force another scan — cooldown must suppress it
mon._scan_kernel_events()
self.assertEqual(m.call_count, 1)
self.assertIn("Xid", m.call_args[0][0])
@mock.patch.object(alerts, "notify")
def test_no_alert_when_kernel_log_empty(self, m):
mon = alerts.AlertMonitor(event_interval=0.0)
mon._last_kernel_scan = 0.0
with mock.patch("rigdoctor.core.syslogs.kernel_log", return_value=""):
mon._scan_kernel_events()
m.assert_not_called()
@mock.patch.object(alerts, "notify")
def test_scan_gated_by_interval(self, m):
mon = alerts.AlertMonitor(event_interval=9999.0) # just constructed → not due yet
with mock.patch("rigdoctor.core.syslogs.kernel_log", return_value="NVRM: Xid 79") as kl:
mon._scan_kernel_events()
kl.assert_not_called()
m.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+50
View File
@@ -57,5 +57,55 @@ class FinishTests(unittest.TestCase):
self.assertTrue(any(kind == "gpu-lost" for _ts, kind, _d in result.summary.events)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+104
View File
@@ -0,0 +1,104 @@
"""Tests for M15 per-diagnostic storage + Report bundles + app logging."""
import json
import tempfile
import unittest
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from unittest import mock
from rigdoctor.core import applog, diagstore
@dataclass
class FakeSummary:
start: float = 1.0
end: float = 2.0
samples: int = 3
events: list = field(default_factory=list)
@dataclass
class FakeFinding:
severity: str = "ok"
category: str = "GPU"
title: str = "Looks fine"
detail: str = "no issues"
@dataclass
class FakeResult:
game: str = "Path of Exile 2"
summary: FakeSummary = field(default_factory=FakeSummary)
findings: list = field(default_factory=lambda: [FakeFinding()])
dir: str | None = None
class StoreTests(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def test_disabled_returns_none(self):
with mock.patch.object(diagstore, "enabled", return_value=False):
self.assertIsNone(diagstore.store(FakeResult()))
def test_store_writes_artifacts(self):
with mock.patch.object(diagstore, "enabled", return_value=True), \
mock.patch("rigdoctor.render.render_summary", return_value="SUMMARY-TEXT"), \
mock.patch("rigdoctor.core.gamelogs.collect", return_value="LOG-TEXT"), \
mock.patch("rigdoctor.core.syslogs.collect", return_value="SYS-LOG"), \
mock.patch("rigdoctor.core.inventory.collect", return_value=[]), \
mock.patch.object(diagstore.config, "DIAGNOSTICS_DIR", self.tmp / "diagnostics"):
directory = diagstore.store(FakeResult())
self.assertTrue((directory / "result.json").exists())
self.assertTrue((directory / "report.txt").exists())
self.assertEqual((directory / "gamelogs.txt").read_text(), "LOG-TEXT")
self.assertEqual((directory / "syslogs.txt").read_text(), "SYS-LOG")
self.assertTrue((directory / "inventory.txt").exists()) # inventory included for debugging
data = json.loads((directory / "result.json").read_text())
self.assertEqual(data["game"], "Path of Exile 2")
self.assertEqual(len(data["findings"]), 1)
def test_record_ai_then_report_includes_ai_and_applog(self):
diag = self.tmp / "20260522-poe2"
diag.mkdir()
diagstore.record_ai(diag, provider="claude", model="claude-opus-4-7",
system="SYS", prompt="EXACT DATA SENT", response="THE REPLY")
ai_files = list((diag / "ai").glob("explain-*.json"))
self.assertTrue(ai_files)
record = json.loads(ai_files[0].read_text())
self.assertEqual(record["model"], "claude-opus-4-7")
self.assertEqual(record["data_sent_to_model"], "EXACT DATA SENT")
self.assertEqual(record["model_reply"], "THE REPLY")
app_log = self.tmp / "app.log"
app_log.write_text("app log line")
with mock.patch.object(diagstore.config, "REPORTS_DIR", self.tmp / "reports"), \
mock.patch.object(diagstore.config, "APP_LOG", app_log):
out = diagstore.make_report(diag)
self.assertTrue(out.exists())
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
self.assertTrue(any(n.endswith("app.log") for n in names))
self.assertTrue(any("/ai/explain-" in n for n in names))
class AppLogTests(unittest.TestCase):
def test_disabled_is_noop(self):
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": False}):
self.assertFalse(applog.setup(force=True))
def test_enabled_writes_file(self):
tmp = Path(tempfile.mkdtemp())
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": True}), \
mock.patch.object(applog.config, "STATE_DIR", tmp), \
mock.patch.object(applog.config, "APP_LOG", tmp / "app.log"):
self.assertTrue(applog.setup(force=True))
applog.get_logger("test").info("hello world")
applog.setup(force=True) # cleanup path: re-run detaches/reattaches cleanly
self.assertTrue((tmp / "app.log").exists())
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Tests for display detection (Mutter D-Bus JSON + xrandr parsers)."""
import unittest
from rigdoctor.core import displays
# Minimal Mutter GetCurrentState (busctl --json) shape: current mode is 60 Hz, panel max 165 Hz.
_MUTTER_60 = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-preferred":{"type":"b","data":true}}]'
'],{}]],[],{}]}'
)
_MUTTER_MAX = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{}]'
'],{}]],[],{}]}'
)
_XRANDR_60 = """Screen 0: minimum 8 x 8, current 3440 x 1440, maximum 16384 x 16384
DP-1 connected primary 3440x1440+0+0 (normal left inverted right x axis y axis) 800mm x 335mm
3440x1440 60.00*+ 165.00 100.00
2560x1440 165.00 60.00
HDMI-1 disconnected (normal left inverted right x axis y axis)
"""
class MutterParseTests(unittest.TestCase):
def test_parses_and_flags_higher_refresh(self):
mons = displays._parse_mutter(_MUTTER_60)
self.assertEqual(len(mons), 1)
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual(m.name, "Samsung LC34G55T") # PNP code SAM mapped
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_at_max_is_not_flagged(self):
m = displays._parse_mutter(_MUTTER_MAX)[0]
self.assertEqual(round(m.refresh), 165)
self.assertFalse(m.can_go_faster)
def test_garbage_returns_empty(self):
self.assertEqual(displays._parse_mutter("not json"), [])
self.assertEqual(displays._parse_mutter("{}"), [])
class XrandrParseTests(unittest.TestCase):
def test_current_and_max_refresh(self):
mons = displays._parse_xrandr(_XRANDR_60)
self.assertEqual(len(mons), 1) # disconnected output ignored
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_empty_returns_empty(self):
self.assertEqual(displays._parse_xrandr(""), [])
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
"""Tests for M14 game/Proton/Steam log collection."""
import os
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import gamelogs
class TailTests(unittest.TestCase):
def test_tail_returns_last_bytes(self):
path = Path(tempfile.mkdtemp()) / "x.log"
path.write_text("A" * 100 + "TAIL")
out = gamelogs._tail(path, 4)
self.assertEqual(out, "TAIL")
def test_tail_short_file(self):
path = Path(tempfile.mkdtemp()) / "x.log"
path.write_text("short")
self.assertEqual(gamelogs._tail(path, 9999), "short")
def test_tail_missing(self):
self.assertEqual(gamelogs._tail(Path("/nope/x.log"), 10), "")
class CollectTests(unittest.TestCase):
def test_collect_includes_proton_and_steam(self):
tmp = Path(tempfile.mkdtemp())
proton = tmp / "steam-570.log"
proton.write_text("err: vkd3d device lost")
console = tmp / "console-linux.txt"
console.write_text("Game removed AppID 570 ... exit")
with mock.patch.object(gamelogs, "_proton_logs", return_value=[proton]), \
mock.patch.object(gamelogs, "_steam_console", return_value=console):
out = gamelogs.collect()
self.assertIn("Proton log", out)
self.assertIn("vkd3d", out)
self.assertIn("Steam log", out)
self.assertIn("exit", out)
def test_collect_empty_when_none(self):
with mock.patch.object(gamelogs, "_proton_logs", return_value=[]), \
mock.patch.object(gamelogs, "_steam_console", return_value=None):
self.assertEqual(gamelogs.collect(), "")
class SinceScopingTests(unittest.TestCase):
def test_since_filter_keeps_window_only(self):
text = (
"[2026-05-22 13:00:00] old session line\n"
"[2026-05-22 13:00:01] another old line\n"
"[2026-05-22 14:30:00] new session launch\n"
"[2026-05-22 14:30:05] new session error\n"
)
since = time.mktime(time.strptime("2026-05-22 14:00:00", "%Y-%m-%d %H:%M:%S"))
out = gamelogs._since_filter(text, since)
self.assertIn("new session launch", out)
self.assertIn("new session error", out)
self.assertNotIn("old session", out)
def test_collect_skips_stale_proton_log(self):
tmp = Path(tempfile.mkdtemp())
proton = tmp / "steam-9999.log"
proton.write_text("stale proton output from an earlier game")
old_mtime = time.time() - 3600
os.utime(proton, (old_mtime, old_mtime))
since = time.time() - 60 # session started a minute ago
with mock.patch.object(gamelogs, "_proton_logs", return_value=[proton]), \
mock.patch.object(gamelogs, "_steam_console", return_value=None):
self.assertEqual(gamelogs.collect(since=since), "") # stale log excluded
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
"""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")
def test_setup_wizard_constructs(self):
from rigdoctor.gui.setup_wizard import SetupWizard
wizard = SetupWizard()
self.assertEqual(wizard._stack.count(), 5) # welcome/bundles/install/trigger/finish
self.assertTrue(wizard._bundle_checks)
if __name__ == "__main__":
unittest.main()
+56 -1
View File
@@ -1,8 +1,19 @@
"""Tests for the M4 health report's log scanner (synthetic input).""" """Tests for the M4 health report's log scanner (synthetic input)."""
import unittest import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core.health import CRITICAL, WARNING, run_health_checks, scan_journal_text from rigdoctor.core import displays, health
from rigdoctor.core.health import (
CRITICAL,
INFO,
WARNING,
check_displays,
check_pcie_links,
run_health_checks,
scan_journal_text,
)
class HealthScanTests(unittest.TestCase): class HealthScanTests(unittest.TestCase):
@@ -42,5 +53,49 @@ class HealthScanTests(unittest.TestCase):
self.assertEqual(ranks, sorted(ranks)) self.assertEqual(ranks, sorted(ranks))
class PcieLinkCheckTests(unittest.TestCase):
def _with_link(self, cur_g, cur_w, max_g, max_w):
# one fake NVMe controller returning the given link tuple
return (mock.patch("rigdoctor.core.inventory.nvme_controllers",
return_value=[("nvme0", Path("/x"))]),
mock.patch("rigdoctor.core.inventory.read_link",
return_value=(cur_g, cur_w, max_g, max_w)))
def test_reduced_width_is_a_warning_about_lane_sharing(self):
ctrls, link = self._with_link(4, "2", 4, "4") # Gen4 x2 but supports x4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, WARNING)
self.assertIn("lane-sharing", findings[0].detail)
def test_reduced_speed_only_is_info(self):
ctrls, link = self._with_link(3, "4", 4, "4") # Gen3 x4 but supports Gen4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
def test_full_speed_no_finding(self):
ctrls, link = self._with_link(4, "4", 4, "4")
with ctrls, link:
self.assertEqual(check_pcie_links(), [])
class DisplayCheckTests(unittest.TestCase):
def test_lower_than_max_refresh_is_flagged(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 60.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
findings = check_displays()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
self.assertIn("165", findings[0].title)
def test_at_max_refresh_no_finding(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 165.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
self.assertEqual(check_displays(), [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -1
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from rigdoctor.core import installer from rigdoctor.core import catalog, installer
from rigdoctor.core.catalog import Component from rigdoctor.core.catalog import Component
from rigdoctor.core.updates import is_newer from rigdoctor.core.updates import is_newer
@@ -31,6 +31,13 @@ class InstallerTests(unittest.TestCase):
rc, _ = installer.install_packages([]) rc, _ = installer.install_packages([])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
def test_by_bundle_groups_all_components(self):
groups = catalog.by_bundle()
flat = [c for comps in groups.values() for c in comps]
self.assertEqual(len(flat), len(catalog.COMPONENTS))
self.assertIn("Gaming", groups)
self.assertIn("Diagnostics", groups)
class UpdateTests(unittest.TestCase): class UpdateTests(unittest.TestCase):
def test_is_newer(self): def test_is_newer(self):
+28
View File
@@ -1,6 +1,8 @@
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system).""" """Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
import tempfile
import unittest import unittest
from pathlib import Path
from rigdoctor.core import inventory from rigdoctor.core import inventory
from rigdoctor.core.inventory import Section from rigdoctor.core.inventory import Section
@@ -26,5 +28,31 @@ class InventoryTests(unittest.TestCase):
self.assertIn("- **Model:** Test CPU", md) self.assertIn("- **Model:** Test CPU", md)
class PcieLinkTests(unittest.TestCase):
def test_gen_mapping(self):
self.assertEqual(inventory._gen("16.0 GT/s PCIe"), 4)
self.assertEqual(inventory._gen("8.0 GT/s PCIe"), 3)
self.assertIsNone(inventory._gen(""))
def _fake_dev(self, cur_s, cur_w, max_s, max_w) -> Path:
d = Path(tempfile.mkdtemp())
(d / "current_link_speed").write_text(cur_s)
(d / "current_link_width").write_text(cur_w)
(d / "max_link_speed").write_text(max_s)
(d / "max_link_width").write_text(max_w)
return d
def test_link_at_full_speed(self):
dev = self._fake_dev("16.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen4 x4")
def test_link_downtrained_flags_capability(self):
dev = self._fake_dev("8.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen3 x4 (capable of Gen4 x4)")
def test_non_nvme_has_no_link(self):
self.assertEqual(inventory._nvme_link("sda"), "")
if __name__ == "__main__": if __name__ == "__main__":
unittest.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()
+114
View File
@@ -0,0 +1,114 @@
"""Tests for M15 session-scoped system-log collection (kernel + coredumps)."""
import unittest
from unittest import mock
from rigdoctor.core import syslogs
class KernelLogTests(unittest.TestCase):
def test_passes_since_and_tails(self):
with mock.patch("shutil.which", return_value="/usr/bin/journalctl"), \
mock.patch.object(syslogs, "_run", return_value="X" * 100 + "TAILLINE") as run:
out = syslogs.kernel_log(since=1_000_000_000, max_bytes=8)
self.assertEqual(out, "TAILLINE")
cmd = run.call_args[0][0]
self.assertIn("-k", cmd)
self.assertIn("--since", cmd)
def test_missing_tool_returns_empty(self):
with mock.patch("shutil.which", return_value=None):
self.assertEqual(syslogs.kernel_log(), "")
class CoredumpTests(unittest.TestCase):
def test_empty_when_no_coredumps(self):
with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \
mock.patch.object(syslogs, "_run", return_value="No coredumps found."):
self.assertEqual(syslogs.coredumps(), "")
def test_returns_list(self):
with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \
mock.patch.object(syslogs, "_run", return_value="TIME PID SIG EXE\n... SEGV PathOfExile"):
out = syslogs.coredumps()
self.assertIn("PathOfExile", out)
class NvidiaTests(unittest.TestCase):
def test_missing_tool(self):
with mock.patch("shutil.which", return_value=None):
self.assertEqual(syslogs.nvidia_snapshot(), "")
def test_snapshot_head_truncated(self):
with mock.patch("shutil.which", return_value="/usr/bin/nvidia-smi"), \
mock.patch.object(syslogs, "_run", return_value="DRIVER\n" + "x" * 99999):
out = syslogs.nvidia_snapshot(max_bytes=10)
self.assertEqual(out, "DRIVER\nxxx") # head, not tail
class DisplayTests(unittest.TestCase):
def test_session_type_env(self):
with mock.patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.assertEqual(syslogs._session_type(), "wayland")
def test_x11_tails_xorg_log(self):
import tempfile
from pathlib import Path
log = Path(tempfile.mkdtemp()) / "Xorg.0.log"
log.write_text("(EE) NVIDIA(GPU-0): something failed")
with mock.patch.object(syslogs, "_session_type", return_value="x11"), \
mock.patch.object(syslogs, "_xorg_log", return_value=log):
out = syslogs.display_log()
self.assertIn("(EE) NVIDIA", out)
def test_wayland_uses_user_journal(self):
with mock.patch.object(syslogs, "_session_type", return_value="wayland"), \
mock.patch("shutil.which", return_value="/usr/bin/journalctl"), \
mock.patch.object(syslogs, "_run", return_value="gnome-shell: GPU error") as run:
out = syslogs.display_log(since=1_000_000_000)
self.assertIn("GPU error", out)
cmd = run.call_args[0][0]
self.assertIn("--user", cmd)
self.assertTrue(any(a.startswith("_COMM=") for a in cmd))
class ScanCriticalTests(unittest.TestCase):
def test_matches_each_category(self):
text = "\n".join([
"NVRM: Xid (PCI:0000:01:00): 79, GPU has fallen off the bus",
"Out of memory: Killed process 1234 (PathOfExile)",
"mce: [Hardware Error]: CPU 0",
"pcieport 0000:00:01.0: AER: Corrected error received",
"blk_update_request: I/O error, dev sda, sector 99",
"this is a perfectly normal line",
])
labels = {label for label, _ in syslogs.scan_critical(text)}
self.assertEqual(labels, {
"GPU error (Xid)", "Out of memory", "CPU machine-check",
"PCIe error", "Disk I/O error"})
def test_clean_log_no_events(self):
self.assertEqual(syslogs.scan_critical("usb 1-2: new high-speed device\nsystemd: started"), [])
class CollectTests(unittest.TestCase):
def test_collect_combines_sections(self):
with mock.patch.object(syslogs, "kernel_log", return_value="NVRM: Xid 79"), \
mock.patch.object(syslogs, "coredumps", return_value="game SIGSEGV"), \
mock.patch.object(syslogs, "nvidia_snapshot", return_value="Driver Version 595"), \
mock.patch.object(syslogs, "display_log", return_value="(EE) NVIDIA"):
out = syslogs.collect()
for needle in ("Kernel log", "Xid 79", "Crashed processes", "SIGSEGV",
"NVIDIA snapshot", "595", "Display server log"):
self.assertIn(needle, out)
def test_collect_empty_when_nothing(self):
with mock.patch.object(syslogs, "kernel_log", return_value=""), \
mock.patch.object(syslogs, "coredumps", return_value=""), \
mock.patch.object(syslogs, "nvidia_snapshot", return_value=""), \
mock.patch.object(syslogs, "display_log", return_value=""):
self.assertEqual(syslogs.collect(), "")
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()
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the M13 updater: install detection + routing the update to the right method."""
import unittest
from unittest import mock
from rigdoctor.core import updates
class InstallKindTests(unittest.TestCase):
def setUp(self):
updates.install_kind.cache_clear()
def tearDown(self):
updates.install_kind.cache_clear()
def test_apt_when_dpkg_owns_the_package(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=True):
self.assertEqual(updates.install_kind(), "apt")
def test_pip_when_running_in_a_venv(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=False), \
mock.patch.object(updates.sys, "prefix", "/opt/venv"), \
mock.patch.object(updates.sys, "base_prefix", "/usr"):
self.assertEqual(updates.install_kind(), "pip")
class ApplyUpdateRoutingTests(unittest.TestCase):
def test_apt_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="apt"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertEqual(rc, 1)
self.assertIn("apt install --only-upgrade", out)
run.assert_not_called()
def test_dev_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="dev"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertIn("git pull", out)
run.assert_not_called()
def test_pip_install_runs_pip(self):
proc = mock.Mock(returncode=0, stdout="Successfully installed", stderr="")
with mock.patch.object(updates, "install_kind", return_value="pip"), \
mock.patch.object(updates, "load_token", return_value="TOK"), \
mock.patch("subprocess.run", return_value=proc) as run:
rc, _out = updates.apply_update("v1.2.3")
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertIn("pip", cmd)
self.assertIn("install", cmd)
class UpdateHintTests(unittest.TestCase):
def test_apt_hint_names_the_apt_command(self):
self.assertIn("apt install --only-upgrade rigdoctor", updates.update_hint("apt"))
def test_dev_hint_says_git_pull(self):
self.assertIn("git pull", updates.update_hint("dev"))
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()