Compare commits

...

9 Commits

Author SHA1 Message Date
jessey cc84bbda88 Merge pull request 'feat(gui): system-tray applet (M11) + GUI smoke tests — 0.19.0' (#15) from feat/m11-tray into main
release / release (push) Successful in 13s
Reviewed-on: #15
2026-05-22 07:22:04 +00:00
jessey 75a4da7af3 feat(gui): system-tray applet (M11) + GUI smoke tests — 0.19.0
QSystemTrayIcon applet (gui/tray.py, D13): menu with live CPU/GPU temp + memory
used/total, a status line, a Run Diagnostic submenu per detected game, plus Open
dashboard / Start-Stop recording / Snapshot-copy / Quit. Reuses the dashboard's
sample stream; drives existing MainWindow flows.

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

Completes the Desktop UI bundle (M10 + M11).

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:04:47 +02:00
10 changed files with 542 additions and 19 deletions
+38
View File
@@ -5,6 +5,44 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions).
## [0.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
+9 -5
View File
@@ -16,7 +16,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | |
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
| M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 |
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ |
@@ -73,10 +73,14 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
nav, a live dashboard (circular gauges + collapsible per-subsystem cards, temperature-
colored values), and a **Recording/Logs page** with full M3 controls (start/stop/status +
post-crash report). Health/Inventory remain placeholders until M4/M5. GUI-first per D17.
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. Dropdown shows live M1 readouts
(CPU temp, GPU temp, memory used/total, status dot) and is led by a **Run Diagnostic**
action (the guided diagnostic session), plus Open dashboard / Start-Stop recording /
Snapshot / Quit (D13). Optional; shares the Qt dependency with M10.
- **M11 Tray applet** — `QSystemTrayIcon` menu-bar applet. *Implemented (`gui/tray.py`, D13):*
the menu shows live M1 readouts (CPU temp, GPU temp, memory used/total) + a status line
(Normal / Hot / GPU not responding), led by a **Run Diagnostic** submenu (per detected game →
the guided session), plus Open dashboard / Start-Stop recording / Snapshot-copy / Quit. It
shares the dashboard's sample stream (no extra sampling) and drives the existing MainWindow
flows. With a tray present, closing the window **hides to the tray** (Quit exits); `rigdoctor-gui
--tray` starts hidden for autostart. Optional; shares the Qt dependency with M10. *Needs a tray
host* — on GNOME that means the AppIndicator extension; degrades to no-op if none is available.
- **M9 Installer** — interactive wizard layered on the `.deb` (D8); apt-first dependency
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
+5 -3
View File
@@ -37,9 +37,11 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
- [ ] SMART integration (smartmontools if present)
## Phase 4 — Desktop UI & installer
- [ ] M10 desktop GUI (PySide6: dashboard, log browser, report viewer, logger controls)
- [ ] M11 tray / menu-bar applet (QSystemTrayIcon: live M1 readouts + Run Diagnostic +
supporting actions — D13)
- [x] M10 desktop GUI (PySide6: dashboard w/ history graphs, logs, health, games, environment,
inventory, setup, notifications, share)
- [x] M11 tray / menu-bar applet (`gui/tray.py`: live CPU/GPU temp + memory readouts, status
line, Run Diagnostic submenu per game, Open dashboard / Start-Stop recording / Snapshot /
Quit — D13; close-to-tray, `--tray` autostart). Needs a tray host (AppIndicator on GNOME).
- [~] Guided diagnostic session (pick game → focused M3 capture → M4 scan → findings),
shared by tray/GUI/CLI — *core + CLI + GUI done* (`core/diagnostic.py`, `rigdoctor
diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.16.0"
version = "0.19.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.16.0"
__version__ = "0.19.0"
+7 -1
View File
@@ -30,7 +30,13 @@ def main(argv: list[str] | None = None) -> int:
interval = float(load_config().get("interval", 1.0))
window = MainWindow(interval=interval)
window.show()
# `--tray` starts hidden to the system tray (for autostart); if no tray is available,
# fall back to showing the window so the app is never invisible-and-unreachable.
args = argv if argv is not None else sys.argv
if "--tray" in args and window.tray_available():
window.start_minimized_note()
else:
window.show()
return app.exec()
+150
View File
@@ -0,0 +1,150 @@
"""Inventory page (M5 in the GUI): system inventory with copy/save + admin re-collect."""
from __future__ import annotations
import os
import threading
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..core import inventory
def _section_card(section) -> QFrame:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(6)
title = QLabel(section.title)
title.setStyleSheet("font-weight: 700; background: transparent;")
layout.addWidget(title)
grid = QGridLayout()
grid.setColumnStretch(1, 1)
grid.setHorizontalSpacing(14)
grid.setVerticalSpacing(4)
for row, (key, value) in enumerate(section.items):
k = QLabel(key)
k.setObjectName("Muted")
v = QLabel(value)
v.setWordWrap(True)
v.setStyleSheet("background: transparent;")
grid.addWidget(k, row, 0)
grid.addWidget(v, row, 1)
layout.addLayout(grid)
return card
class InventoryPage(QWidget):
_result = Signal(object) # list[Section]
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._sections: list = []
self._result.connect(self._render)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
header = QHBoxLayout()
title = QLabel("Inventory")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
self._status = QLabel("")
self._status.setObjectName("Muted")
header.addWidget(self._status)
self._copy_btn = QPushButton("Copy Markdown")
self._copy_btn.clicked.connect(self._copy)
header.addWidget(self._copy_btn)
self._save_btn = QPushButton("Save…")
self._save_btn.clicked.connect(self._save)
header.addWidget(self._save_btn)
self._refresh_btn = QPushButton("Refresh")
self._refresh_btn.setObjectName("PrimaryButton")
self._refresh_btn.clicked.connect(self._run)
header.addWidget(self._refresh_btn)
root.addLayout(header)
self._scroll = scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
self._container = QWidget()
self._list = QVBoxLayout(self._container)
self._list.setContentsMargins(0, 0, 0, 0)
self._list.setSpacing(12)
self._list.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(self._container)
root.addWidget(scroll, 1)
QTimer.singleShot(300, self._run)
def _run(self) -> None:
self._busy("Collecting…")
threading.Thread(target=self._work, daemon=True).start()
def _work(self) -> None:
try:
sections = inventory.collect()
except Exception:
sections = []
self._result.emit(sections)
def _busy(self, text: str) -> None:
self._status.setText(text)
for b in (self._refresh_btn, self._copy_btn, self._save_btn):
b.setEnabled(False)
def _render(self, sections) -> None:
self._refresh_btn.setEnabled(True)
self._copy_btn.setEnabled(True)
self._save_btn.setEnabled(True)
if sections is None: # collection failed — keep current
self._status.setText("collection failed")
return
if sections == self._sections: # unchanged — don't rebuild (would jump scroll)
self._status.setText("")
return
scroll_pos = self._scroll.verticalScrollBar().value()
self._sections = sections
while self._list.count():
item = self._list.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
for section in sections:
self._list.addWidget(_section_card(section))
self._list.addStretch(1)
self._status.setText("")
# restore scroll after the layout settles so re-renders don't yank to the top
QTimer.singleShot(0, lambda: self._scroll.verticalScrollBar().setValue(scroll_pos))
def _copy(self) -> None:
if self._sections:
QApplication.clipboard().setText(inventory.render_markdown(self._sections))
self._status.setText("copied as Markdown")
def _save(self) -> None:
if not self._sections:
return
path, _ = QFileDialog.getSaveFileName(self, "Save inventory", "rigdoctor-inventory.md", "Markdown (*.md)")
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(inventory.render_markdown(self._sections))
self._status.setText(f"saved {os.path.basename(path)}")
+119 -8
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import html
import os
import sys
import threading
from pathlib import Path
from PySide6.QtCore import Qt, QProcess, QTimer, Signal
from PySide6.QtGui import QTextDocument
from PySide6.QtGui import QIcon, QTextDocument
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
@@ -19,6 +21,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QStackedWidget,
QSystemTrayIcon,
QTextEdit,
QVBoxLayout,
QWidget,
@@ -31,14 +34,17 @@ from .dashboard import Dashboard
from .environment_page import EnvironmentPage
from .games_page import GamesPage
from .health_page import HealthPage
from .inventory_page import InventoryPage
from .notifications_page import NotificationsPage
from .recorder_page import RecorderPage
from .setup_page import SetupPage
from .share_page import SharePage
from .theme import ACCENT, GOOD, MUTED
from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT
from .tray import TrayIcon
from .worker import SamplerWorker
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Setup", "Notifications", "Share"]
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Inventory", "Setup", "Notifications", "Share"]
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
class MainWindow(QMainWindow):
@@ -71,6 +77,7 @@ class MainWindow(QMainWindow):
self.games_page = GamesPage()
self.games_page.new_count_changed.connect(self._set_games_badge)
self.environment_page = EnvironmentPage()
self.inventory_page = InventoryPage()
self.setup_page = SetupPage()
self.notifications_page = NotificationsPage()
self.notifications_page.changed.connect(self._apply_alert_settings)
@@ -80,9 +87,10 @@ class MainWindow(QMainWindow):
self._stack.addWidget(self.health_page) # 2 Health
self._stack.addWidget(self.games_page) # 3 Games
self._stack.addWidget(self.environment_page) # 4 Environment
self._stack.addWidget(self.setup_page) # 5 Setup
self._stack.addWidget(self.notifications_page) # 6 Notifications
self._stack.addWidget(self.share_page) # 7 Share
self._stack.addWidget(self.inventory_page) # 5 Inventory
self._stack.addWidget(self.setup_page) # 6 Setup
self._stack.addWidget(self.notifications_page) # 7 Notifications
self._stack.addWidget(self.share_page) # 8 Share
content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar())
@@ -124,6 +132,30 @@ class MainWindow(QMainWindow):
self._update_timer.timeout.connect(self._start_update_check)
self._update_timer.start()
# Reflect any capture (manual, diagnostic, or the Steam wrapper) in the sidebar on
# every page, so it's always clear when RigDoctor is recording and for which game.
self._rec_timer = QTimer(self)
self._rec_timer.setInterval(1500)
self._rec_timer.timeout.connect(self._update_recording)
self._rec_timer.start()
self._update_recording()
# System-tray applet (M11) — optional; only when the desktop offers a tray. When
# present, closing the window hides to the tray instead of quitting.
self._tray = None
self._quitting = False
self._tray_hint_shown = False
if QSystemTrayIcon.isSystemTrayAvailable():
icon = self.windowIcon() if not self.windowIcon().isNull() else QIcon(str(_ICON))
self._tray = TrayIcon(
self, icon,
gpu_alert=float(cfg.get("gpu_temp_alert", 90.0)),
cpu_alert=float(cfg.get("cpu_temp_alert", 95.0)),
)
self._worker.sampled.connect(self._tray.update_sample)
self._tray.show()
QApplication.instance().setQuitOnLastWindowClosed(False)
def _build_sidebar(self) -> QFrame:
bar = QFrame()
bar.setObjectName("Sidebar")
@@ -138,6 +170,17 @@ class MainWindow(QMainWindow):
subtitle.setObjectName("AppSubtitle")
v.addWidget(title)
v.addWidget(subtitle)
# Global recording indicator — visible on every page while a capture runs.
self._rec_indicator = QLabel()
self._rec_indicator.setWordWrap(True)
self._rec_indicator.setTextFormat(Qt.TextFormat.RichText)
self._rec_indicator.setStyleSheet(
f"background: #241316; border: 1px solid {CRIT}; border-radius: 8px; padding: 8px 10px;"
)
self._rec_indicator.hide()
v.addSpacing(12)
v.addWidget(self._rec_indicator)
v.addSpacing(18)
group = QButtonGroup(self)
@@ -234,9 +277,64 @@ class MainWindow(QMainWindow):
self._elevated.emit()
def _on_elevated(self) -> None:
# Re-run Health now that root-only SMART data is available. (dmidecode is still
# collected and used by the relay guest view + the CLI `rigdoctor inventory`.)
# Re-run Health + Inventory now that root-only data is available (SMART for Health,
# dmidecode motherboard/BIOS/RAM for Inventory).
self.health_page._run()
self.inventory_page._run()
# --- tray-driven actions (M11) ----------------------------------------------------
def show_page(self, name: str) -> None:
"""Bring the window forward on a given page (used by the tray)."""
if name in self._nav_buttons:
self._stack.setCurrentIndex(_NAV_ITEMS.index(name))
self._nav_buttons[name].setChecked(True)
self.showNormal()
self.raise_()
self.activateWindow()
def show_dashboard(self) -> None:
self.show_page("Dashboard")
def tray_available(self) -> bool:
return self._tray is not None
def start_minimized_note(self) -> None:
"""Started hidden to the tray (autostart) — let the user know it's there."""
if self._tray is not None:
self._tray_hint_shown = True
self._tray.showMessage(
"RigDoctor", "Running in the tray — right-click the icon for actions.",
QSystemTrayIcon.MessageIcon.Information, 4000,
)
def run_diagnostic(self, name: str, appid: str) -> None:
self.show_page("Games")
self.games_page._start_diagnostic(name, appid)
def quit_app(self) -> None:
self._quitting = True
self._worker.stop()
self.share_page.shutdown()
if self._tray is not None:
self._tray.hide()
QApplication.instance().quit()
def _update_recording(self) -> None:
from ..core import diagnostic
status = diagnostic.active()
if not status:
self._rec_indicator.hide()
return
game = status.get("game")
lines = [f"<span style='color:{CRIT};'>●</span> <b style='color:{TEXT};'>Recording</b>"]
if game:
lines.append(f"<span style='color:{TEXT};'>{html.escape(str(game))}</span>")
if status.get("gpu_lost"):
lines.append(f"<span style='color:{CRIT};'>⚠ GPU-lost</span>")
self._rec_indicator.setText("<br>".join(lines))
self._rec_indicator.show()
def _set_games_badge(self, count: int) -> None:
btn = self._nav_buttons.get("Games")
@@ -319,6 +417,19 @@ class MainWindow(QMainWindow):
self._update_label.setText("up-to-date")
def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
# With a tray, closing the window hides it (the app keeps running for the tray
# readouts + any capture); Quit from the tray menu exits for real.
if self._tray is not None and not self._quitting:
event.ignore()
self.hide()
if not self._tray_hint_shown:
self._tray_hint_shown = True
self._tray.showMessage(
"RigDoctor",
"Still running in the tray — right-click the icon for actions or Quit.",
QSystemTrayIcon.MessageIcon.Information, 5000,
)
return
self._worker.stop()
self.share_page.shutdown()
super().closeEvent(event)
+144
View File
@@ -0,0 +1,144 @@
"""System-tray applet (M11, D13): live readouts + quick actions over the shared engine.
A QSystemTrayIcon whose menu shows at-a-glance CPU/GPU temp + memory and a status dot, led
by **Run Diagnostic** (the guided session), plus Open dashboard / Start-Stop recording /
Snapshot / Quit. It consumes the same sample stream as the dashboard (no extra sampling) and
drives the existing MainWindow flows — one engine, another front-end.
"""
from __future__ import annotations
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from ..core import reccontrol
def _gpu_temp(sample):
for r in sample.readings:
if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None:
return r.value
return None
def _cpu_temp(sample):
temps = [r for r in sample.readings if r.source == "cpu" and r.metric == "temp" and r.value is not None]
for r in temps:
low = r.label.lower()
if low.startswith("package") or "tctl" in low or "tdie" in low:
return r.value
return max((r.value for r in temps), default=None)
def _memory(sample):
used = total = pct = None
for r in sample.readings:
if r.source == "memory":
if r.metric == "used":
used = r.value
elif r.metric == "total":
total = r.value
elif r.metric == "used_pct":
pct = r.value
return used, total, pct
def _gpu_lost(sample) -> bool:
return any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout"
for r in sample.readings)
class TrayIcon(QSystemTrayIcon):
def __init__(self, window, icon, gpu_alert: float = 90.0, cpu_alert: float = 95.0) -> None:
super().__init__(icon, window)
self._window = window
self._gpu_alert = gpu_alert
self._cpu_alert = cpu_alert
self._last = None
self.setToolTip("RigDoctor")
menu = QMenu()
self._status_act = self._readout(menu, "● starting…")
self._cpu_act = self._readout(menu, "CPU temp: —")
self._gpu_act = self._readout(menu, "GPU temp: —")
self._mem_act = self._readout(menu, "Memory: —")
menu.addSeparator()
self._diag_menu = menu.addMenu("Run Diagnostic")
self._diag_menu.aboutToShow.connect(self._rebuild_diag_menu)
menu.addAction("Open dashboard", self._window.show_dashboard)
self._rec_act = menu.addAction("Start recording", self._toggle_record)
menu.addAction("Snapshot (copy)", self._snapshot)
menu.addSeparator()
menu.addAction("Quit", self._window.quit_app)
menu.aboutToShow.connect(self._refresh_actions)
self.setContextMenu(menu)
self.activated.connect(self._on_activated)
@staticmethod
def _readout(menu: QMenu, text: str):
act = menu.addAction(text)
act.setEnabled(False) # display-only line
return act
def _on_activated(self, reason) -> None:
if reason in (QSystemTrayIcon.ActivationReason.Trigger,
QSystemTrayIcon.ActivationReason.DoubleClick):
self._window.show_dashboard()
def update_sample(self, sample) -> None:
self._last = sample
cpu, gpu = _cpu_temp(sample), _gpu_temp(sample)
used, total, pct = _memory(sample)
self._cpu_act.setText(f"CPU temp: {cpu:.0f} °C" if cpu is not None else "CPU temp: —")
self._gpu_act.setText(f"GPU temp: {gpu:.0f} °C" if gpu is not None else "GPU temp: —")
if used is not None and total is not None:
extra = f" ({pct:.0f}%)" if pct is not None else ""
self._mem_act.setText(f"Memory: {used:.1f} / {total:.1f} GB{extra}")
else:
self._mem_act.setText("Memory: —")
if _gpu_lost(sample):
self._status_act.setText("● GPU not responding")
elif (gpu is not None and gpu >= self._gpu_alert) or (cpu is not None and cpu >= self._cpu_alert):
self._status_act.setText("● Hot — over alert threshold")
else:
self._status_act.setText("● Normal")
bits = []
if cpu is not None:
bits.append(f"CPU {cpu:.0f}°C")
if gpu is not None:
bits.append(f"GPU {gpu:.0f}°C")
self.setToolTip("RigDoctor" + ("" + " ".join(bits) if bits else ""))
def _refresh_actions(self) -> None:
self._rec_act.setText("Stop recording" if reccontrol.running_pid() else "Start recording")
def _toggle_record(self) -> None:
if reccontrol.running_pid():
reccontrol.stop_background()
else:
reccontrol.start_background()
def _rebuild_diag_menu(self) -> None:
from ..core import steam
self._diag_menu.clear()
games = steam.cached_games()
if not games:
self._diag_menu.addAction("Open Games to pick a game…",
lambda: self._window.show_page("Games"))
return
for g in games[:20]:
self._diag_menu.addAction(
g.name,
lambda _checked=False, name=g.name, appid=g.appid: self._window.run_diagnostic(name, appid),
)
def _snapshot(self) -> None:
if self._last is None:
return
from ..render import render_snapshot
QApplication.clipboard().setText(render_snapshot(self._last))
self.showMessage("RigDoctor", "Snapshot copied to clipboard.",
QSystemTrayIcon.MessageIcon.Information, 4000)
+68
View File
@@ -0,0 +1,68 @@
"""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._NAV_ITEMS))
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()