Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4a37176e1 | |||
| 67665974dc | |||
| 51b7ed69bd | |||
| 6fca2c9aba | |||
| 4c5a6547ec | |||
| 587568e574 | |||
| cc84bbda88 | |||
| 75a4da7af3 | |||
| f95387c5b8 | |||
| 1dc86121f6 | |||
| cd54e5f2c5 | |||
| 1b24d1b032 | |||
| 7ac14416b5 | |||
| b22a2f5593 | |||
| f45d8c9b34 | |||
| 8d6ce47e87 | |||
| 03b2dd8363 |
@@ -5,6 +5,94 @@ All notable changes to RigDoctor are recorded here. Format follows
|
||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||
release tag (so the auto-updater, D18, can compare versions).
|
||||
|
||||
## [0.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
|
||||
|
||||
+42
-24
@@ -8,18 +8,18 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
|
||||
| ID | Module | Bundle | Key deps | GPU scope | Priority | Status |
|
||||
|----|--------|--------|----------|-----------|----------|--------|
|
||||
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | 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 | 🟨 |
|
||||
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ |
|
||||
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 |
|
||||
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 |
|
||||
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | 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 | ✅ |
|
||||
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ✅ |
|
||||
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ |
|
||||
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ |
|
||||
| M6 | Gaming env checks | Diagnostics | none | 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 | ⬜ |
|
||||
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
|
||||
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ |
|
||||
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
|
||||
| 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 | ✅ |
|
||||
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
|
||||
|
||||
## Notes per module
|
||||
@@ -31,15 +31,20 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
*Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation
|
||||
(`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
|
||||
entrypoint; the service unit + always-on/game-launch triggers (D6/D12) land in Phase 4.
|
||||
Also fully driven from the GUI's Recording/Logs page (M10) via shared `core.reccontrol`.
|
||||
entrypoint. The **game-launch trigger** is implemented via the D12 wrapper (`rigdoctor wrap
|
||||
%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
|
||||
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 →
|
||||
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`
|
||||
(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
|
||||
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
|
||||
@@ -56,19 +61,32 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
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**
|
||||
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
|
||||
(PowerMizer) checks.
|
||||
stay suggestion-only. *Guided diagnostic (D12 "pick a game", `core/diagnostic.py`):* a focused
|
||||
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).
|
||||
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log
|
||||
browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped
|
||||
early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
|
||||
nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
|
||||
colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status +
|
||||
post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17.
|
||||
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
|
||||
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
|
||||
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
|
||||
Snapshot / Quit (D13). Optional; shares the Qt dependency with M10.
|
||||
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine. Optional; adds the
|
||||
Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System /
|
||||
App) over: **Dashboard** (live history graphs + per-subsystem cards), **Games** (M6 detection
|
||||
+ Run Diagnostic), **Recordings** (recorder controls + view/report any captured log + analyze
|
||||
a crash), **System Health** (M4 scan), **Tuning** (M6 gaming tunables + fixes), **Inventory**
|
||||
(M5), **Settings** (components/deps + alerts + account + uninstall), and **Share** (M12). A
|
||||
global recording badge shows on every page. GUI-first per D17.
|
||||
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
|
||||
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
|
||||
(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
|
||||
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
|
||||
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
|
||||
|
||||
+20
-10
@@ -22,7 +22,8 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
||||
last readings + a plausible cause.
|
||||
|
||||
## 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)
|
||||
|
||||
## Phase 3 — Diagnostics breadth
|
||||
@@ -33,23 +34,32 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
||||
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,
|
||||
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)
|
||||
|
||||
## Phase 4 — Desktop UI & installer
|
||||
- [ ] M10 desktop GUI (PySide6: dashboard, log browser, report viewer, logger controls)
|
||||
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
|
||||
supporting actions — D13)
|
||||
- [x] M10 desktop GUI (PySide6: dashboard w/ history graphs, logs, health, games, environment,
|
||||
inventory, setup, notifications, share)
|
||||
- [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),
|
||||
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
|
||||
page → recording banner → results dialog with the capture summary + findings). Tags a
|
||||
focused capture with the chosen game (own diagnostic log, window-scoped report) and
|
||||
combines the capture summary with the M4 findings. *Pending:* the tray (M11) entry point,
|
||||
and auto start/stop via the D12 wrapper/watcher.
|
||||
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first:
|
||||
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
|
||||
(Steam RunningAppID + /proc) and GameMode hook follow)
|
||||
combines the capture summary with the M4 findings. **Auto start/stop** via the D12
|
||||
wrapper is wired in, and a **hard-crash is detected** (capture left without a clean stop)
|
||||
→ flagged on next launch with a deeper crash-boot log analysis. *Pending:* the tray (M11)
|
||||
entry point and the zero-config watcher.
|
||||
- [~] Logger trigger modes: always-on + game-launch (D12) — *game-launch **wrapper** done:*
|
||||
`rigdoctor wrap %command%` (per-game Steam launch option / Lutris/Heroic wrapper field)
|
||||
auto-brackets a focused capture around the game; GUI "Auto-capture…" helper shows the
|
||||
launch-option string. *Pending:* global Steam compat-tool registration, the zero-config
|
||||
watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user`
|
||||
service.
|
||||
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
|
||||
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
|
||||
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.15.0"
|
||||
version = "0.22.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.15.0"
|
||||
__version__ = "0.22.0"
|
||||
|
||||
+45
-32
@@ -44,17 +44,10 @@ def cmd_snapshot(args) -> int:
|
||||
|
||||
|
||||
def cmd_monitor(args) -> int:
|
||||
from .tui import run
|
||||
|
||||
interval = args.interval or load_config()["interval"]
|
||||
try:
|
||||
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
|
||||
return run(interval, plain=getattr(args, "plain", False))
|
||||
|
||||
|
||||
def cmd_gui(args) -> int:
|
||||
@@ -417,6 +410,12 @@ def cmd_diagnose(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_wrap(args) -> int:
|
||||
from .core import wrap
|
||||
|
||||
return wrap.run(args.command)
|
||||
|
||||
|
||||
def cmd_gameenv(args) -> int:
|
||||
from dataclasses import asdict
|
||||
|
||||
@@ -432,34 +431,41 @@ def cmd_gameenv(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()
|
||||
if not selected:
|
||||
print("No Steam libraries selected to scan.")
|
||||
print(" See them with: rigdoctor games libraries")
|
||||
print(" Then enable one: rigdoctor games libraries --enable <path> (or --all)")
|
||||
return 1
|
||||
result = steam.rescan()
|
||||
if args.json:
|
||||
from dataclasses import asdict
|
||||
result = steam.rescan() if selected else None
|
||||
steam_games = result.games if result else []
|
||||
extra = launchers.scan() # non-Steam (Lutris/Heroic)
|
||||
all_games = list(steam_games) + list(extra)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
"scanned_at": result.scanned_at,
|
||||
"new_appids": result.new_appids,
|
||||
"games": [asdict(g) for g in result.games],
|
||||
"scanned_at": result.scanned_at if result else None,
|
||||
"new_appids": result.new_appids if result else [],
|
||||
"games": [asdict(g) for g in all_games],
|
||||
}, indent=2, ensure_ascii=False))
|
||||
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
|
||||
new = set(result.new_appids)
|
||||
print(f"{len(result.games)} game(s) across {len(selected)} librar(y/ies):\n")
|
||||
for g in result.games:
|
||||
flag = " NEW" if g.appid in new else ""
|
||||
print(f" {g.name:<48} {steam.human_size(g.size_bytes):>9}{flag}")
|
||||
if new:
|
||||
print(f"\n{len(new)} newly-installed since the last scan.")
|
||||
|
||||
new = set(result.new_appids) if result else set()
|
||||
print(f"{len(all_games)} game(s):\n")
|
||||
for g in all_games:
|
||||
tag = " NEW" if g.appid in new else ""
|
||||
src = "" if g.launcher == "steam" else f" [{g.launcher}]"
|
||||
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
|
||||
|
||||
|
||||
@@ -510,8 +516,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("--json", action="store_true", help="output JSON instead of text")
|
||||
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("--plain", action="store_true", help="plain redraw instead of the curses UI")
|
||||
mp.set_defaults(func=cmd_monitor)
|
||||
|
||||
sub.add_parser("gui", help="launch the desktop GUI (needs PySide6)").set_defaults(func=cmd_gui)
|
||||
@@ -605,6 +612,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
|
||||
diag_finish.set_defaults(func=cmd_diagnose)
|
||||
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
|
||||
|
||||
wrap_p = sub.add_parser(
|
||||
"wrap", help="run a game with automatic crash-capture (Steam launch option, D12)")
|
||||
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
|
||||
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
|
||||
wrap_p.set_defaults(func=cmd_wrap)
|
||||
return p
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ LOG_FILE = LOG_DIR / "capture.jsonl"
|
||||
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on
|
||||
# crash log, so its report covers only that session's window.
|
||||
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
|
||||
# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture
|
||||
# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first.
|
||||
DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl"
|
||||
STATUS_FILE = STATE_DIR / "recorder.json"
|
||||
PID_FILE = STATE_DIR / "recorder.pid"
|
||||
SPAWN_LOG = STATE_DIR / "recorder.out"
|
||||
|
||||
@@ -53,6 +53,11 @@ def start(game: str | None = None, interval: float | None = None) -> int | None:
|
||||
Returns the pid, or None if a capture is already running."""
|
||||
if reccontrol.running_pid():
|
||||
return None
|
||||
if _crash_from_log(config.DIAG_LOG): # preserve an unanalyzed crash before overwriting it
|
||||
try:
|
||||
config.DIAG_LOG.replace(config.DIAG_CRASH)
|
||||
except OSError:
|
||||
pass
|
||||
_clear_diag_log()
|
||||
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
|
||||
|
||||
@@ -97,17 +102,11 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
|
||||
|
||||
# --- hard-crash detection & post-crash analysis -----------------------------------
|
||||
|
||||
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. Returns None if a capture is running, none
|
||||
is recorded, it stopped cleanly, or the user already acknowledged it.
|
||||
"""
|
||||
if is_running() or not config.DIAG_LOG.exists():
|
||||
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(config.DIAG_LOG)
|
||||
summary = summarize(path)
|
||||
kinds = {kind for _ts, kind, _detail in summary.events}
|
||||
if "session-start" not in kinds:
|
||||
return None
|
||||
@@ -121,8 +120,34 @@ def pending_crash() -> CrashInfo | None:
|
||||
)
|
||||
|
||||
|
||||
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 (appends a marker event)."""
|
||||
"""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:
|
||||
@@ -154,7 +179,7 @@ def analyze_crash(last_n: int = 15) -> DiagnosticResult:
|
||||
+ the rest of the health report (SMART/driver/persistence/temps)."""
|
||||
from .health import check_previous_boot, run_health_checks
|
||||
|
||||
summary = summarize(config.DIAG_LOG, last_n=last_n)
|
||||
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
|
||||
|
||||
@@ -71,6 +71,32 @@ def check_pcie_aspm() -> list[Finding]:
|
||||
|
||||
# --- 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]:
|
||||
if shutil.which("nvidia-smi") is None:
|
||||
return []
|
||||
@@ -235,6 +261,34 @@ def check_mitigations() -> list[Finding]:
|
||||
|
||||
# --- 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]:
|
||||
from . import steam
|
||||
|
||||
@@ -259,6 +313,7 @@ def run_gameenv_checks() -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
findings += check_pcie_aspm()
|
||||
findings += check_gpu_persistence()
|
||||
findings += check_gpu_powermizer()
|
||||
findings += check_cpu_governor()
|
||||
findings += check_gamemode()
|
||||
findings += check_mangohud()
|
||||
@@ -267,5 +322,7 @@ def run_gameenv_checks() -> list[Finding]:
|
||||
findings += check_thp()
|
||||
findings += check_mitigations()
|
||||
findings += check_proton()
|
||||
findings += check_wine()
|
||||
findings += check_steam_client()
|
||||
findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
|
||||
return findings
|
||||
|
||||
@@ -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())
|
||||
@@ -58,10 +58,11 @@ class SteamLibrary:
|
||||
class Game:
|
||||
appid: 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
|
||||
size_bytes: int = 0
|
||||
last_updated: int = 0 # epoch seconds (acf LastUpdated), 0 if unknown
|
||||
launcher: str = "steam" # "steam" | "lutris" | "heroic"
|
||||
|
||||
|
||||
# --- VDF (Valve Data Format) parsing --------------------------------------------------
|
||||
@@ -313,7 +314,8 @@ def cached_games() -> list[Game]:
|
||||
cache = load_cache()
|
||||
if not cache:
|
||||
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:
|
||||
@@ -353,6 +355,21 @@ def acknowledge_new() -> None:
|
||||
|
||||
# --- 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:
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +30,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
interval = float(load_config().get("interval", 1.0))
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class EnvironmentPage(QWidget):
|
||||
root.setSpacing(16)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("Environment")
|
||||
title = QLabel("Tuning")
|
||||
title.setObjectName("PageTitle")
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
|
||||
@@ -13,10 +13,13 @@ import time
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
@@ -85,6 +88,7 @@ class GamesPage(QWidget):
|
||||
self._diag_done.connect(self._on_diag_done)
|
||||
self._busy = False
|
||||
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)
|
||||
@@ -99,6 +103,9 @@ class GamesPage(QWidget):
|
||||
self._status = QLabel("")
|
||||
self._status.setObjectName("Muted")
|
||||
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.setObjectName("PrimaryButton")
|
||||
self._rescan_btn.clicked.connect(self.refresh)
|
||||
@@ -207,7 +214,7 @@ class GamesPage(QWidget):
|
||||
threading.Thread(target=self._work, daemon=True).start()
|
||||
|
||||
def _work(self) -> None:
|
||||
from ..core import steam
|
||||
from ..core import launchers, steam
|
||||
|
||||
try:
|
||||
selected = {os.path.realpath(p) for p in steam.selected_library_paths()}
|
||||
@@ -217,6 +224,10 @@ class GamesPage(QWidget):
|
||||
for lib in steam.discover_libraries()
|
||||
]
|
||||
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())
|
||||
except Exception:
|
||||
self._scanned.emit(None)
|
||||
@@ -259,11 +270,13 @@ class GamesPage(QWidget):
|
||||
self._status.setText("scan failed")
|
||||
return
|
||||
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)
|
||||
suffix = f" · {new} new" if new else ""
|
||||
non_steam = f" · {len(self._extra_games)} non-Steam" if self._extra_games else ""
|
||||
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)
|
||||
|
||||
@@ -287,12 +300,17 @@ class GamesPage(QWidget):
|
||||
return
|
||||
|
||||
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(
|
||||
g.name,
|
||||
os.path.basename(g.library.rstrip("/")) or g.library,
|
||||
sublabel,
|
||||
steam.human_size(g.size_bytes),
|
||||
g.appid in new_appids,
|
||||
appid=g.appid,
|
||||
appid=appid,
|
||||
on_diagnose=self._start_diagnostic,
|
||||
))
|
||||
self._list.addStretch(1)
|
||||
@@ -395,6 +413,43 @@ class GamesPage(QWidget):
|
||||
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:
|
||||
|
||||
@@ -32,7 +32,7 @@ class HealthPage(QWidget):
|
||||
root.setSpacing(16)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("Health")
|
||||
title = QLabel("System Health")
|
||||
title.setObjectName("PageTitle")
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
|
||||
@@ -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)}")
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QProcess, QTimer, Signal
|
||||
from PySide6.QtGui import QTextDocument
|
||||
from PySide6.QtGui import QIcon, QTextDocument
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
@@ -19,6 +21,7 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -31,14 +34,24 @@ from .dashboard import Dashboard
|
||||
from .environment_page import EnvironmentPage
|
||||
from .games_page import GamesPage
|
||||
from .health_page import HealthPage
|
||||
from .notifications_page import NotificationsPage
|
||||
from .inventory_page import InventoryPage
|
||||
from .recorder_page import RecorderPage
|
||||
from .setup_page import SetupPage
|
||||
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
|
||||
|
||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"]
|
||||
# Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
|
||||
# filled in this order, so _PAGES.index(name) is the stack index.
|
||||
_NAV = [
|
||||
("Monitor", ["Dashboard"]),
|
||||
("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
|
||||
("System", ["Inventory"]),
|
||||
("App", ["Settings", "Share"]),
|
||||
]
|
||||
_PAGES = [name for _section, names in _NAV for name in names]
|
||||
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -71,18 +84,23 @@ class MainWindow(QMainWindow):
|
||||
self.games_page = GamesPage()
|
||||
self.games_page.new_count_changed.connect(self._set_games_badge)
|
||||
self.environment_page = EnvironmentPage()
|
||||
self.inventory_page = InventoryPage()
|
||||
self.setup_page = SetupPage()
|
||||
self.notifications_page = NotificationsPage()
|
||||
self.notifications_page.changed.connect(self._apply_alert_settings)
|
||||
self.setup_page.changed.connect(self._apply_alert_settings)
|
||||
self.share_page = SharePage()
|
||||
self._stack.addWidget(self.dashboard) # 0 Dashboard
|
||||
self._stack.addWidget(self.recorder_page) # 1 Logs
|
||||
self._stack.addWidget(self.health_page) # 2 Health
|
||||
self._stack.addWidget(self.games_page) # 3 Games
|
||||
self._stack.addWidget(self.environment_page) # 4 Environment
|
||||
self._stack.addWidget(self.setup_page) # 5 Setup
|
||||
self._stack.addWidget(self.notifications_page) # 6 Notifications
|
||||
self._stack.addWidget(self.share_page) # 7 Share
|
||||
# Page name → widget; the stack is filled in _PAGES order so indices line up.
|
||||
self._pages = {
|
||||
"Dashboard": self.dashboard,
|
||||
"Games": self.games_page,
|
||||
"Recordings": self.recorder_page,
|
||||
"System Health": self.health_page,
|
||||
"Tuning": self.environment_page,
|
||||
"Inventory": self.inventory_page,
|
||||
"Settings": self.setup_page,
|
||||
"Share": self.share_page,
|
||||
}
|
||||
for name in _PAGES:
|
||||
self._stack.addWidget(self._pages[name])
|
||||
content_layout.addWidget(self._stack)
|
||||
|
||||
layout.addWidget(self._build_sidebar())
|
||||
@@ -124,6 +142,30 @@ class MainWindow(QMainWindow):
|
||||
self._update_timer.timeout.connect(self._start_update_check)
|
||||
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:
|
||||
bar = QFrame()
|
||||
bar.setObjectName("Sidebar")
|
||||
@@ -138,21 +180,38 @@ class MainWindow(QMainWindow):
|
||||
subtitle.setObjectName("AppSubtitle")
|
||||
v.addWidget(title)
|
||||
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)
|
||||
|
||||
group = QButtonGroup(self)
|
||||
group.setExclusive(True)
|
||||
self._nav_buttons: dict[str, QPushButton] = {}
|
||||
for i, name in enumerate(_NAV_ITEMS):
|
||||
btn = QPushButton(name)
|
||||
btn.setObjectName("NavButton")
|
||||
btn.setCheckable(True)
|
||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
btn.setChecked(i == 0)
|
||||
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx))
|
||||
group.addButton(btn, i)
|
||||
v.addWidget(btn)
|
||||
self._nav_buttons[name] = btn
|
||||
for section, names in _NAV:
|
||||
header = QLabel(section.upper())
|
||||
header.setObjectName("NavSection")
|
||||
v.addSpacing(8)
|
||||
v.addWidget(header)
|
||||
for name in names:
|
||||
idx = _PAGES.index(name)
|
||||
btn = QPushButton(name)
|
||||
btn.setObjectName("NavButton")
|
||||
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)
|
||||
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
|
||||
@@ -234,9 +293,64 @@ class MainWindow(QMainWindow):
|
||||
self._elevated.emit()
|
||||
|
||||
def _on_elevated(self) -> None:
|
||||
# Re-run Health now that root-only SMART data is available. (dmidecode is still
|
||||
# collected and used by the relay guest view + the CLI `rigdoctor inventory`.)
|
||||
# Re-run Health + Inventory now that root-only data is available (SMART for Health,
|
||||
# dmidecode motherboard/BIOS/RAM for Inventory).
|
||||
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:
|
||||
btn = self._nav_buttons.get("Games")
|
||||
@@ -319,6 +433,19 @@ class MainWindow(QMainWindow):
|
||||
self._update_label.setText("up-to-date")
|
||||
|
||||
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.share_page.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -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).")
|
||||
@@ -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
|
||||
`rigdoctor record …` are interchangeable.
|
||||
Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
|
||||
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
|
||||
|
||||
import threading
|
||||
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.QtWidgets import (
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -25,6 +28,7 @@ from .. import config
|
||||
from ..core import reccontrol
|
||||
from ..core.crashlog import summarize
|
||||
from ..render import format_headline, render_summary
|
||||
from .diagnostic_dialog import DiagnosticDialog
|
||||
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):
|
||||
_analyzed = Signal(object) # DiagnosticResult from a crash analysis
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("Page")
|
||||
self._analyzed.connect(self._show_analysis)
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(20, 18, 20, 18)
|
||||
root.setSpacing(16)
|
||||
|
||||
title = QLabel("Recording")
|
||||
title = QLabel("Recordings")
|
||||
title.setObjectName("PageTitle")
|
||||
root.addWidget(title)
|
||||
|
||||
# --- Status + controls -------------------------------------------------
|
||||
status_card, status_layout = _panel("Status")
|
||||
|
||||
self._state = QLabel("○ Not recording")
|
||||
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
|
||||
status_layout.addWidget(self._state)
|
||||
|
||||
self._info = QLabel("")
|
||||
self._info.setObjectName("Muted")
|
||||
status_layout.addWidget(self._info)
|
||||
|
||||
self._latest = QLabel("")
|
||||
status_layout.addWidget(self._latest)
|
||||
|
||||
self._warn = QLabel("")
|
||||
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
|
||||
self._warn.setVisible(False)
|
||||
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
|
||||
status_layout.addLayout(controls)
|
||||
root.addWidget(status_card)
|
||||
|
||||
# --- Report ------------------------------------------------------------
|
||||
report_card = QFrame()
|
||||
report_card.setObjectName("Card")
|
||||
report_layout = QVBoxLayout(report_card)
|
||||
report_layout.setContentsMargins(16, 14, 16, 14)
|
||||
report_layout.setSpacing(10)
|
||||
# --- Captured logs -----------------------------------------------------
|
||||
report_card, report_layout = _panel("Captured logs")
|
||||
header = QHBoxLayout()
|
||||
report_title = QLabel("Post-crash report")
|
||||
report_title.setStyleSheet("font-weight: 700; background: transparent;")
|
||||
header.addWidget(report_title)
|
||||
header.addStretch(1)
|
||||
header.addWidget(QLabel("Show:"))
|
||||
self._source = QComboBox()
|
||||
self._source.currentIndexChanged.connect(self._load_report)
|
||||
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.clicked.connect(self._load_report)
|
||||
refresh_btn.clicked.connect(self._refresh_sources)
|
||||
header.addWidget(refresh_btn)
|
||||
report_layout.addLayout(header)
|
||||
|
||||
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
|
||||
report_layout.addWidget(self._report)
|
||||
root.addWidget(report_card, 1)
|
||||
|
||||
# Poll recorder status once a second (reflects CLI-driven sessions too).
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._refresh_status)
|
||||
self._timer.start()
|
||||
self._refresh_status()
|
||||
self._load_report()
|
||||
self._refresh_sources()
|
||||
|
||||
# --- actions ---------------------------------------------------------------
|
||||
def _on_start(self) -> None:
|
||||
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
|
||||
self._stop_btn.setEnabled(False)
|
||||
reccontrol.stop_background()
|
||||
QTimer.singleShot(600, self._refresh_status)
|
||||
QTimer.singleShot(900, self._load_report)
|
||||
QTimer.singleShot(900, self._refresh_sources)
|
||||
|
||||
def _open_folder(self) -> None:
|
||||
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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 ---------------------------------------------------------------
|
||||
def _refresh_status(self) -> None:
|
||||
pid = reccontrol.running_pid()
|
||||
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
|
||||
self._interval.setEnabled(not running)
|
||||
|
||||
if status:
|
||||
game = status.get("game")
|
||||
game_line = f"Game: {game} " if game else ""
|
||||
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"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
|
||||
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._latest.setText("")
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,10 @@ from PySide6.QtCore import Qt, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import config
|
||||
from ..core import installer, sysenv, uninstall, updates
|
||||
from ..core import alerts, installer, sysenv, uninstall, updates
|
||||
from .theme import GOOD, MUTED, WARN
|
||||
|
||||
|
||||
@@ -49,6 +52,7 @@ _BACKEND_DESC = {
|
||||
class SetupPage(QWidget):
|
||||
_installed = Signal(int, str)
|
||||
_upd_state = Signal(object)
|
||||
changed = Signal() # alert settings saved — main window re-applies them live
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -60,7 +64,7 @@ class SetupPage(QWidget):
|
||||
root.setContentsMargins(20, 18, 20, 18)
|
||||
root.setSpacing(16)
|
||||
|
||||
title = QLabel("Setup")
|
||||
title = QLabel("Settings")
|
||||
title.setObjectName("PageTitle")
|
||||
root.addWidget(title)
|
||||
|
||||
@@ -70,7 +74,7 @@ class SetupPage(QWidget):
|
||||
env_layout.addWidget(self._env)
|
||||
root.addWidget(env_card)
|
||||
|
||||
comp_card, comp_layout = _panel("Optional components")
|
||||
comp_card, comp_layout = _panel("Components & dependencies")
|
||||
self._components = QVBoxLayout()
|
||||
self._components.setSpacing(6)
|
||||
comp_layout.addLayout(self._components)
|
||||
@@ -86,6 +90,39 @@ class SetupPage(QWidget):
|
||||
comp_layout.addLayout(controls)
|
||||
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.
|
||||
upd_card, upd_layout = _panel("Account access")
|
||||
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
||||
@@ -115,7 +152,7 @@ class SetupPage(QWidget):
|
||||
self._output = QTextEdit()
|
||||
self._output.setObjectName("Report")
|
||||
self._output.setReadOnly(True)
|
||||
self._output.setMinimumHeight(180)
|
||||
self._output.setMinimumHeight(160)
|
||||
self._output.setVisible(False)
|
||||
root.addWidget(self._output)
|
||||
root.addStretch(1)
|
||||
@@ -129,8 +166,39 @@ class SetupPage(QWidget):
|
||||
root.addLayout(danger)
|
||||
|
||||
self._refresh()
|
||||
self._load_alerts()
|
||||
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:
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
|
||||
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
|
||||
}}
|
||||
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
||||
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; }}
|
||||
QPushButton#CardHeader {{
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -66,6 +66,7 @@ class CrashDetectionTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -81,6 +82,7 @@ class CrashDetectionTests(unittest.TestCase):
|
||||
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())
|
||||
|
||||
@@ -89,6 +91,7 @@ class CrashDetectionTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -99,6 +102,7 @@ class CrashDetectionTests(unittest.TestCase):
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user