diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f54068..344e2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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 diff --git a/docs/MODULES.md b/docs/MODULES.md index 587b6d5..ce0fddc 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -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`), diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5f03104..7c7eaa7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index debbbec..0354214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.18.2" +version = "0.19.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index c4ac1a2..d9d095c 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor β€” modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.18.2" +__version__ = "0.19.0" diff --git a/src/rigdoctor/gui/app.py b/src/rigdoctor/gui/app.py index 3d04973..5d0febe 100644 --- a/src/rigdoctor/gui/app.py +++ b/src/rigdoctor/gui/app.py @@ -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() diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index f92aac2..76ae2d3 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -6,9 +6,10 @@ 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, @@ -20,6 +21,7 @@ from PySide6.QtWidgets import ( QMessageBox, QPushButton, QStackedWidget, + QSystemTrayIcon, QTextEdit, QVBoxLayout, QWidget, @@ -38,9 +40,11 @@ from .recorder_page import RecorderPage from .setup_page import SetupPage from .share_page import SharePage from .theme import ACCENT, CRIT, GOOD, MUTED, TEXT +from .tray import TrayIcon from .worker import SamplerWorker _NAV_ITEMS = ["Dashboard", "Logs", "Health", "Games", "Environment", "Inventory", "Setup", "Notifications", "Share"] +_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg" class MainWindow(QMainWindow): @@ -136,6 +140,22 @@ class MainWindow(QMainWindow): 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") @@ -262,6 +282,44 @@ class MainWindow(QMainWindow): 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 @@ -359,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) diff --git a/src/rigdoctor/gui/tray.py b/src/rigdoctor/gui/tray.py new file mode 100644 index 0000000..dde742f --- /dev/null +++ b/src/rigdoctor/gui/tray.py @@ -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) diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py new file mode 100644 index 0000000..a65a9cb --- /dev/null +++ b/tests/test_gui_smoke.py @@ -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()