587568e574
Reshape the IA so it reads by intent instead of a flat pile of pages. - Grouped sidebar: Monitor / Diagnose / System / App (section headers). - Renames: Health → System Health, Environment → Tuning, Logs → Recordings, Setup → Settings. - Settings absorbs Notifications (alerts) as a section; Notifications dropped as a separate page (notifications_page.py removed; SetupPage gains the alerts card + `changed` signal wired to the live alert monitor). - Recordings is now a hub: a source dropdown to view any captured log (always-on / last diagnostic / preserved crash) + Analyze-crash in place, plus the recorder controls; status line now shows the captured game. - main_window nav is data-driven (_NAV groups → _PAGES order → stack); show_page, badges, and tray flows updated. GUI smoke test asserts the new page set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
18 KiB
Python
452 lines
18 KiB
Python
"""RigDoctor main window — sidebar navigation over a stacked content area."""
|
|
|
|
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 QIcon, QTextDocument
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QButtonGroup,
|
|
QDialog,
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QStackedWidget,
|
|
QSystemTrayIcon,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from .. import __version__
|
|
from ..config import load_config
|
|
from ..core import alerts, elevation, updates
|
|
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 .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
|
|
|
|
# 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):
|
|
_update_checked = Signal(object) # (state, tag, notes)
|
|
_update_applied = Signal(int) # pip exit code
|
|
_changelog_ready = Signal(object) # ([(tag, date, notes)], error)
|
|
_elevated = Signal() # privileged data collected at launch
|
|
|
|
def __init__(self, interval: float = 1.0) -> None:
|
|
super().__init__()
|
|
self.setWindowTitle("RigDoctor")
|
|
self.resize(1000, 680)
|
|
cfg = load_config()
|
|
|
|
central = QWidget()
|
|
self.setCentralWidget(central)
|
|
layout = QHBoxLayout(central)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# Content stack
|
|
content = QWidget()
|
|
content.setObjectName("ContentArea")
|
|
content_layout = QVBoxLayout(content)
|
|
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
self._stack = QStackedWidget()
|
|
self.dashboard = Dashboard()
|
|
self.recorder_page = RecorderPage()
|
|
self.health_page = HealthPage()
|
|
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.setup_page.changed.connect(self._apply_alert_settings)
|
|
self.share_page = SharePage()
|
|
# Page name → widget; the stack is filled in _PAGES order so indices line up.
|
|
self._pages = {
|
|
"Dashboard": self.dashboard,
|
|
"Games": self.games_page,
|
|
"Recordings": self.recorder_page,
|
|
"System Health": self.health_page,
|
|
"Tuning": self.environment_page,
|
|
"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)
|
|
|
|
layout.addWidget(self._build_sidebar())
|
|
layout.addWidget(content, 1)
|
|
|
|
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 +
|
|
# dmidecode); Health/Inventory then always show the full picture (config:
|
|
# elevate_on_launch). Falls back silently to non-root if cancelled/unavailable.
|
|
if cfg.get("elevate_on_launch", True) and elevation.available():
|
|
self._elevated.connect(self._on_elevated)
|
|
threading.Thread(target=self._collect_privileged, daemon=True).start()
|
|
|
|
# Update check (M13): once at launch, then periodically so a newly published
|
|
# release is detected without restarting (interval from config; 0 disables).
|
|
self._latest_tag = None
|
|
self._latest_notes = ""
|
|
self._applied = False
|
|
self._update_checked.connect(self._show_update_state)
|
|
self._update_applied.connect(self._on_update_applied)
|
|
self._changelog_ready.connect(self._on_changelog)
|
|
self._start_update_check()
|
|
minutes = float(cfg.get("update_check_minutes", 30) or 0)
|
|
if minutes > 0:
|
|
self._update_timer = QTimer(self)
|
|
self._update_timer.setInterval(int(minutes * 60_000))
|
|
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")
|
|
bar.setFixedWidth(208)
|
|
v = QVBoxLayout(bar)
|
|
v.setContentsMargins(16, 18, 16, 16)
|
|
v.setSpacing(4)
|
|
|
|
title = QLabel("RigDoctor")
|
|
title.setObjectName("AppTitle")
|
|
subtitle = QLabel("Hardware monitor")
|
|
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)
|
|
group.setExclusive(True)
|
|
self._nav_buttons: dict[str, QPushButton] = {}
|
|
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.setObjectName("NavButton")
|
|
btn.setCheckable(True)
|
|
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
btn.setChecked(idx == 0)
|
|
btn.clicked.connect(lambda _checked, i=idx: self._stack.setCurrentIndex(i))
|
|
group.addButton(btn, idx)
|
|
v.addWidget(btn)
|
|
self._nav_buttons[name] = btn
|
|
|
|
v.addStretch(1)
|
|
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
|
|
v.addWidget(live)
|
|
version = QLabel(f"v{__version__}")
|
|
version.setObjectName("Muted")
|
|
v.addWidget(version)
|
|
changelog_btn = QPushButton("Changelog")
|
|
changelog_btn.setObjectName("LinkButton")
|
|
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
changelog_btn.clicked.connect(self._show_changelog)
|
|
v.addWidget(changelog_btn)
|
|
check_btn = QPushButton("Check for updates")
|
|
check_btn.setObjectName("LinkButton")
|
|
check_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
check_btn.clicked.connect(self._manual_check)
|
|
v.addWidget(check_btn)
|
|
|
|
# Update state (filled in by the background check).
|
|
self._update_label = QLabel("checking for updates…")
|
|
self._update_label.setObjectName("Muted")
|
|
v.addWidget(self._update_label)
|
|
self._update_btn = QPushButton()
|
|
self._update_btn.setObjectName("PrimaryButton")
|
|
self._update_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self._update_btn.clicked.connect(self._apply_update)
|
|
self._update_btn.setVisible(False)
|
|
v.addWidget(self._update_btn)
|
|
self._restart_btn = QPushButton("Restart now")
|
|
self._restart_btn.setObjectName("PrimaryButton")
|
|
self._restart_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self._restart_btn.clicked.connect(self._restart)
|
|
self._restart_btn.setVisible(False)
|
|
v.addWidget(self._restart_btn)
|
|
return bar
|
|
|
|
def _restart(self) -> None:
|
|
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
|
|
if os.path.exists(gui):
|
|
QProcess.startDetached(gui)
|
|
else: # dev / not installed next to python
|
|
QProcess.startDetached(sys.executable, sys.argv)
|
|
QApplication.instance().quit()
|
|
|
|
def _apply_update(self) -> None:
|
|
if not self._latest_tag:
|
|
return
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle(f"Update to {self._latest_tag}")
|
|
box.setText(f"Update RigDoctor to {self._latest_tag}?")
|
|
notes_doc = QTextDocument()
|
|
notes_doc.setMarkdown(self._latest_notes or "_(no release notes)_")
|
|
box.setInformativeText(notes_doc.toHtml()) # render Markdown as rich text (#1)
|
|
box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
|
|
box.button(QMessageBox.StandardButton.Ok).setText("Update")
|
|
if box.exec() != QMessageBox.StandardButton.Ok:
|
|
return
|
|
self._update_btn.setEnabled(False)
|
|
self._update_label.setText("updating…")
|
|
tag = self._latest_tag
|
|
threading.Thread(target=lambda: self._update_applied.emit(updates.apply_update(tag)[0]), daemon=True).start()
|
|
|
|
def _on_update_applied(self, rc: int) -> None:
|
|
if rc == 0:
|
|
self._applied = True
|
|
self._update_label.setText("update installed")
|
|
self._update_btn.setVisible(False)
|
|
self._restart_btn.setVisible(True)
|
|
if hasattr(self, "_update_timer"):
|
|
self._update_timer.stop()
|
|
else:
|
|
self._update_label.setText("update failed")
|
|
self._update_btn.setEnabled(True)
|
|
|
|
def _collect_privileged(self) -> None:
|
|
data = elevation.collect_via_pkexec()
|
|
if data is not None:
|
|
elevation.set_privileged(data)
|
|
self._elevated.emit()
|
|
|
|
def _on_elevated(self) -> None:
|
|
# 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(_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:
|
|
btn = self._nav_buttons.get("Games")
|
|
if btn is not None:
|
|
btn.setText(f"Games ● {count}" if count > 0 else "Games")
|
|
|
|
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
|
|
self._update_label.setText("checking for updates…")
|
|
self._start_update_check()
|
|
|
|
def _start_update_check(self) -> None:
|
|
threading.Thread(target=self._check_updates, daemon=True).start()
|
|
|
|
def _show_changelog(self) -> None:
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("RigDoctor — Changelog")
|
|
dialog.resize(560, 540)
|
|
layout = QVBoxLayout(dialog)
|
|
view = QTextEdit()
|
|
view.setObjectName("Report")
|
|
view.setReadOnly(True)
|
|
view.setPlainText("Loading…")
|
|
layout.addWidget(view)
|
|
self._changelog_view = view
|
|
dialog.show()
|
|
threading.Thread(target=self._fetch_changelog, daemon=True).start()
|
|
|
|
def _fetch_changelog(self) -> None:
|
|
self._changelog_ready.emit(updates.list_releases())
|
|
|
|
def _on_changelog(self, result) -> None:
|
|
view = getattr(self, "_changelog_view", None)
|
|
if view is None:
|
|
return
|
|
releases, error = result
|
|
if error == updates.NO_TOKEN:
|
|
view.setPlainText("Add an update token (Setup → Update access) to load the changelog.")
|
|
return
|
|
if error or not releases:
|
|
view.setPlainText("Couldn't load the changelog from the update server.")
|
|
return
|
|
blocks = []
|
|
for tag, date, notes in releases:
|
|
title = f"## {tag}" + (f" — {date}" if date else "")
|
|
blocks.append(f"{title}\n\n{notes or '_(no notes)_'}")
|
|
view.setMarkdown("\n\n".join(blocks)) # render Markdown instead of raw text (#1)
|
|
|
|
def _check_updates(self) -> None:
|
|
self._update_checked.emit(updates.update_state())
|
|
|
|
def _show_update_state(self, result) -> None:
|
|
if self._applied: # an update was applied this session; awaiting restart
|
|
return
|
|
state, tag, notes = result
|
|
self._latest_tag = tag
|
|
self._latest_notes = notes
|
|
self._update_btn.setVisible(False)
|
|
if state == updates.NO_TOKEN:
|
|
self._update_label.setText("connect to update server")
|
|
elif state == updates.AUTH:
|
|
self._update_label.setText("update access denied")
|
|
elif state == updates.NETWORK:
|
|
self._update_label.setText("update check unavailable")
|
|
elif state == updates.AVAILABLE:
|
|
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")
|
|
|
|
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)
|