diff --git a/CHANGELOG.md b/CHANGELOG.md index cae62df..a29927a 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.4.0] - 2026-05-21 +### Added +- **Alerts (M8)**: desktop notifications (via `notify-send`) for **overheat** (GPU/CPU past a + threshold), **GPU-lost** (nvidia-smi timeout), and a **new version available** (fired once + per version). Edge-triggered with a cooldown so it doesn't spam. Degrades gracefully if + `notify-send` isn't installed. +- **Notifications page**: configure alerts (enable/disable, GPU/CPU temperature thresholds) + with a "Send test" button; changes apply live and persist to `config.toml`. +- **App icon**: ships a RigDoctor icon and shows it in the dock/launcher. The GUI + **self-registers** the icon + `.desktop` on launch (and sets the Wayland app-id), so a + self-update + relaunch picks it up — no need to re-run the installer. + ## [0.3.2] - 2026-05-21 ### Changed - Replaced the per-page "Run with admin" buttons with a **single password prompt at launch** diff --git a/docs/MODULES.md b/docs/MODULES.md index 079908f..65ad78f 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -12,7 +12,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done | M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ | -| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ⬜ | +| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 | | M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 | | M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 | diff --git a/install.sh b/install.sh index 60240ab..83bf791 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,8 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) uninstall() { echo "Removing RigDoctor user-local install…" rm -rf "$VENV" - rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" + rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" \ + "$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg" echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)" } @@ -81,6 +82,17 @@ mkdir -p "$BIN_DIR" ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor" ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui" +# Install the app icon (for the dock/launcher); fall back to a stock icon. +ICON_NAME=utilities-system-monitor +ICON_SRC=$("$VENV/bin/python" -c "import os, rigdoctor.gui as g; print(os.path.join(os.path.dirname(g.__file__), 'assets', 'rigdoctor.svg'))" 2>/dev/null || true) +if [ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ]; then + ICON_DST="$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg" + mkdir -p "$(dirname "$ICON_DST")" + cp "$ICON_SRC" "$ICON_DST" + ICON_NAME=rigdoctor + command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -qtf "$DATA_HOME/icons/hicolor" 2>/dev/null || true +fi + mkdir -p "$DESKTOP_DIR" cat > "$DESKTOP_FILE" </dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true echo echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed." diff --git a/pyproject.toml b/pyproject.toml index bea4782..6723884 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.3.2" +version = "0.4.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" @@ -21,3 +21,6 @@ rigdoctor-gui = "rigdoctor.gui.app:main" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +rigdoctor = ["gui/assets/*.svg"] diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 438d528..04930d5 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.3.2" +__version__ = "0.4.0" diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index 03bf26d..1b3d731 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -139,6 +139,9 @@ DEFAULTS: dict = { "log_backups": 10, # keep this many rotated segments (bounds disk use) "update_check_minutes": 30, # re-check for updates this often while running (0 = off) "elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode) + "alerts_enabled": True, # desktop notifications on overheat / GPU-lost / new version + "gpu_temp_alert": 90.0, # °C — alert when GPU reaches this + "cpu_temp_alert": 95.0, # °C — alert when CPU reaches this } @@ -154,3 +157,27 @@ def load_config() -> dict: except Exception: pass return cfg + + +def _toml_value(value) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) + return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def save_config(values: dict) -> None: + """Write a flat config.toml (stdlib has no TOML writer).""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + lines = ["# RigDoctor config — edit in the app (Notifications) or here."] + lines += [f"{key} = {_toml_value(value)}" for key, value in values.items()] + CONFIG_FILE.write_text("\n".join(lines) + "\n") + + +def update_config(**changes) -> dict: + """Merge changes into the current effective config and persist them.""" + cfg = load_config() + cfg.update(changes) + save_config(cfg) + return cfg diff --git a/src/rigdoctor/core/alerts.py b/src/rigdoctor/core/alerts.py new file mode 100644 index 0000000..47cbfd3 --- /dev/null +++ b/src/rigdoctor/core/alerts.py @@ -0,0 +1,91 @@ +"""Desktop alerts (M8): notify on overheat / GPU-lost / new version via notify-send. + +Edge-triggered: an alert fires when a condition becomes true (not every sample), and +can fire again only after it has cleared and a cooldown has passed — so a hot GPU or a +1-Hz sample loop doesn't spam notifications. Degrades to a no-op if notify-send is absent. +""" + +from __future__ import annotations + +import shutil +import subprocess +import time + +from .sample import Sample + +APP_NAME = "RigDoctor" +_ICON = "utilities-system-monitor" + + +def available() -> bool: + return shutil.which("notify-send") is not None + + +def notify(title: str, message: str, urgency: str = "normal") -> bool: + """Send a desktop notification (best-effort). urgency: low|normal|critical.""" + if not available(): + return False + try: + subprocess.run( + ["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _ICON, title, message], + timeout=10, + check=False, + ) + return True + except (subprocess.SubprocessError, OSError): + return False + + +class AlertMonitor: + """Evaluate samples and raise edge-triggered desktop alerts.""" + + def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0): + self.gpu_temp = gpu_temp + self.cpu_temp = cpu_temp + self.cooldown = cooldown + self.enabled = True + self._active: dict[str, bool] = {} + self._last: dict[str, float] = {} + + def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None: + if self._active.get(key): + return # already alerting; wait until it clears + now = time.time() + if now - self._last.get(key, 0.0) < self.cooldown: + return + self._active[key] = True + self._last[key] = now + notify(title, message, urgency) + + def _clear(self, key: str) -> None: + self._active[key] = False + + def check(self, sample: Sample) -> None: + if not self.enabled: + return + gpu_t = next( + (r.value for r in sample.readings + if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None), + None, + ) + if gpu_t is not None: + if gpu_t >= self.gpu_temp: + self._fire("gpu_temp", "GPU overheating", f"GPU at {gpu_t:.0f} °C") + else: + self._clear("gpu_temp") + + cpu_temps = [r.value for r in sample.readings + if r.source == "cpu" and r.metric == "temp" and r.value is not None] + if cpu_temps: + cpu_t = max(cpu_temps) + if cpu_t >= self.cpu_temp: + self._fire("cpu_temp", "CPU overheating", f"CPU at {cpu_t:.0f} °C") + else: + self._clear("cpu_temp") + + lost = any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout" + for r in sample.readings) + if lost: + self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped") + else: + self._clear("gpu_lost") diff --git a/src/rigdoctor/core/uninstall.py b/src/rigdoctor/core/uninstall.py index 6770ce7..7c541a9 100644 --- a/src/rigdoctor/core/uninstall.py +++ b/src/rigdoctor/core/uninstall.py @@ -23,6 +23,7 @@ def targets(purge: bool = False) -> list[Path]: home / ".local" / "bin" / "rigdoctor", home / ".local" / "bin" / "rigdoctor-gui", share / "applications" / "rigdoctor.desktop", + share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg", ] if purge: items += [config.CONFIG_DIR, config.STATE_DIR, config.DATA_DIR] diff --git a/src/rigdoctor/gui/app.py b/src/rigdoctor/gui/app.py index 2ddcf77..3d04973 100644 --- a/src/rigdoctor/gui/app.py +++ b/src/rigdoctor/gui/app.py @@ -3,18 +3,28 @@ from __future__ import annotations import sys +from pathlib import Path +from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication from ..config import load_config +from . import desktop from .main_window import MainWindow from .theme import STYLESHEET +ICON = Path(__file__).parent / "assets" / "rigdoctor.svg" + def main(argv: list[str] | None = None) -> int: + desktop.ensure() # self-register icon + .desktop so updates show it without re-installing app = QApplication(argv if argv is not None else sys.argv) app.setApplicationName("RigDoctor") app.setApplicationDisplayName("RigDoctor") + # Match the installed rigdoctor.desktop so the dock/launcher shows our icon (Wayland app-id). + app.setDesktopFileName("rigdoctor") + if ICON.exists(): + app.setWindowIcon(QIcon(str(ICON))) app.setStyle("Fusion") app.setStyleSheet(STYLESHEET) diff --git a/src/rigdoctor/gui/assets/rigdoctor.svg b/src/rigdoctor/gui/assets/rigdoctor.svg new file mode 100644 index 0000000..2cdb524 --- /dev/null +++ b/src/rigdoctor/gui/assets/rigdoctor.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/rigdoctor/gui/desktop.py b/src/rigdoctor/gui/desktop.py new file mode 100644 index 0000000..f4e112e --- /dev/null +++ b/src/rigdoctor/gui/desktop.py @@ -0,0 +1,51 @@ +"""Best-effort desktop integration: install our icon + .desktop so the dock shows it. + +Runs at GUI launch (idempotent), so a self-update + relaunch refreshes the icon without +re-running install.sh. No-op for non-installed (dev) runs where the launcher is absent. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from .. import config + +_ICON_SRC = Path(__file__).parent / "assets" / "rigdoctor.svg" + +_DESKTOP = """[Desktop Entry] +Type=Application +Name=RigDoctor +Comment=Hardware monitoring & crash diagnostics for Linux gamers +Exec={exec} +Icon=rigdoctor +Terminal=false +Categories=System;Monitor;Utility; +StartupWMClass=rigdoctor +""" + + +def ensure() -> None: + share = config.DATA_DIR.parent # ~/.local/share + + try: + if _ICON_SRC.exists(): + icon_dst = share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg" + icon_dst.parent.mkdir(parents=True, exist_ok=True) + if not icon_dst.exists() or icon_dst.read_bytes() != _ICON_SRC.read_bytes(): + shutil.copyfile(_ICON_SRC, icon_dst) + except OSError: + pass + + gui_exec = Path(sys.executable).with_name("rigdoctor-gui") + if not gui_exec.exists(): # dev / not a normal install — don't fabricate a .desktop + return + try: + desktop = share / "applications" / "rigdoctor.desktop" + content = _DESKTOP.format(exec=gui_exec) + desktop.parent.mkdir(parents=True, exist_ok=True) + if not desktop.exists() or desktop.read_text() != content: + desktop.write_text(content) + except OSError: + pass diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index 2cd930b..ceb3b2c 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -26,16 +26,17 @@ from PySide6.QtWidgets import ( from .. import __version__ from ..config import load_config -from ..core import elevation, updates +from ..core import alerts, elevation, updates from .dashboard import Dashboard 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 .theme import ACCENT, GOOD, MUTED from .worker import SamplerWorker -_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory"] +_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory", "Notifications"] class MainWindow(QMainWindow): @@ -67,11 +68,14 @@ class MainWindow(QMainWindow): self.health_page = HealthPage() self.setup_page = SetupPage() self.inventory_page = InventoryPage() + self.notifications_page = NotificationsPage() + self.notifications_page.changed.connect(self._apply_alert_settings) self._stack.addWidget(self.dashboard) # 0 Dashboard self._stack.addWidget(self.recorder_page) # 1 Logs self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.setup_page) # 3 Setup self._stack.addWidget(self.inventory_page) # 4 Inventory + self._stack.addWidget(self.notifications_page) # 5 Notifications content_layout.addWidget(self._stack) layout.addWidget(self._build_sidebar()) @@ -79,6 +83,15 @@ class MainWindow(QMainWindow): self._worker = SamplerWorker(interval=interval) self._worker.sampled.connect(self.dashboard.update_sample) + # Desktop alerts (M8): overheat / GPU-lost from the sample stream, new-version below. + # Configurable on the Notifications page; gated by AlertMonitor.enabled. + self._notified_update_tag = None + self._alert_monitor = alerts.AlertMonitor( + gpu_temp=float(cfg.get("gpu_temp_alert", 90.0)), + cpu_temp=float(cfg.get("cpu_temp_alert", 95.0)), + ) + self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True)) + self._worker.sampled.connect(self._alert_monitor.check) self._worker.start() # Ask for the password once at launch and collect root-only data (SMART + @@ -216,6 +229,12 @@ class MainWindow(QMainWindow): self.health_page._run() self.inventory_page._run() + def _apply_alert_settings(self) -> None: + cfg = load_config() + self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True)) + self._alert_monitor.gpu_temp = float(cfg.get("gpu_temp_alert", 90.0)) + self._alert_monitor.cpu_temp = float(cfg.get("cpu_temp_alert", 95.0)) + def _manual_check(self) -> None: if self._applied: return @@ -279,6 +298,9 @@ class MainWindow(QMainWindow): self._update_label.setText(f'{tag} available') self._update_btn.setText(f"Update to {tag}") self._update_btn.setVisible(True) + if self._alert_monitor.enabled and tag != self._notified_update_tag: + self._notified_update_tag = tag # once per version, not every poll + alerts.notify("Update available", f"RigDoctor {tag} is available — open RigDoctor to update.") else: # UP_TO_DATE self._update_label.setText("up-to-date") diff --git a/src/rigdoctor/gui/notifications_page.py b/src/rigdoctor/gui/notifications_page.py new file mode 100644 index 0000000..1539888 --- /dev/null +++ b/src/rigdoctor/gui/notifications_page.py @@ -0,0 +1,108 @@ +"""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).") diff --git a/tests/test_alerts.py b/tests/test_alerts.py new file mode 100644 index 0000000..f8af1b3 --- /dev/null +++ b/tests/test_alerts.py @@ -0,0 +1,38 @@ +"""Tests for the M8 alert monitor (edge-triggered; notify mocked).""" + +import unittest +from unittest import mock + +from rigdoctor.core import alerts +from rigdoctor.core.sample import Reading, Sample + + +def _gpu(temp): + return Sample(readings=[Reading("gpu", "temp", temp, "°C")]) + + +class AlertTests(unittest.TestCase): + @mock.patch.object(alerts, "notify") + def test_edge_triggered_no_repeat(self, m): + mon = alerts.AlertMonitor(gpu_temp=90.0, cooldown=0.0) + mon.check(_gpu(95)) # fires + mon.check(_gpu(96)) # still hot — no repeat while active + self.assertEqual(m.call_count, 1) + mon.check(_gpu(50)) # clears + mon.check(_gpu(95)) # hot again — fires + self.assertEqual(m.call_count, 2) + + @mock.patch.object(alerts, "notify") + def test_no_alert_below_threshold(self, m): + alerts.AlertMonitor(gpu_temp=90.0).check(_gpu(70)) + m.assert_not_called() + + @mock.patch.object(alerts, "notify") + def test_gpu_lost(self, m): + mon = alerts.AlertMonitor() + mon.check(Sample(readings=[Reading("gpu", "status", None, "", "query-timeout")])) + m.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..97ce17b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,32 @@ +"""Tests for config save/load (flat TOML writer).""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from rigdoctor import config + + +class ConfigTests(unittest.TestCase): + def test_save_load_round_trip(self): + with tempfile.TemporaryDirectory() as d: + cf = Path(d) / "config.toml" + with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)): + config.save_config({"alerts_enabled": False, "gpu_temp_alert": 88.0, "update_check_minutes": 5}) + loaded = config.load_config() + self.assertIs(loaded["alerts_enabled"], False) + self.assertEqual(loaded["gpu_temp_alert"], 88.0) + self.assertEqual(loaded["update_check_minutes"], 5) + + def test_update_config_merges_and_keeps_defaults(self): + with tempfile.TemporaryDirectory() as d: + cf = Path(d) / "config.toml" + with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)): + config.update_config(cpu_temp_alert=70.0) + self.assertEqual(config.load_config()["cpu_temp_alert"], 70.0) + self.assertEqual(config.load_config()["gpu_temp_alert"], 90.0) # default preserved + + +if __name__ == "__main__": + unittest.main()