Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5a6547ec | |||
| 587568e574 | |||
| cc84bbda88 | |||
| 75a4da7af3 | |||
| f95387c5b8 | |||
| 1dc86121f6 | |||
| cd54e5f2c5 | |||
| 1b24d1b032 | |||
| 7ac14416b5 | |||
| b22a2f5593 | |||
| f45d8c9b34 | |||
| 8d6ce47e87 | |||
| 03b2dd8363 |
@@ -5,6 +5,73 @@ All notable changes to RigDoctor are recorded here. Format follows
|
|||||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||||
release tag (so the auto-updater, D18, can compare versions).
|
release tag (so the auto-updater, D18, can compare versions).
|
||||||
|
|
||||||
|
## [0.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
|
## [0.15.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **Hard-crash detection & recovery for the guided diagnostic.** If a focused capture ends
|
- **Hard-crash detection & recovery for the guided diagnostic.** If a focused capture ends
|
||||||
|
|||||||
+35
-22
@@ -8,18 +8,18 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
|
|
||||||
| ID | Module | Bundle | Key deps | GPU scope | Priority | Status |
|
| ID | Module | Bundle | Key deps | GPU scope | Priority | Status |
|
||||||
|----|--------|--------|----------|-----------|----------|--------|
|
|----|--------|--------|----------|-----------|----------|--------|
|
||||||
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ⬜ |
|
| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ |
|
||||||
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
|
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
|
||||||
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
|
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ |
|
||||||
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ |
|
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ |
|
||||||
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 |
|
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ |
|
||||||
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 |
|
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ |
|
||||||
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
|
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
|
||||||
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 |
|
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
|
||||||
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ |
|
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ |
|
||||||
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
|
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
|
||||||
| M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 |
|
| M12 | Session sharing / 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) |
|
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
|
||||||
|
|
||||||
## Notes per module
|
## Notes per module
|
||||||
@@ -31,8 +31,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
*Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation
|
*Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation
|
||||||
(`log_max_bytes`/`log_backups`), GPU-lost/recovered event markers, atomic status file, and
|
(`log_max_bytes`/`log_backups`), GPU-lost/recovered event markers, atomic status file, and
|
||||||
`rigdoctor record run|start|stop|status|report`. The foreground `run` is the systemd-ready
|
`rigdoctor record run|start|stop|status|report`. The foreground `run` is the systemd-ready
|
||||||
entrypoint; the service unit + always-on/game-launch triggers (D6/D12) land in Phase 4.
|
entrypoint. The **game-launch trigger** is implemented via the D12 wrapper (`rigdoctor wrap
|
||||||
Also fully driven from the GUI's Recording/Logs page (M10) via shared `core.reccontrol`.
|
%command%`, see M6/below); the `systemd --user` service unit + always-on trigger (D6) and the
|
||||||
|
zero-config watcher (D12) are still pending. Also fully driven from the GUI's Recording/Logs
|
||||||
|
page (M10) via shared `core.reccontrol`.
|
||||||
- **M4 Health report** — turns scattered logs into a prioritized, plain-language findings
|
- **M4 Health report** — turns scattered logs into a prioritized, plain-language findings
|
||||||
list with **suggested** fixes (read-only, D9). Reuses M1 for a live snapshot. Also powers
|
list with **suggested** fixes (read-only, D9). Reuses M1 for a live snapshot. Also powers
|
||||||
the **guided diagnostic session** (with M3): pick a game → focused capture → scan →
|
the **guided diagnostic session** (with M3): pick a game → focused capture → scan →
|
||||||
@@ -56,19 +58,30 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness /
|
for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness /
|
||||||
THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install**
|
THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install**
|
||||||
of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations
|
of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations
|
||||||
stay suggestion-only. *Pending:* non-Steam launchers (Lutris/Heroic) and GPU power-profile
|
stay suggestion-only. *Guided diagnostic (D12 "pick a game", `core/diagnostic.py`):* a focused
|
||||||
(PowerMizer) checks.
|
capture tagged with a game → window-scoped report (capture summary + M4 findings), in the CLI
|
||||||
|
(`rigdoctor diagnose start/status/finish`) and GUI (per-game **Run Diagnostic** → recording
|
||||||
|
banner → results dialog). **Auto-capture** via the D12 wrapper (`rigdoctor wrap %command%`,
|
||||||
|
`core/wrap.py`; GUI "Auto-capture…" helper). **Hard crashes are detected** (capture left
|
||||||
|
without a clean stop) and flagged on next launch with a crash-boot kernel-log analysis
|
||||||
|
(`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam
|
||||||
|
launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher.
|
||||||
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
|
- **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11).
|
||||||
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log
|
- **M10 Desktop GUI** — PySide6 graphical front-end over the core engine. Optional; adds the
|
||||||
browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped
|
Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System /
|
||||||
early (ahead of its Phase 4 slot) at the user's request:* dark-themed window with sidebar
|
App) over: **Dashboard** (live history graphs + per-subsystem cards), **Games** (M6 detection
|
||||||
nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
|
+ Run Diagnostic), **Recordings** (recorder controls + view/report any captured log + analyze
|
||||||
colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status +
|
a crash), **System Health** (M4 scan), **Tuning** (M6 gaming tunables + fixes), **Inventory**
|
||||||
post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17.
|
(M5), **Settings** (components/deps + alerts + account + uninstall), and **Share** (M12). A
|
||||||
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
|
global recording badge shows on every page. GUI-first per D17.
|
||||||
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
|
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
|
||||||
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
|
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
|
||||||
Snapshot / Quit (D13). Optional; shares the Qt dependency with M10.
|
(Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
|
||||||
|
the guided session), plus Open dashboard / Start-Stop recording / Snapshot-copy / Quit. It
|
||||||
|
shares the dashboard's sample stream (no extra sampling) and drives the existing MainWindow
|
||||||
|
flows. With a tray present, closing the window **hides to the tray** (Quit exits); `rigdoctor-gui
|
||||||
|
--tray` starts hidden for autostart. Optional; shares the Qt dependency with M10. *Needs a tray
|
||||||
|
host* — on GNOME that means the AppIndicator extension; degrades to no-op if none is available.
|
||||||
- **M9 Installer** — interactive wizard layered on the `.deb` (D8); apt-first dependency
|
- **M9 Installer** — interactive wizard layered on the `.deb` (D8); apt-first dependency
|
||||||
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
|
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
|
||||||
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
|
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
|
||||||
|
|||||||
+15
-8
@@ -37,19 +37,26 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
|||||||
- [ ] SMART integration (smartmontools if present)
|
- [ ] SMART integration (smartmontools if present)
|
||||||
|
|
||||||
## Phase 4 — Desktop UI & installer
|
## Phase 4 — Desktop UI & installer
|
||||||
- [ ] M10 desktop GUI (PySide6: dashboard, log browser, report viewer, logger controls)
|
- [x] M10 desktop GUI (PySide6: dashboard w/ history graphs, logs, health, games, environment,
|
||||||
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
|
inventory, setup, notifications, share)
|
||||||
supporting actions — D13)
|
- [x] M11 tray / menu-bar applet (`gui/tray.py`: live CPU/GPU temp + memory readouts, status
|
||||||
|
line, Run Diagnostic submenu per game, Open dashboard / Start-Stop recording / Snapshot /
|
||||||
|
Quit — D13; close-to-tray, `--tray` autostart). Needs a tray host (AppIndicator on GNOME).
|
||||||
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
|
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
|
||||||
shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
|
shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
|
||||||
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
|
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
|
||||||
page → recording banner → results dialog with the capture summary + findings). Tags a
|
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
|
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,
|
combines the capture summary with the M4 findings. **Auto start/stop** via the D12
|
||||||
and auto start/stop via the D12 wrapper/watcher.
|
wrapper is wired in, and a **hard-crash is detected** (capture left without a clean stop)
|
||||||
- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first:
|
→ flagged on next launch with a deeper crash-boot log analysis. *Pending:* the tray (M11)
|
||||||
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
|
entry point and the zero-config watcher.
|
||||||
(Steam RunningAppID + /proc) and GameMode hook follow)
|
- [~] Logger trigger modes: always-on + game-launch (D12) — *game-launch **wrapper** done:*
|
||||||
|
`rigdoctor wrap %command%` (per-game Steam launch option / Lutris/Heroic wrapper field)
|
||||||
|
auto-brackets a focused capture around the game; GUI "Auto-capture…" helper shows the
|
||||||
|
launch-option string. *Pending:* global Steam compat-tool registration, the zero-config
|
||||||
|
watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user`
|
||||||
|
service.
|
||||||
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
|
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
|
||||||
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
|
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
|
||||||
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
|
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.15.0"
|
version = "0.20.0"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.15.0"
|
__version__ = "0.20.0"
|
||||||
|
|||||||
@@ -417,6 +417,12 @@ def cmd_diagnose(args) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_wrap(args) -> int:
|
||||||
|
from .core import wrap
|
||||||
|
|
||||||
|
return wrap.run(args.command)
|
||||||
|
|
||||||
|
|
||||||
def cmd_gameenv(args) -> int:
|
def cmd_gameenv(args) -> int:
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
@@ -605,6 +611,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
|
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
|
||||||
diag_finish.set_defaults(func=cmd_diagnose)
|
diag_finish.set_defaults(func=cmd_diagnose)
|
||||||
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
|
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
|
||||||
|
|
||||||
|
wrap_p = sub.add_parser(
|
||||||
|
"wrap", help="run a game with automatic crash-capture (Steam launch option, D12)")
|
||||||
|
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
|
||||||
|
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
|
||||||
|
wrap_p.set_defaults(func=cmd_wrap)
|
||||||
return p
|
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
|
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on
|
||||||
# crash log, so its report covers only that session's window.
|
# crash log, so its report covers only that session's window.
|
||||||
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
|
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
|
||||||
|
# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture
|
||||||
|
# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first.
|
||||||
|
DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl"
|
||||||
STATUS_FILE = STATE_DIR / "recorder.json"
|
STATUS_FILE = STATE_DIR / "recorder.json"
|
||||||
PID_FILE = STATE_DIR / "recorder.pid"
|
PID_FILE = STATE_DIR / "recorder.pid"
|
||||||
SPAWN_LOG = STATE_DIR / "recorder.out"
|
SPAWN_LOG = STATE_DIR / "recorder.out"
|
||||||
|
|||||||
@@ -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."""
|
Returns the pid, or None if a capture is already running."""
|
||||||
if reccontrol.running_pid():
|
if reccontrol.running_pid():
|
||||||
return None
|
return None
|
||||||
|
if _crash_from_log(config.DIAG_LOG): # preserve an unanalyzed crash before overwriting it
|
||||||
|
try:
|
||||||
|
config.DIAG_LOG.replace(config.DIAG_CRASH)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
_clear_diag_log()
|
_clear_diag_log()
|
||||||
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
|
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
|
||||||
|
|
||||||
@@ -97,17 +102,11 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
|
|||||||
|
|
||||||
# --- hard-crash detection & post-crash analysis -----------------------------------
|
# --- hard-crash detection & post-crash analysis -----------------------------------
|
||||||
|
|
||||||
def pending_crash() -> CrashInfo | None:
|
def _crash_from_log(path) -> CrashInfo | None:
|
||||||
"""Detect a diagnostic that ended abnormally (no clean stop, no live recorder).
|
"""CrashInfo if `path` holds an abnormally-ended session (start, no stop, not acked)."""
|
||||||
|
if not path.exists():
|
||||||
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():
|
|
||||||
return None
|
return None
|
||||||
summary = summarize(config.DIAG_LOG)
|
summary = summarize(path)
|
||||||
kinds = {kind for _ts, kind, _detail in summary.events}
|
kinds = {kind for _ts, kind, _detail in summary.events}
|
||||||
if "session-start" not in kinds:
|
if "session-start" not in kinds:
|
||||||
return None
|
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:
|
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:
|
try:
|
||||||
config.DIAG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
config.DIAG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(config.DIAG_LOG, "a", encoding="utf-8") as fh:
|
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)."""
|
+ the rest of the health report (SMART/driver/persistence/temps)."""
|
||||||
from .health import check_previous_boot, run_health_checks
|
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: list[Finding] = [_crash_headline(summary)]
|
||||||
findings += check_previous_boot() # the crashed boot's kernel log
|
findings += check_previous_boot() # the crashed boot's kernel log
|
||||||
findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps
|
findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps
|
||||||
|
|||||||
@@ -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,6 +30,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
interval = float(load_config().get("interval", 1.0))
|
interval = float(load_config().get("interval", 1.0))
|
||||||
window = MainWindow(interval=interval)
|
window = MainWindow(interval=interval)
|
||||||
|
# `--tray` starts hidden to the system tray (for autostart); if no tray is available,
|
||||||
|
# fall back to showing the window so the app is never invisible-and-unreachable.
|
||||||
|
args = argv if argv is not None else sys.argv
|
||||||
|
if "--tray" in args and window.tray_available():
|
||||||
|
window.start_minimized_note()
|
||||||
|
else:
|
||||||
window.show()
|
window.show()
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class EnvironmentPage(QWidget):
|
|||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("Environment")
|
title = QLabel("Tuning")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import time
|
|||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
@@ -99,6 +102,9 @@ class GamesPage(QWidget):
|
|||||||
self._status = QLabel("")
|
self._status = QLabel("")
|
||||||
self._status.setObjectName("Muted")
|
self._status.setObjectName("Muted")
|
||||||
header.addWidget(self._status)
|
header.addWidget(self._status)
|
||||||
|
self._autocap_btn = QPushButton("Auto-capture…")
|
||||||
|
self._autocap_btn.clicked.connect(self._show_autocapture)
|
||||||
|
header.addWidget(self._autocap_btn)
|
||||||
self._rescan_btn = QPushButton("Rescan")
|
self._rescan_btn = QPushButton("Rescan")
|
||||||
self._rescan_btn.setObjectName("PrimaryButton")
|
self._rescan_btn.setObjectName("PrimaryButton")
|
||||||
self._rescan_btn.clicked.connect(self.refresh)
|
self._rescan_btn.clicked.connect(self.refresh)
|
||||||
@@ -395,6 +401,43 @@ class GamesPage(QWidget):
|
|||||||
reccontrol.stop_background()
|
reccontrol.stop_background()
|
||||||
self._banner.hide()
|
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 ----------------------------------------------------------
|
# --- hard-crash recovery ----------------------------------------------------------
|
||||||
|
|
||||||
def _check_crash(self) -> None:
|
def _check_crash(self) -> None:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class HealthPage(QWidget):
|
|||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("Health")
|
title = QLabel("System Health")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QProcess, QTimer, Signal
|
from PySide6.QtCore import Qt, QProcess, QTimer, Signal
|
||||||
from PySide6.QtGui import QTextDocument
|
from PySide6.QtGui import QIcon, QTextDocument
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
@@ -19,6 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
|
QSystemTrayIcon,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -31,14 +34,24 @@ from .dashboard import Dashboard
|
|||||||
from .environment_page import EnvironmentPage
|
from .environment_page import EnvironmentPage
|
||||||
from .games_page import GamesPage
|
from .games_page import GamesPage
|
||||||
from .health_page import HealthPage
|
from .health_page import HealthPage
|
||||||
from .notifications_page import NotificationsPage
|
from .inventory_page import InventoryPage
|
||||||
from .recorder_page import RecorderPage
|
from .recorder_page import RecorderPage
|
||||||
from .setup_page import SetupPage
|
from .setup_page import SetupPage
|
||||||
from .share_page import SharePage
|
from .share_page import SharePage
|
||||||
from .theme import ACCENT, GOOD, MUTED
|
from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT
|
||||||
|
from .tray import TrayIcon
|
||||||
from .worker import SamplerWorker
|
from .worker import SamplerWorker
|
||||||
|
|
||||||
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"]
|
# Sidebar grouped by intent. Each page name maps to a widget built in __init__; the stack is
|
||||||
|
# filled in this order, so _PAGES.index(name) is the stack index.
|
||||||
|
_NAV = [
|
||||||
|
("Monitor", ["Dashboard"]),
|
||||||
|
("Diagnose", ["Games", "Recordings", "System Health", "Tuning"]),
|
||||||
|
("System", ["Inventory"]),
|
||||||
|
("App", ["Settings", "Share"]),
|
||||||
|
]
|
||||||
|
_PAGES = [name for _section, names in _NAV for name in names]
|
||||||
|
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -71,18 +84,23 @@ class MainWindow(QMainWindow):
|
|||||||
self.games_page = GamesPage()
|
self.games_page = GamesPage()
|
||||||
self.games_page.new_count_changed.connect(self._set_games_badge)
|
self.games_page.new_count_changed.connect(self._set_games_badge)
|
||||||
self.environment_page = EnvironmentPage()
|
self.environment_page = EnvironmentPage()
|
||||||
|
self.inventory_page = InventoryPage()
|
||||||
self.setup_page = SetupPage()
|
self.setup_page = SetupPage()
|
||||||
self.notifications_page = NotificationsPage()
|
self.setup_page.changed.connect(self._apply_alert_settings)
|
||||||
self.notifications_page.changed.connect(self._apply_alert_settings)
|
|
||||||
self.share_page = SharePage()
|
self.share_page = SharePage()
|
||||||
self._stack.addWidget(self.dashboard) # 0 Dashboard
|
# Page name → widget; the stack is filled in _PAGES order so indices line up.
|
||||||
self._stack.addWidget(self.recorder_page) # 1 Logs
|
self._pages = {
|
||||||
self._stack.addWidget(self.health_page) # 2 Health
|
"Dashboard": self.dashboard,
|
||||||
self._stack.addWidget(self.games_page) # 3 Games
|
"Games": self.games_page,
|
||||||
self._stack.addWidget(self.environment_page) # 4 Environment
|
"Recordings": self.recorder_page,
|
||||||
self._stack.addWidget(self.setup_page) # 5 Setup
|
"System Health": self.health_page,
|
||||||
self._stack.addWidget(self.notifications_page) # 6 Notifications
|
"Tuning": self.environment_page,
|
||||||
self._stack.addWidget(self.share_page) # 7 Share
|
"Inventory": self.inventory_page,
|
||||||
|
"Settings": self.setup_page,
|
||||||
|
"Share": self.share_page,
|
||||||
|
}
|
||||||
|
for name in _PAGES:
|
||||||
|
self._stack.addWidget(self._pages[name])
|
||||||
content_layout.addWidget(self._stack)
|
content_layout.addWidget(self._stack)
|
||||||
|
|
||||||
layout.addWidget(self._build_sidebar())
|
layout.addWidget(self._build_sidebar())
|
||||||
@@ -124,6 +142,30 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_timer.timeout.connect(self._start_update_check)
|
self._update_timer.timeout.connect(self._start_update_check)
|
||||||
self._update_timer.start()
|
self._update_timer.start()
|
||||||
|
|
||||||
|
# Reflect any capture (manual, diagnostic, or the Steam wrapper) in the sidebar on
|
||||||
|
# every page, so it's always clear when RigDoctor is recording and for which game.
|
||||||
|
self._rec_timer = QTimer(self)
|
||||||
|
self._rec_timer.setInterval(1500)
|
||||||
|
self._rec_timer.timeout.connect(self._update_recording)
|
||||||
|
self._rec_timer.start()
|
||||||
|
self._update_recording()
|
||||||
|
|
||||||
|
# System-tray applet (M11) — optional; only when the desktop offers a tray. When
|
||||||
|
# present, closing the window hides to the tray instead of quitting.
|
||||||
|
self._tray = None
|
||||||
|
self._quitting = False
|
||||||
|
self._tray_hint_shown = False
|
||||||
|
if QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
|
icon = self.windowIcon() if not self.windowIcon().isNull() else QIcon(str(_ICON))
|
||||||
|
self._tray = TrayIcon(
|
||||||
|
self, icon,
|
||||||
|
gpu_alert=float(cfg.get("gpu_temp_alert", 90.0)),
|
||||||
|
cpu_alert=float(cfg.get("cpu_temp_alert", 95.0)),
|
||||||
|
)
|
||||||
|
self._worker.sampled.connect(self._tray.update_sample)
|
||||||
|
self._tray.show()
|
||||||
|
QApplication.instance().setQuitOnLastWindowClosed(False)
|
||||||
|
|
||||||
def _build_sidebar(self) -> QFrame:
|
def _build_sidebar(self) -> QFrame:
|
||||||
bar = QFrame()
|
bar = QFrame()
|
||||||
bar.setObjectName("Sidebar")
|
bar.setObjectName("Sidebar")
|
||||||
@@ -138,19 +180,36 @@ class MainWindow(QMainWindow):
|
|||||||
subtitle.setObjectName("AppSubtitle")
|
subtitle.setObjectName("AppSubtitle")
|
||||||
v.addWidget(title)
|
v.addWidget(title)
|
||||||
v.addWidget(subtitle)
|
v.addWidget(subtitle)
|
||||||
|
|
||||||
|
# Global recording indicator — visible on every page while a capture runs.
|
||||||
|
self._rec_indicator = QLabel()
|
||||||
|
self._rec_indicator.setWordWrap(True)
|
||||||
|
self._rec_indicator.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self._rec_indicator.setStyleSheet(
|
||||||
|
f"background: #241316; border: 1px solid {CRIT}; border-radius: 8px; padding: 8px 10px;"
|
||||||
|
)
|
||||||
|
self._rec_indicator.hide()
|
||||||
|
v.addSpacing(12)
|
||||||
|
v.addWidget(self._rec_indicator)
|
||||||
v.addSpacing(18)
|
v.addSpacing(18)
|
||||||
|
|
||||||
group = QButtonGroup(self)
|
group = QButtonGroup(self)
|
||||||
group.setExclusive(True)
|
group.setExclusive(True)
|
||||||
self._nav_buttons: dict[str, QPushButton] = {}
|
self._nav_buttons: dict[str, QPushButton] = {}
|
||||||
for i, name in enumerate(_NAV_ITEMS):
|
for section, names in _NAV:
|
||||||
|
header = QLabel(section.upper())
|
||||||
|
header.setObjectName("NavSection")
|
||||||
|
v.addSpacing(8)
|
||||||
|
v.addWidget(header)
|
||||||
|
for name in names:
|
||||||
|
idx = _PAGES.index(name)
|
||||||
btn = QPushButton(name)
|
btn = QPushButton(name)
|
||||||
btn.setObjectName("NavButton")
|
btn.setObjectName("NavButton")
|
||||||
btn.setCheckable(True)
|
btn.setCheckable(True)
|
||||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
btn.setChecked(i == 0)
|
btn.setChecked(idx == 0)
|
||||||
btn.clicked.connect(lambda _checked, idx=i: self._stack.setCurrentIndex(idx))
|
btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
|
||||||
group.addButton(btn, i)
|
group.addButton(btn, idx)
|
||||||
v.addWidget(btn)
|
v.addWidget(btn)
|
||||||
self._nav_buttons[name] = btn
|
self._nav_buttons[name] = btn
|
||||||
|
|
||||||
@@ -234,9 +293,64 @@ class MainWindow(QMainWindow):
|
|||||||
self._elevated.emit()
|
self._elevated.emit()
|
||||||
|
|
||||||
def _on_elevated(self) -> None:
|
def _on_elevated(self) -> None:
|
||||||
# Re-run Health now that root-only SMART data is available. (dmidecode is still
|
# Re-run Health + Inventory now that root-only data is available (SMART for Health,
|
||||||
# collected and used by the relay guest view + the CLI `rigdoctor inventory`.)
|
# dmidecode motherboard/BIOS/RAM for Inventory).
|
||||||
self.health_page._run()
|
self.health_page._run()
|
||||||
|
self.inventory_page._run()
|
||||||
|
|
||||||
|
# --- tray-driven actions (M11) ----------------------------------------------------
|
||||||
|
|
||||||
|
def show_page(self, name: str) -> None:
|
||||||
|
"""Bring the window forward on a given page (used by the tray)."""
|
||||||
|
if name in self._nav_buttons:
|
||||||
|
self._stack.setCurrentIndex(_PAGES.index(name))
|
||||||
|
self._nav_buttons[name].setChecked(True)
|
||||||
|
self.showNormal()
|
||||||
|
self.raise_()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
def show_dashboard(self) -> None:
|
||||||
|
self.show_page("Dashboard")
|
||||||
|
|
||||||
|
def tray_available(self) -> bool:
|
||||||
|
return self._tray is not None
|
||||||
|
|
||||||
|
def start_minimized_note(self) -> None:
|
||||||
|
"""Started hidden to the tray (autostart) — let the user know it's there."""
|
||||||
|
if self._tray is not None:
|
||||||
|
self._tray_hint_shown = True
|
||||||
|
self._tray.showMessage(
|
||||||
|
"RigDoctor", "Running in the tray — right-click the icon for actions.",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 4000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_diagnostic(self, name: str, appid: str) -> None:
|
||||||
|
self.show_page("Games")
|
||||||
|
self.games_page._start_diagnostic(name, appid)
|
||||||
|
|
||||||
|
def quit_app(self) -> None:
|
||||||
|
self._quitting = True
|
||||||
|
self._worker.stop()
|
||||||
|
self.share_page.shutdown()
|
||||||
|
if self._tray is not None:
|
||||||
|
self._tray.hide()
|
||||||
|
QApplication.instance().quit()
|
||||||
|
|
||||||
|
def _update_recording(self) -> None:
|
||||||
|
from ..core import diagnostic
|
||||||
|
|
||||||
|
status = diagnostic.active()
|
||||||
|
if not status:
|
||||||
|
self._rec_indicator.hide()
|
||||||
|
return
|
||||||
|
game = status.get("game")
|
||||||
|
lines = [f"<span style='color:{CRIT};'>●</span> <b style='color:{TEXT};'>Recording</b>"]
|
||||||
|
if game:
|
||||||
|
lines.append(f"<span style='color:{TEXT};'>{html.escape(str(game))}</span>")
|
||||||
|
if status.get("gpu_lost"):
|
||||||
|
lines.append(f"<span style='color:{CRIT};'>⚠ GPU-lost</span>")
|
||||||
|
self._rec_indicator.setText("<br>".join(lines))
|
||||||
|
self._rec_indicator.show()
|
||||||
|
|
||||||
def _set_games_badge(self, count: int) -> None:
|
def _set_games_badge(self, count: int) -> None:
|
||||||
btn = self._nav_buttons.get("Games")
|
btn = self._nav_buttons.get("Games")
|
||||||
@@ -319,6 +433,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_label.setText("up-to-date")
|
self._update_label.setText("up-to-date")
|
||||||
|
|
||||||
def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
|
def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
|
||||||
|
# With a tray, closing the window hides it (the app keeps running for the tray
|
||||||
|
# readouts + any capture); Quit from the tray menu exits for real.
|
||||||
|
if self._tray is not None and not self._quitting:
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
if not self._tray_hint_shown:
|
||||||
|
self._tray_hint_shown = True
|
||||||
|
self._tray.showMessage(
|
||||||
|
"RigDoctor",
|
||||||
|
"Still running in the tray — right-click the icon for actions or Quit.",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 5000,
|
||||||
|
)
|
||||||
|
return
|
||||||
self._worker.stop()
|
self._worker.stop()
|
||||||
self.share_page.shutdown()
|
self.share_page.shutdown()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|||||||
@@ -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
|
Drives the same background recorder as the CLI via core.reccontrol, and surfaces the
|
||||||
`rigdoctor record …` are interchangeable.
|
captured data — the always-on log, the last guided diagnostic, and a preserved hard-crash
|
||||||
|
(which can be analyzed in place). One place to see what was captured and what it means.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer, QUrl
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices, QFont
|
from PySide6.QtGui import QDesktopServices, QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -25,6 +28,7 @@ from .. import config
|
|||||||
from ..core import reccontrol
|
from ..core import reccontrol
|
||||||
from ..core.crashlog import summarize
|
from ..core.crashlog import summarize
|
||||||
from ..render import format_headline, render_summary
|
from ..render import format_headline, render_summary
|
||||||
|
from .diagnostic_dialog import DiagnosticDialog
|
||||||
from .theme import GOOD, MUTED, WARN
|
from .theme import GOOD, MUTED, WARN
|
||||||
|
|
||||||
|
|
||||||
@@ -45,31 +49,30 @@ def _fmt_time(value, fmt="%Y-%m-%d %H:%M:%S") -> str:
|
|||||||
|
|
||||||
|
|
||||||
class RecorderPage(QWidget):
|
class RecorderPage(QWidget):
|
||||||
|
_analyzed = Signal(object) # DiagnosticResult from a crash analysis
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setObjectName("Page")
|
self.setObjectName("Page")
|
||||||
|
self._analyzed.connect(self._show_analysis)
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(20, 18, 20, 18)
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
title = QLabel("Recording")
|
title = QLabel("Recordings")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
root.addWidget(title)
|
root.addWidget(title)
|
||||||
|
|
||||||
# --- Status + controls -------------------------------------------------
|
# --- Status + controls -------------------------------------------------
|
||||||
status_card, status_layout = _panel("Status")
|
status_card, status_layout = _panel("Status")
|
||||||
|
|
||||||
self._state = QLabel("○ Not recording")
|
self._state = QLabel("○ Not recording")
|
||||||
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
|
self._state.setStyleSheet(f"color: {MUTED}; font-weight: 700; background: transparent;")
|
||||||
status_layout.addWidget(self._state)
|
status_layout.addWidget(self._state)
|
||||||
|
|
||||||
self._info = QLabel("")
|
self._info = QLabel("")
|
||||||
self._info.setObjectName("Muted")
|
self._info.setObjectName("Muted")
|
||||||
status_layout.addWidget(self._info)
|
status_layout.addWidget(self._info)
|
||||||
|
|
||||||
self._latest = QLabel("")
|
self._latest = QLabel("")
|
||||||
status_layout.addWidget(self._latest)
|
status_layout.addWidget(self._latest)
|
||||||
|
|
||||||
self._warn = QLabel("")
|
self._warn = QLabel("")
|
||||||
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
|
self._warn.setStyleSheet(f"color: {WARN}; font-weight: 600; background: transparent;")
|
||||||
self._warn.setVisible(False)
|
self._warn.setVisible(False)
|
||||||
@@ -97,19 +100,20 @@ class RecorderPage(QWidget):
|
|||||||
status_layout.addLayout(controls)
|
status_layout.addLayout(controls)
|
||||||
root.addWidget(status_card)
|
root.addWidget(status_card)
|
||||||
|
|
||||||
# --- Report ------------------------------------------------------------
|
# --- Captured logs -----------------------------------------------------
|
||||||
report_card = QFrame()
|
report_card, report_layout = _panel("Captured logs")
|
||||||
report_card.setObjectName("Card")
|
|
||||||
report_layout = QVBoxLayout(report_card)
|
|
||||||
report_layout.setContentsMargins(16, 14, 16, 14)
|
|
||||||
report_layout.setSpacing(10)
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
report_title = QLabel("Post-crash report")
|
header.addWidget(QLabel("Show:"))
|
||||||
report_title.setStyleSheet("font-weight: 700; background: transparent;")
|
self._source = QComboBox()
|
||||||
header.addWidget(report_title)
|
self._source.currentIndexChanged.connect(self._load_report)
|
||||||
header.addStretch(1)
|
header.addWidget(self._source, 1)
|
||||||
|
self._analyze_btn = QPushButton("Analyze crash")
|
||||||
|
self._analyze_btn.setObjectName("ActionButton")
|
||||||
|
self._analyze_btn.clicked.connect(self._analyze_crash)
|
||||||
|
self._analyze_btn.setVisible(False)
|
||||||
|
header.addWidget(self._analyze_btn)
|
||||||
refresh_btn = QPushButton("Refresh")
|
refresh_btn = QPushButton("Refresh")
|
||||||
refresh_btn.clicked.connect(self._load_report)
|
refresh_btn.clicked.connect(self._refresh_sources)
|
||||||
header.addWidget(refresh_btn)
|
header.addWidget(refresh_btn)
|
||||||
report_layout.addLayout(header)
|
report_layout.addLayout(header)
|
||||||
|
|
||||||
@@ -121,13 +125,12 @@ class RecorderPage(QWidget):
|
|||||||
report_layout.addWidget(self._report)
|
report_layout.addWidget(self._report)
|
||||||
root.addWidget(report_card, 1)
|
root.addWidget(report_card, 1)
|
||||||
|
|
||||||
# Poll recorder status once a second (reflects CLI-driven sessions too).
|
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(1000)
|
self._timer.setInterval(1000)
|
||||||
self._timer.timeout.connect(self._refresh_status)
|
self._timer.timeout.connect(self._refresh_status)
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
self._refresh_status()
|
self._refresh_status()
|
||||||
self._load_report()
|
self._refresh_sources()
|
||||||
|
|
||||||
# --- actions ---------------------------------------------------------------
|
# --- actions ---------------------------------------------------------------
|
||||||
def _on_start(self) -> None:
|
def _on_start(self) -> None:
|
||||||
@@ -139,12 +142,56 @@ class RecorderPage(QWidget):
|
|||||||
self._stop_btn.setEnabled(False)
|
self._stop_btn.setEnabled(False)
|
||||||
reccontrol.stop_background()
|
reccontrol.stop_background()
|
||||||
QTimer.singleShot(600, self._refresh_status)
|
QTimer.singleShot(600, self._refresh_status)
|
||||||
QTimer.singleShot(900, self._load_report)
|
QTimer.singleShot(900, self._refresh_sources)
|
||||||
|
|
||||||
def _open_folder(self) -> None:
|
def _open_folder(self) -> None:
|
||||||
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
config.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(config.LOG_DIR)))
|
||||||
|
|
||||||
|
# --- captured logs ---------------------------------------------------------
|
||||||
|
def _refresh_sources(self) -> None:
|
||||||
|
from ..core import diagnostic
|
||||||
|
|
||||||
|
current = self._source.currentData()
|
||||||
|
self._source.blockSignals(True)
|
||||||
|
self._source.clear()
|
||||||
|
self._source.addItem("Always-on capture", str(config.LOG_FILE))
|
||||||
|
if config.DIAG_LOG.exists():
|
||||||
|
self._source.addItem("Last diagnostic", str(config.DIAG_LOG))
|
||||||
|
if config.DIAG_CRASH.exists():
|
||||||
|
self._source.addItem("Crash (unanalyzed)", str(config.DIAG_CRASH))
|
||||||
|
# keep the previous selection if it's still present
|
||||||
|
idx = self._source.findData(current) if current else -1
|
||||||
|
self._source.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
|
self._source.blockSignals(False)
|
||||||
|
self._analyze_btn.setVisible(diagnostic.pending_crash() is not None)
|
||||||
|
self._load_report()
|
||||||
|
|
||||||
|
def _load_report(self) -> None:
|
||||||
|
path = self._source.currentData() or str(config.LOG_FILE)
|
||||||
|
summary = summarize(path, last_n=10)
|
||||||
|
self._report.setPlainText(render_summary(summary, log_path=path))
|
||||||
|
|
||||||
|
def _analyze_crash(self) -> None:
|
||||||
|
self._analyze_btn.setEnabled(False)
|
||||||
|
self._report.setPlainText("Analyzing the crash (final readings + system logs)…")
|
||||||
|
threading.Thread(target=self._work_analyze, daemon=True).start()
|
||||||
|
|
||||||
|
def _work_analyze(self) -> None:
|
||||||
|
from ..core import diagnostic
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = diagnostic.analyze_crash()
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
self._analyzed.emit(result)
|
||||||
|
|
||||||
|
def _show_analysis(self, result) -> None:
|
||||||
|
self._analyze_btn.setEnabled(True)
|
||||||
|
if result is not None:
|
||||||
|
DiagnosticDialog(result, self).exec()
|
||||||
|
self._refresh_sources()
|
||||||
|
|
||||||
# --- refresh ---------------------------------------------------------------
|
# --- refresh ---------------------------------------------------------------
|
||||||
def _refresh_status(self) -> None:
|
def _refresh_status(self) -> None:
|
||||||
pid = reccontrol.running_pid()
|
pid = reccontrol.running_pid()
|
||||||
@@ -162,8 +209,10 @@ class RecorderPage(QWidget):
|
|||||||
self._interval.setEnabled(not running)
|
self._interval.setEnabled(not running)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
|
game = status.get("game")
|
||||||
|
game_line = f"Game: {game} " if game else ""
|
||||||
self._info.setText(
|
self._info.setText(
|
||||||
f"Samples: {status.get('samples', 0)} "
|
f"{game_line}Samples: {status.get('samples', 0)} "
|
||||||
f"Started: {_fmt_time(status.get('started'))} "
|
f"Started: {_fmt_time(status.get('started'))} "
|
||||||
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
|
f"Updated: {_fmt_time(status.get('updated'), '%H:%M:%S')}\n"
|
||||||
f"Log: {status.get('log', config.LOG_FILE)}"
|
f"Log: {status.get('log', config.LOG_FILE)}"
|
||||||
@@ -179,7 +228,3 @@ class RecorderPage(QWidget):
|
|||||||
self._info.setText("No recording yet. Press “Start recording”.")
|
self._info.setText("No recording yet. Press “Start recording”.")
|
||||||
self._latest.setText("")
|
self._latest.setText("")
|
||||||
self._warn.setVisible(False)
|
self._warn.setVisible(False)
|
||||||
|
|
||||||
def _load_report(self) -> None:
|
|
||||||
summary = summarize(config.LOG_FILE, last_n=10)
|
|
||||||
self._report.setPlainText(render_summary(summary, log_path=config.LOG_FILE))
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Setup page (M9 in the GUI): show environment + optional components, install missing."""
|
"""Settings page: components/deps, alerts (M8), account access (token), and uninstall."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,7 +8,10 @@ from PySide6.QtCore import Qt, QUrl, Signal
|
|||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..core import installer, sysenv, uninstall, updates
|
from ..core import alerts, installer, sysenv, uninstall, updates
|
||||||
from .theme import GOOD, MUTED, WARN
|
from .theme import GOOD, MUTED, WARN
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ _BACKEND_DESC = {
|
|||||||
class SetupPage(QWidget):
|
class SetupPage(QWidget):
|
||||||
_installed = Signal(int, str)
|
_installed = Signal(int, str)
|
||||||
_upd_state = Signal(object)
|
_upd_state = Signal(object)
|
||||||
|
changed = Signal() # alert settings saved — main window re-applies them live
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -60,7 +64,7 @@ class SetupPage(QWidget):
|
|||||||
root.setContentsMargins(20, 18, 20, 18)
|
root.setContentsMargins(20, 18, 20, 18)
|
||||||
root.setSpacing(16)
|
root.setSpacing(16)
|
||||||
|
|
||||||
title = QLabel("Setup")
|
title = QLabel("Settings")
|
||||||
title.setObjectName("PageTitle")
|
title.setObjectName("PageTitle")
|
||||||
root.addWidget(title)
|
root.addWidget(title)
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ class SetupPage(QWidget):
|
|||||||
env_layout.addWidget(self._env)
|
env_layout.addWidget(self._env)
|
||||||
root.addWidget(env_card)
|
root.addWidget(env_card)
|
||||||
|
|
||||||
comp_card, comp_layout = _panel("Optional components")
|
comp_card, comp_layout = _panel("Components & dependencies")
|
||||||
self._components = QVBoxLayout()
|
self._components = QVBoxLayout()
|
||||||
self._components.setSpacing(6)
|
self._components.setSpacing(6)
|
||||||
comp_layout.addLayout(self._components)
|
comp_layout.addLayout(self._components)
|
||||||
@@ -86,6 +90,39 @@ class SetupPage(QWidget):
|
|||||||
comp_layout.addLayout(controls)
|
comp_layout.addLayout(controls)
|
||||||
root.addWidget(comp_card)
|
root.addWidget(comp_card)
|
||||||
|
|
||||||
|
# Alerts (M8) — folded in from the old Notifications page.
|
||||||
|
alerts_card, alerts_layout = _panel("Notifications")
|
||||||
|
self._alerts_enabled = QCheckBox("Enable desktop notifications")
|
||||||
|
alerts_layout.addWidget(self._alerts_enabled)
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setHorizontalSpacing(12)
|
||||||
|
grid.setColumnStretch(2, 1)
|
||||||
|
self._gpu_alert = self._spin()
|
||||||
|
self._cpu_alert = self._spin()
|
||||||
|
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
|
||||||
|
grid.addWidget(self._gpu_alert, 0, 1)
|
||||||
|
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
|
||||||
|
grid.addWidget(self._cpu_alert, 1, 1)
|
||||||
|
alerts_layout.addLayout(grid)
|
||||||
|
alerts_note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
|
||||||
|
alerts_note.setObjectName("Muted")
|
||||||
|
alerts_note.setWordWrap(True)
|
||||||
|
alerts_layout.addWidget(alerts_note)
|
||||||
|
alerts_buttons = QHBoxLayout()
|
||||||
|
save_alerts = QPushButton("Save")
|
||||||
|
save_alerts.setObjectName("PrimaryButton")
|
||||||
|
save_alerts.clicked.connect(self._save_alerts)
|
||||||
|
test_alerts = QPushButton("Send test")
|
||||||
|
test_alerts.clicked.connect(self._test_alerts)
|
||||||
|
alerts_buttons.addWidget(save_alerts)
|
||||||
|
alerts_buttons.addWidget(test_alerts)
|
||||||
|
alerts_buttons.addStretch(1)
|
||||||
|
self._alerts_status = QLabel("")
|
||||||
|
self._alerts_status.setObjectName("Muted")
|
||||||
|
alerts_buttons.addWidget(self._alerts_status)
|
||||||
|
alerts_layout.addLayout(alerts_buttons)
|
||||||
|
root.addWidget(alerts_card)
|
||||||
|
|
||||||
# Account access (M13/M12): one Gitea token gates updates and session sharing.
|
# Account access (M13/M12): one Gitea token gates updates and session sharing.
|
||||||
upd_card, upd_layout = _panel("Account access")
|
upd_card, upd_layout = _panel("Account access")
|
||||||
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
||||||
@@ -115,7 +152,7 @@ class SetupPage(QWidget):
|
|||||||
self._output = QTextEdit()
|
self._output = QTextEdit()
|
||||||
self._output.setObjectName("Report")
|
self._output.setObjectName("Report")
|
||||||
self._output.setReadOnly(True)
|
self._output.setReadOnly(True)
|
||||||
self._output.setMinimumHeight(180)
|
self._output.setMinimumHeight(160)
|
||||||
self._output.setVisible(False)
|
self._output.setVisible(False)
|
||||||
root.addWidget(self._output)
|
root.addWidget(self._output)
|
||||||
root.addStretch(1)
|
root.addStretch(1)
|
||||||
@@ -129,8 +166,39 @@ class SetupPage(QWidget):
|
|||||||
root.addLayout(danger)
|
root.addLayout(danger)
|
||||||
|
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
self._load_alerts()
|
||||||
self._refresh_update_status()
|
self._refresh_update_status()
|
||||||
|
|
||||||
|
# --- alerts (M8) ----------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _spin() -> QDoubleSpinBox:
|
||||||
|
spin = QDoubleSpinBox()
|
||||||
|
spin.setRange(40, 110)
|
||||||
|
spin.setDecimals(0)
|
||||||
|
spin.setSingleStep(1)
|
||||||
|
spin.setSuffix(" °C")
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _load_alerts(self) -> None:
|
||||||
|
cfg = config.load_config()
|
||||||
|
self._alerts_enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
|
||||||
|
self._gpu_alert.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
|
||||||
|
self._cpu_alert.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
|
||||||
|
|
||||||
|
def _save_alerts(self) -> None:
|
||||||
|
config.update_config(
|
||||||
|
alerts_enabled=self._alerts_enabled.isChecked(),
|
||||||
|
gpu_temp_alert=self._gpu_alert.value(),
|
||||||
|
cpu_temp_alert=self._cpu_alert.value(),
|
||||||
|
)
|
||||||
|
self.changed.emit()
|
||||||
|
self._alerts_status.setText("Saved.")
|
||||||
|
|
||||||
|
def _test_alerts(self) -> None:
|
||||||
|
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
|
||||||
|
self._alerts_status.setText(
|
||||||
|
"Test sent." if ok else "notify-send not found — install libnotify-bin above.")
|
||||||
|
|
||||||
def _uninstall(self) -> None:
|
def _uninstall(self) -> None:
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
box.setIcon(QMessageBox.Icon.Warning)
|
box.setIcon(QMessageBox.Icon.Warning)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ QPushButton#NavButton {{
|
|||||||
}}
|
}}
|
||||||
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
QPushButton#NavButton:hover {{ background: {CARD}; color: {TEXT}; }}
|
||||||
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
|
QPushButton#NavButton:checked {{ background: {CARD}; color: #ffffff; font-weight: 600; }}
|
||||||
|
QLabel#NavSection {{ color: {MUTED}; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 12px 0; }}
|
||||||
|
|
||||||
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
|
#Card {{ background: {CARD}; border: 1px solid {CARD_BORDER}; border-radius: 12px; }}
|
||||||
QPushButton#CardHeader {{
|
QPushButton#CardHeader {{
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -66,6 +66,7 @@ class CrashDetectionTests(unittest.TestCase):
|
|||||||
log = self._diag_log(d)
|
log = self._diag_log(d)
|
||||||
_write_log(str(log), "Tarkov") # has session-start + game, no session-stop
|
_write_log(str(log), "Tarkov") # has session-start + game, no session-stop
|
||||||
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
|
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):
|
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
|
||||||
info = diagnostic.pending_crash()
|
info = diagnostic.pending_crash()
|
||||||
self.assertIsNotNone(info)
|
self.assertIsNotNone(info)
|
||||||
@@ -81,6 +82,7 @@ class CrashDetectionTests(unittest.TestCase):
|
|||||||
w.write_event("session-stop", "samples=1")
|
w.write_event("session-stop", "samples=1")
|
||||||
w.close()
|
w.close()
|
||||||
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
|
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):
|
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
|
||||||
self.assertIsNone(diagnostic.pending_crash())
|
self.assertIsNone(diagnostic.pending_crash())
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ class CrashDetectionTests(unittest.TestCase):
|
|||||||
log = self._diag_log(d)
|
log = self._diag_log(d)
|
||||||
_write_log(str(log), "Tarkov")
|
_write_log(str(log), "Tarkov")
|
||||||
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
|
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):
|
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None):
|
||||||
self.assertIsNotNone(diagnostic.pending_crash())
|
self.assertIsNotNone(diagnostic.pending_crash())
|
||||||
diagnostic.acknowledge_crash()
|
diagnostic.acknowledge_crash()
|
||||||
@@ -99,6 +102,7 @@ class CrashDetectionTests(unittest.TestCase):
|
|||||||
log = self._diag_log(d)
|
log = self._diag_log(d)
|
||||||
_write_log(str(log), "Tarkov")
|
_write_log(str(log), "Tarkov")
|
||||||
with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \
|
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):
|
mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=4321):
|
||||||
self.assertIsNone(diagnostic.pending_crash()) # it's in-progress, not crashed
|
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,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