feat: alerts (M8), notifications config page, and app icon (0.4.0)
release / release (push) Successful in 14s

- feat(alerts): desktop notifications (notify-send) for overheat (GPU/CPU past a
  configurable threshold), GPU-lost, and a new-version-available alert (once per
  version). Edge-triggered with cooldown so it doesn't spam (core/alerts.py)
- feat(gui): Notifications page to configure alerts (enable, GPU/CPU thresholds,
  Send test); changes apply live and persist via config.save_config/update_config
- feat(gui): ship a RigDoctor icon; the GUI self-registers the icon + .desktop on
  launch and sets the Wayland app-id, so the dock shows it after an update + relaunch
  (no installer re-run); installer/uninstaller updated to manage the icon
- config: alerts_enabled, gpu_temp_alert, cpu_temp_alert; flat-TOML writer
- tests for the alert monitor and config round-trip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:22:13 +02:00
parent 54c0971ac3
commit e3b20089f0
15 changed files with 424 additions and 7 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.3.2"
__version__ = "0.4.0"
+27
View File
@@ -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
+91
View File
@@ -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")
+1
View File
@@ -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]
+10
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect x="8" y="8" width="240" height="240" rx="52" fill="#15181e"/>
<circle cx="128" cy="128" r="84" fill="none" stroke="#2a2f39" stroke-width="14"/>
<path d="M128 44 a84 84 0 1 1 -59.4 24.6" fill="none" stroke="#38bdf8"
stroke-width="14" stroke-linecap="round"/>
<path d="M60 132 H100 L116 96 L140 168 L156 132 H196" fill="none" stroke="#e6e8eb"
stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

+51
View File
@@ -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
+24 -2
View File
@@ -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'<span style="color:{GOOD};">{tag} available</span>')
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")
+108
View File
@@ -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).")