Compare commits

...

8 Commits

Author SHA1 Message Date
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
23 changed files with 1073 additions and 229 deletions
+46
View File
@@ -5,6 +5,52 @@ 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.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 ## [0.18.2] - 2026-05-22
### Fixed ### Fixed
- **GUI wouldn't start** (0.18.0 regression): the recording indicator used a wrong relative - **GUI wouldn't start** (0.18.0 regression): the recording indicator used a wrong relative
+25 -15
View File
@@ -11,12 +11,12 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ | | M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ |
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | | M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | |
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ | | M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ |
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ | | M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 | | M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | |
| M9 | Installer | (meta) | none | all | P1 | 🟨 | | M9 | Installer | (meta) | none | all | P1 | 🟨 |
| M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 | | M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | 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 | ✅ |
@@ -41,7 +41,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
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
@@ -64,19 +67,26 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
banner → results dialog). **Auto-capture** via the D12 wrapper (`rigdoctor wrap %command%`, 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 `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 without a clean stop) and flagged on next launch with a crash-boot kernel-log analysis
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam (`pending_crash`/`analyze_crash` + `health.check_previous_boot`). **Non-Steam launchers**
launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher. (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`),
+10 -5
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,13 +34,17 @@ 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 + GUI done* (`core/diagnostic.py`, `rigdoctor shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.18.2" version = "0.22.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.18.2" __version__ = "0.22.0"
+33 -32
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:
@@ -438,34 +431,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
@@ -516,8 +516,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)
+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
+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())
+19 -2
View File
@@ -58,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 --------------------------------------------------
@@ -313,7 +314,8 @@ def cached_games() -> list[Game]:
cache = load_cache() cache = load_cache()
if not cache: if not cache:
return [] return []
return [Game(**{k: g.get(k) for k in Game.__dataclass_fields__}) for g in cache.get("games", [])] # Only pass keys present in the record so dataclass defaults fill any new fields.
return [Game(**{k: g[k] for k in Game.__dataclass_fields__ if k in g}) for g in cache.get("games", [])]
def rescan(cfg: dict | None = None) -> ScanResult: def rescan(cfg: dict | None = None) -> ScanResult:
@@ -353,6 +355,21 @@ 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: def launch_game(appid: str) -> bool:
"""Best-effort: ask Steam to launch a game by appid (steam:// URL). Non-blocking.""" """Best-effort: ask Steam to launch a game by appid (steam:// URL). Non-blocking."""
if not appid: if not appid:
+7 -1
View File
@@ -30,7 +30,13 @@ def main(argv: list[str] | None = None) -> int:
interval = float(load_config().get("interval", 1.0)) interval = float(load_config().get("interval", 1.0))
window = MainWindow(interval=interval) window = MainWindow(interval=interval)
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()
return app.exec() return app.exec()
+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)
+17 -5
View File
@@ -88,6 +88,7 @@ class GamesPage(QWidget):
self._diag_done.connect(self._on_diag_done) 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 self._diag_game: str | None = None
root = QVBoxLayout(self) root = QVBoxLayout(self)
@@ -213,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()}
@@ -223,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)
@@ -265,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)
@@ -293,12 +300,17 @@ 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=g.appid, appid=appid,
on_diagnose=self._start_diagnostic, on_diagnose=self._start_diagnostic,
)) ))
self._list.addStretch(1) self._list.addStretch(1)
+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)
+111 -24
View File
@@ -6,9 +6,10 @@ 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,
@@ -20,6 +21,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QStackedWidget, QStackedWidget,
QSystemTrayIcon,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -33,14 +35,23 @@ 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 .inventory_page import InventoryPage from .inventory_page import InventoryPage
from .notifications_page import NotificationsPage
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, CRIT, GOOD, MUTED, TEXT 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", "Inventory", "Setup", "Notifications", "Share"] # Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
# filled in this order, so _PAGES.index(name) is the stack index.
_NAV = [
("Monitor", ["Dashboard"]),
("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
("System", ["Inventory"]),
("App", ["Settings", "Share"]),
]
_PAGES = [name for _section, names in _NAV for name in names]
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -75,18 +86,21 @@ class MainWindow(QMainWindow):
self.environment_page = EnvironmentPage() self.environment_page = EnvironmentPage()
self.inventory_page = InventoryPage() 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.inventory_page) # 5 Inventory "System Health": self.health_page,
self._stack.addWidget(self.setup_page) # 6 Setup "Tuning": self.environment_page,
self._stack.addWidget(self.notifications_page) # 7 Notifications "Inventory": self.inventory_page,
self._stack.addWidget(self.share_page) # 8 Share "Settings": self.setup_page,
"Share": self.share_page,
}
for name in _PAGES:
self._stack.addWidget(self._pages[name])
content_layout.addWidget(self._stack) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
@@ -136,6 +150,22 @@ class MainWindow(QMainWindow):
self._rec_timer.start() self._rec_timer.start()
self._update_recording() 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")
@@ -166,16 +196,22 @@ class MainWindow(QMainWindow):
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>')
@@ -262,6 +298,44 @@ class MainWindow(QMainWindow):
self.health_page._run() self.health_page._run()
self.inventory_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: def _update_recording(self) -> None:
from ..core import diagnostic from ..core import diagnostic
@@ -359,6 +433,19 @@ class MainWindow(QMainWindow):
self._update_label.setText("up-to-date") self._update_label.setText("up-to-date")
def closeEvent(self, event) -> None: # noqa: N802 (Qt override) def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
# With a tray, closing the window hides it (the app keeps running for the tray
# readouts + any capture); Quit from the tray menu exits for real.
if self._tray is not None and not self._quitting:
event.ignore()
self.hide()
if not self._tray_hint_shown:
self._tray_hint_shown = True
self._tray.showMessage(
"RigDoctor",
"Still running in the tray — right-click the icon for actions or Quit.",
QSystemTrayIcon.MessageIcon.Information, 5000,
)
return
self._worker.stop() self._worker.stop()
self.share_page.shutdown() self.share_page.shutdown()
super().closeEvent(event) super().closeEvent(event)
-108
View File
@@ -1,108 +0,0 @@
"""Notifications page (M8 config): user-configurable alert settings."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..config import load_config, update_config
from ..core import alerts
class NotificationsPage(QWidget):
changed = Signal() # settings saved — main window re-applies them live
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
title = QLabel("Notifications")
title.setObjectName("PageTitle")
root.addWidget(title)
card = QFrame()
card.setObjectName("Card")
v = QVBoxLayout(card)
v.setContentsMargins(16, 14, 16, 14)
v.setSpacing(10)
head = QLabel("Alerts")
head.setStyleSheet("font-weight: 700; background: transparent;")
v.addWidget(head)
self._enabled = QCheckBox("Enable desktop notifications")
v.addWidget(self._enabled)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setColumnStretch(2, 1)
self._gpu = self._spin()
self._cpu = self._spin()
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
grid.addWidget(self._gpu, 0, 1)
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
grid.addWidget(self._cpu, 1, 1)
v.addLayout(grid)
note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
buttons = QHBoxLayout()
save = QPushButton("Save")
save.setObjectName("PrimaryButton")
save.clicked.connect(self._save)
test = QPushButton("Send test")
test.clicked.connect(self._test)
buttons.addWidget(save)
buttons.addWidget(test)
buttons.addStretch(1)
v.addLayout(buttons)
self._status = QLabel("")
self._status.setObjectName("Muted")
v.addWidget(self._status)
root.addWidget(card)
root.addStretch(1)
self._load()
@staticmethod
def _spin() -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(40, 110)
spin.setDecimals(0)
spin.setSingleStep(1)
spin.setSuffix(" °C")
return spin
def _load(self) -> None:
cfg = load_config()
self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
def _save(self) -> None:
update_config(
alerts_enabled=self._enabled.isChecked(),
gpu_temp_alert=self._gpu.value(),
cpu_temp_alert=self._cpu.value(),
)
self.changed.emit()
self._status.setText("Saved.")
def _test(self) -> None:
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
+73 -28
View File
@@ -1,16 +1,19 @@
"""Recording & Logs page (M3 in the GUI): start/stop/status + post-crash report. """Recordings page (M3 in the GUI): recorder controls + view/report any captured log.
Drives the same background recorder as the CLI via core.reccontrol, so the GUI and Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
`rigdoctor record ` are interchangeable. captured data the always-on log, the last guided diagnostic, and a preserved hard-crash
(which can be analyzed in place). One place to see what was captured and what it means.
""" """
from __future__ import annotations from __future__ import annotations
import threading
import time import time
from PySide6.QtCore import Qt, QTimer, QUrl from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QFont from PySide6.QtGui import QDesktopServices, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@@ -25,6 +28,7 @@ from .. import config
from ..core import reccontrol from ..core import reccontrol
from ..core.crashlog import summarize from ..core.crashlog import summarize
from ..render import format_headline, render_summary from ..render import format_headline, render_summary
from .diagnostic_dialog import DiagnosticDialog
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -45,31 +49,30 @@ def _fmt_time(value, fmt="%Y-%m-%d %H:%M:%S") -> str:
class RecorderPage(QWidget): class RecorderPage(QWidget):
_analyzed = Signal(object) # DiagnosticResult from a crash analysis
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._analyzed.connect(self._show_analysis)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16) root.setSpacing(16)
title = QLabel("Recording") title = QLabel("Recordings")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
root.addWidget(title) root.addWidget(title)
# --- Status + controls ------------------------------------------------- # --- Status + controls -------------------------------------------------
status_card, status_layout = _panel("Status") status_card, status_layout = _panel("Status")
self._state = QLabel("○ Not recording") self._state = QLabel("○ Not recording")
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;") self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
status_layout.addWidget(self._state) status_layout.addWidget(self._state)
self._info = QLabel("") self._info = QLabel("")
self._info.setObjectName("Muted") self._info.setObjectName("Muted")
status_layout.addWidget(self._info) status_layout.addWidget(self._info)
self._latest = QLabel("") self._latest = QLabel("")
status_layout.addWidget(self._latest) status_layout.addWidget(self._latest)
self._warn = QLabel("") self._warn = QLabel("")
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;") self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
self._warn.setVisible(False) self._warn.setVisible(False)
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
status_layout.addLayout(controls) status_layout.addLayout(controls)
root.addWidget(status_card) root.addWidget(status_card)
# --- Report ------------------------------------------------------------ # --- Captured logs -----------------------------------------------------
report_card = QFrame() report_card, report_layout = _panel("Captured logs")
report_card.setObjectName("Card")
report_layout = QVBoxLayout(report_card)
report_layout.setContentsMargins(16, 14, 16, 14)
report_layout.setSpacing(10)
header = QHBoxLayout() header = QHBoxLayout()
report_title = QLabel("Post-crash report") header.addWidget(QLabel("Show:"))
report_title.setStyleSheet("font-weight: 700; background: transparent;") self._source = QComboBox()
header.addWidget(report_title) self._source.currentIndexChanged.connect(self._load_report)
header.addStretch(1) header.addWidget(self._source, 1)
self._analyze_btn = QPushButton("Analyze crash")
self._analyze_btn.setObjectName("ActionButton")
self._analyze_btn.clicked.connect(self._analyze_crash)
self._analyze_btn.setVisible(False)
header.addWidget(self._analyze_btn)
refresh_btn = QPushButton("Refresh") refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self._load_report) refresh_btn.clicked.connect(self._refresh_sources)
header.addWidget(refresh_btn) header.addWidget(refresh_btn)
report_layout.addLayout(header) report_layout.addLayout(header)
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
report_layout.addWidget(self._report) report_layout.addWidget(self._report)
root.addWidget(report_card, 1) root.addWidget(report_card, 1)
# Poll recorder status once a second (reflects CLI-driven sessions too).
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setInterval(1000) self._timer.setInterval(1000)
self._timer.timeout.connect(self._refresh_status) self._timer.timeout.connect(self._refresh_status)
self._timer.start() self._timer.start()
self._refresh_status() self._refresh_status()
self._load_report() self._refresh_sources()
# --- actions --------------------------------------------------------------- # --- actions ---------------------------------------------------------------
def _on_start(self) -> None: def _on_start(self) -> None:
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
self._stop_btn.setEnabled(False) self._stop_btn.setEnabled(False)
reccontrol.stop_background() reccontrol.stop_background()
QTimer.singleShot(600, self._refresh_status) QTimer.singleShot(600, self._refresh_status)
QTimer.singleShot(900, self._load_report) QTimer.singleShot(900, self._refresh_sources)
def _open_folder(self) -> None: def _open_folder(self) -> None:
config.LOG_DIR.mkdir(parents=True, exist_ok=True) config.LOG_DIR.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
# --- captured logs ---------------------------------------------------------
def _refresh_sources(self) -> None:
from ..core import diagnostic
current = self._source.currentData()
self._source.blockSignals(True)
self._source.clear()
self._source.addItem("Always-on capture", str(config.LOG_FILE))
if config.DIAG_LOG.exists():
self._source.addItem("Last diagnostic", str(config.DIAG_LOG))
if config.DIAG_CRASH.exists():
self._source.addItem("Crash (unanalyzed)", str(config.DIAG_CRASH))
# keep the previous selection if it's still present
idx = self._source.findData(current) if current else -1
self._source.setCurrentIndex(idx if idx >= 0 else 0)
self._source.blockSignals(False)
self._analyze_btn.setVisible(diagnostic.pending_crash() is not None)
self._load_report()
def _load_report(self) -> None:
path = self._source.currentData() or str(config.LOG_FILE)
summary = summarize(path, last_n=10)
self._report.setPlainText(render_summary(summary, log_path=path))
def _analyze_crash(self) -> None:
self._analyze_btn.setEnabled(False)
self._report.setPlainText("Analyzing the crash (final readings + system logs)…")
threading.Thread(target=self._work_analyze, daemon=True).start()
def _work_analyze(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.analyze_crash()
except Exception:
result = None
self._analyzed.emit(result)
def _show_analysis(self, result) -> None:
self._analyze_btn.setEnabled(True)
if result is not None:
DiagnosticDialog(result, self).exec()
self._refresh_sources()
# --- refresh --------------------------------------------------------------- # --- refresh ---------------------------------------------------------------
def _refresh_status(self) -> None: def _refresh_status(self) -> None:
pid = reccontrol.running_pid() pid = reccontrol.running_pid()
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
self._interval.setEnabled(not running) self._interval.setEnabled(not running)
if status: if status:
game = status.get("game")
game_line = f"Game: {game} " if game else ""
self._info.setText( self._info.setText(
f"Samples: {status.get('samples', 0)} " f"{game_line}Samples: {status.get('samples', 0)} "
f"Started: {_fmt_time(status.get('started'))} " f"Started: {_fmt_time(status.get('started'))} "
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n" f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
f"Log: {status.get('log', config.LOG_FILE)}" f"Log: {status.get('log', config.LOG_FILE)}"
@@ -179,7 +228,3 @@ class RecorderPage(QWidget):
self._info.setText("No recording yet. Press “Start recording”.") self._info.setText("No recording yet. Press “Start recording”.")
self._latest.setText("") self._latest.setText("")
self._warn.setVisible(False) self._warn.setVisible(False)
def _load_report(self) -> None:
summary = summarize(config.LOG_FILE, last_n=10)
self._report.setPlainText(render_summary(summary, log_path=config.LOG_FILE))
+73 -5
View File
@@ -1,4 +1,4 @@
"""Setup page (M9 in the GUI): show environment + optional components, install missing.""" """Settings page: components/deps, alerts (M8), account access (token), and uninstall."""
from __future__ import annotations from __future__ import annotations
@@ -8,7 +8,10 @@ from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QCheckBox,
QDoubleSpinBox,
QFrame, QFrame,
QGridLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import installer, sysenv, uninstall, updates from ..core import alerts, installer, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -49,6 +52,7 @@ _BACKEND_DESC = {
class SetupPage(QWidget): class SetupPage(QWidget):
_installed = Signal(int, str) _installed = Signal(int, str)
_upd_state = Signal(object) _upd_state = Signal(object)
changed = Signal() # alert settings saved — main window re-applies them live
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -60,7 +64,7 @@ class SetupPage(QWidget):
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 +74,7 @@ class SetupPage(QWidget):
env_layout.addWidget(self._env) env_layout.addWidget(self._env)
root.addWidget(env_card) root.addWidget(env_card)
comp_card, comp_layout = _panel("Optional components") comp_card, comp_layout = _panel("Components & dependencies")
self._components = QVBoxLayout() self._components = QVBoxLayout()
self._components.setSpacing(6) self._components.setSpacing(6)
comp_layout.addLayout(self._components) comp_layout.addLayout(self._components)
@@ -86,6 +90,39 @@ class SetupPage(QWidget):
comp_layout.addLayout(controls) comp_layout.addLayout(controls)
root.addWidget(comp_card) root.addWidget(comp_card)
# Alerts (M8) — folded in from the old Notifications page.
alerts_card, alerts_layout = _panel("Notifications")
self._alerts_enabled = QCheckBox("Enable desktop notifications")
alerts_layout.addWidget(self._alerts_enabled)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setColumnStretch(2, 1)
self._gpu_alert = self._spin()
self._cpu_alert = self._spin()
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
grid.addWidget(self._gpu_alert, 0, 1)
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
grid.addWidget(self._cpu_alert, 1, 1)
alerts_layout.addLayout(grid)
alerts_note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
alerts_note.setObjectName("Muted")
alerts_note.setWordWrap(True)
alerts_layout.addWidget(alerts_note)
alerts_buttons = QHBoxLayout()
save_alerts = QPushButton("Save")
save_alerts.setObjectName("PrimaryButton")
save_alerts.clicked.connect(self._save_alerts)
test_alerts = QPushButton("Send test")
test_alerts.clicked.connect(self._test_alerts)
alerts_buttons.addWidget(save_alerts)
alerts_buttons.addWidget(test_alerts)
alerts_buttons.addStretch(1)
self._alerts_status = QLabel("")
self._alerts_status.setObjectName("Muted")
alerts_buttons.addWidget(self._alerts_status)
alerts_layout.addLayout(alerts_buttons)
root.addWidget(alerts_card)
# 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 +152,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 +166,39 @@ class SetupPage(QWidget):
root.addLayout(danger) root.addLayout(danger)
self._refresh() self._refresh()
self._load_alerts()
self._refresh_update_status() self._refresh_update_status()
# --- 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)
+1
View File
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
}} }}
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }} QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }} QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
QLabel#NavSection {{ color: {MUTED}; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 12px 0; }}
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }} #Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
QPushButton#CardHeader {{ QPushButton#CardHeader {{
+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)
+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
+69
View File
@@ -0,0 +1,69 @@
"""GUI smoke tests: construct the real widgets so a startup crash fails the build.
These run headless (offscreen) and skip cleanly if PySide6 isn't installed (the core/CLI
test suite stays Qt-free). Constructing MainWindow is the check that would have caught the
0.18.0 bad-import regression that broke launch.
"""
import os
import time
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication, QWidget
HAVE_QT = True
except ImportError:
HAVE_QT = False
@unittest.skipUnless(HAVE_QT, "PySide6 not installed")
class GuiSmokeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def test_main_window_constructs(self):
from unittest import mock
from rigdoctor.core import updates
from rigdoctor.gui import main_window as mw
# Avoid construction side effects: no pkexec elevation, no network update check.
with mock.patch("rigdoctor.core.elevation.available", return_value=False), \
mock.patch.object(updates, "update_state", return_value=(updates.UP_TO_DATE, None, "")):
window = mw.MainWindow()
try:
self.assertEqual(len(window._nav_buttons), len(mw._PAGES))
self.assertEqual(set(window._nav_buttons), set(mw._PAGES))
finally:
window._worker.stop()
def test_tray_readouts_update(self):
from rigdoctor.core.sample import Reading, Sample
from rigdoctor.gui.tray import TrayIcon
class StubWindow(QWidget):
def show_dashboard(self): ...
def show_page(self, name): ...
def run_diagnostic(self, name, appid): ...
def quit_app(self): ...
tray = TrayIcon(StubWindow(), QIcon())
tray.update_sample(Sample(time.time(), [
Reading("gpu", "temp", 72.0, "°C", ""),
Reading("cpu", "temp", 65.0, "°C", "Package id 0"),
Reading("memory", "used", 14.2, "GB"),
Reading("memory", "total", 31.0, "GB"),
Reading("memory", "used_pct", 46.0, "%"),
]))
self.assertIn("72", tray._gpu_act.text())
self.assertIn("65", tray._cpu_act.text())
self.assertIn("14.2 / 31.0 GB", tray._mem_act.text())
self.assertEqual(tray._status_act.text(), "● Normal")
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Tests for M6 non-Steam game detection (Lutris SQLite + Heroic JSON)."""
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import launchers
class LutrisTests(unittest.TestCase):
def test_reads_installed_games_only(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "pga.db"
con = sqlite3.connect(db)
con.execute("CREATE TABLE games (id INTEGER, name TEXT, slug TEXT, installed INTEGER)")
con.executemany(
"INSERT INTO games VALUES (?, ?, ?, ?)",
[(1, "Hades", "hades", 1), (2, "Hollow Knight", "hollow-knight", 1), (3, "Old Game", "old", 0)],
)
con.commit()
con.close()
with mock.patch.object(launchers, "LUTRIS_DB", db), \
mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"):
games = launchers.scan()
names = {g.name for g in games}
self.assertEqual(names, {"Hades", "Hollow Knight"})
self.assertTrue(all(g.launcher == "lutris" for g in games))
def test_missing_db_is_empty(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "absent.db"), \
mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"):
self.assertEqual(launchers.scan(), [])
class HeroicTests(unittest.TestCase):
def test_epic_and_gog(self):
with tempfile.TemporaryDirectory() as d:
base = Path(d) / "heroic"
(base / "legendaryConfig" / "legendary").mkdir(parents=True)
(base / "gog_store").mkdir(parents=True)
(base / "legendaryConfig" / "legendary" / "installed.json").write_text(
json.dumps({"abc123": {"title": "Control"}}))
(base / "gog_store" / "installed.json").write_text(
json.dumps({"installed": [{"appName": "777", "title": "The Witcher 3"}]}))
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \
mock.patch.object(launchers, "HEROIC_DIR", base):
names = {g.name for g in launchers.scan()}
self.assertEqual(names, {"Control", "The Witcher 3"})
def test_gog_title_falls_back_to_install_path(self):
with tempfile.TemporaryDirectory() as d:
base = Path(d) / "heroic"
(base / "gog_store").mkdir(parents=True)
(base / "gog_store" / "installed.json").write_text(
json.dumps({"installed": [{"appName": "9", "install_path": "/games/Stardew Valley"}]}))
with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \
mock.patch.object(launchers, "HEROIC_DIR", base):
names = {g.name for g in launchers.scan()}
self.assertEqual(names, {"Stardew Valley"})
if __name__ == "__main__":
unittest.main()
+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()