From 54c0971ac335cb4307c7a474538b81aad8f024bb Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Thu, 21 May 2026 19:11:15 +0200 Subject: [PATCH] refactor(gui): one-time launch elevation instead of "Run with admin" Remove the per-page "Run with admin" buttons. At launch the GUI asks for the password once (pkexec) and collects root-only data (SMART + dmidecode board/ BIOS/RAM) via the internal `collect-priv` command, caching it for the session; Health and Inventory read that cache so they always show the full picture. - core/elevation.py: pkexec collect + session cache - cli: hidden `collect-priv` command (SMART + dmidecode -> JSON) - health/inventory: use the elevation cache when present, else non-root - main_window: collect at launch (config elevate_on_launch), then refresh Health/Inventory; falls back silently if cancelled/unavailable - config: elevate_on_launch (default true) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 ++++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 15 +++++++++ src/rigdoctor/config.py | 1 + src/rigdoctor/core/elevation.py | 51 +++++++++++++++++++++++++++++ src/rigdoctor/core/health.py | 14 ++++++-- src/rigdoctor/core/inventory.py | 5 ++- src/rigdoctor/gui/health_page.py | 36 ++------------------ src/rigdoctor/gui/inventory_page.py | 30 ++--------------- src/rigdoctor/gui/main_window.py | 24 ++++++++++++-- 11 files changed, 119 insertions(+), 68 deletions(-) create mode 100644 src/rigdoctor/core/elevation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8455224..cae62df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.3.2] - 2026-05-21 +### Changed +- Replaced the per-page "Run with admin" buttons with a **single password prompt at launch** + (`pkexec`): the GUI collects root-only data (SMART + dmidecode board/BIOS/RAM) once and + caches it for the session, so Health and Inventory always show the full picture. Falls back + to non-root if cancelled/unavailable; disable via `elevate_on_launch = false`. + ## [0.3.1] - 2026-05-21 ### Fixed - Changelog/release notes now **render Markdown** instead of showing raw `#`/`**` markup — diff --git a/pyproject.toml b/pyproject.toml index 9fd9fd0..bea4782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.3.1" +version = "0.3.2" 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 f848e74..438d528 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.1" +__version__ = "0.3.2" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index 7c7da10..15bd2e9 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -295,6 +295,18 @@ def cmd_uninstall(args) -> int: return 0 +def cmd_collect_priv(args) -> int: + """Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch.""" + from dataclasses import asdict + + from .core.health import check_smart + from .core.inventory import _dmidecode + + data = {"smart": [asdict(f) for f in check_smart()], "dmidecode": _dmidecode()} + print(json.dumps(data)) + return 0 + + def cmd_inventory(args) -> int: from .core import inventory @@ -390,6 +402,9 @@ def build_parser() -> argparse.ArgumentParser: rep.add_argument("--json", action="store_true", help="output JSON instead of text") rep.set_defaults(func=cmd_report) + cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec + cp.set_defaults(func=cmd_collect_priv) + inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details") inv.add_argument("--json", action="store_true", help="output JSON") inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)") diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index be17755..03bf26d 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -138,6 +138,7 @@ DEFAULTS: dict = { "log_max_bytes": 20_000_000, # rotate a log segment past this size "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) } diff --git a/src/rigdoctor/core/elevation.py b/src/rigdoctor/core/elevation.py new file mode 100644 index 0000000..c07619c --- /dev/null +++ b/src/rigdoctor/core/elevation.py @@ -0,0 +1,51 @@ +"""Session privilege elevation. + +At GUI launch the app asks for the password once (pkexec) and collects the data that +needs root — SMART health + dmidecode (board/BIOS/RAM) — caching it for the session so +Health and Inventory can always show the full picture without per-action prompts. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys + +_privileged: dict | None = None + + +def privileged() -> dict | None: + """Cached root-collected data ({"smart": [...], "dmidecode": {...}}), or None.""" + return _privileged + + +def set_privileged(data: dict | None) -> None: + global _privileged + _privileged = data + + +def available() -> bool: + return shutil.which("pkexec") is not None and os.geteuid() != 0 + + +def _cli() -> list[str]: + candidate = os.path.join(os.path.dirname(sys.executable), "rigdoctor") + return [candidate] if os.path.exists(candidate) else [sys.executable, "-m", "rigdoctor"] + + +def collect_via_pkexec(timeout: float = 120.0) -> dict | None: + """Run one elevated collection (single password prompt). None if unavailable/cancelled.""" + if not available(): + return None + try: + proc = subprocess.run( + ["pkexec", *_cli(), "collect-priv"], + capture_output=True, text=True, timeout=timeout, + ) + if proc.returncode == 0 and proc.stdout.strip(): + return json.loads(proc.stdout) + except (subprocess.SubprocessError, OSError, ValueError): + pass + return None diff --git a/src/rigdoctor/core/health.py b/src/rigdoctor/core/health.py index c2758fd..33a5451 100644 --- a/src/rigdoctor/core/health.py +++ b/src/rigdoctor/core/health.py @@ -234,12 +234,22 @@ def check_live_temps() -> list[Finding]: def run_health_checks() -> list[Finding]: - """Run all checks and return findings sorted by severity (worst first).""" + """Run all checks and return findings sorted by severity (worst first). + + SMART needs root; if the session collected it via launch elevation, use that + instead of re-running smartctl (which would just report "needs root"). + """ + from . import elevation + findings: list[Finding] = [] findings += check_nvidia_driver() findings += check_journal() findings += check_journal_persistence() - findings += check_smart() + priv = elevation.privileged() + if priv is not None and priv.get("smart") is not None: + findings += [Finding(**d) for d in priv["smart"]] + else: + findings += check_smart() findings += check_live_temps() findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) return findings diff --git a/src/rigdoctor/core/inventory.py b/src/rigdoctor/core/inventory.py index 5fb76d6..8810b30 100644 --- a/src/rigdoctor/core/inventory.py +++ b/src/rigdoctor/core/inventory.py @@ -171,7 +171,10 @@ def _dmidecode() -> dict: def collect() -> list[Section]: - dmi = _dmidecode() + from . import elevation + + priv = elevation.privileged() + dmi = priv["dmidecode"] if (priv and priv.get("dmidecode") is not None) else _dmidecode() return [_system(), _cpu(), _firmware(dmi), _memory(dmi), _gpu(), _storage(), _display()] diff --git a/src/rigdoctor/gui/health_page.py b/src/rigdoctor/gui/health_page.py index e6e51ea..633ffab 100644 --- a/src/rigdoctor/gui/health_page.py +++ b/src/rigdoctor/gui/health_page.py @@ -2,11 +2,6 @@ from __future__ import annotations -import json -import os -import shutil -import subprocess -import sys import threading import time @@ -77,11 +72,6 @@ class HealthPage(QWidget): self._status = QLabel("") self._status.setObjectName("Muted") header.addWidget(self._status) - self._admin_btn = QPushButton("Run with admin") - self._admin_btn.setToolTip("Run all checks with root (SMART needs it) — prompts for your password") - self._admin_btn.clicked.connect(self._run_admin) - self._admin_btn.setEnabled(shutil.which("pkexec") is not None) - header.addWidget(self._admin_btn) self._run_btn = QPushButton("Run health report") self._run_btn.setObjectName("PrimaryButton") self._run_btn.clicked.connect(self._run) @@ -116,32 +106,10 @@ class HealthPage(QWidget): findings = [] self._result.emit(findings) - def _run_admin(self) -> None: - self._run_btn.setEnabled(False) - self._admin_btn.setEnabled(False) - self._status.setText("Running all checks with admin (you'll be prompted)…") - threading.Thread(target=self._work_admin, daemon=True).start() - - def _work_admin(self) -> None: - from ..core.health import Finding - - cli = os.path.join(os.path.dirname(sys.executable), "rigdoctor") - if os.path.exists(cli): - cmd = ["pkexec", cli, "report", "--json"] - else: # dev / not on PATH next to python - cmd = ["pkexec", sys.executable, "-m", "rigdoctor", "report", "--json"] - try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180) - findings = [Finding(**d) for d in json.loads(proc.stdout)] if proc.returncode == 0 else None - except Exception: - findings = None # pkexec cancelled / failed / unparsable - self._result.emit(findings) - def _render_findings(self, findings) -> None: self._run_btn.setEnabled(True) - self._admin_btn.setEnabled(shutil.which("pkexec") is not None) - if findings is None: # elevated run cancelled/failed — keep current results - self._status.setText("admin run cancelled") + if findings is None: # collection failed — keep current results + self._status.setText("check failed") return while self._list.count(): diff --git a/src/rigdoctor/gui/inventory_page.py b/src/rigdoctor/gui/inventory_page.py index cfdfc89..6e38e51 100644 --- a/src/rigdoctor/gui/inventory_page.py +++ b/src/rigdoctor/gui/inventory_page.py @@ -2,11 +2,7 @@ from __future__ import annotations -import json import os -import shutil -import subprocess -import sys import threading from PySide6.QtCore import Qt, QTimer, Signal @@ -73,11 +69,6 @@ class InventoryPage(QWidget): self._status = QLabel("") self._status.setObjectName("Muted") header.addWidget(self._status) - self._admin_btn = QPushButton("Run with admin") - self._admin_btn.setToolTip("Re-collect with root for motherboard/BIOS/RAM details (dmidecode)") - self._admin_btn.setEnabled(shutil.which("pkexec") is not None) - self._admin_btn.clicked.connect(self._run_admin) - header.addWidget(self._admin_btn) self._copy_btn = QPushButton("Copy Markdown") self._copy_btn.clicked.connect(self._copy) header.addWidget(self._copy_btn) @@ -115,32 +106,17 @@ class InventoryPage(QWidget): sections = [] self._result.emit(sections) - def _run_admin(self) -> None: - self._busy("Collecting with admin (you'll be prompted)…") - threading.Thread(target=self._work_admin, daemon=True).start() - - def _work_admin(self) -> None: - cli = os.path.join(os.path.dirname(sys.executable), "rigdoctor") - cmd = [cli, "inventory", "--json"] if os.path.exists(cli) else [sys.executable, "-m", "rigdoctor", "inventory", "--json"] - try: - proc = subprocess.run(["pkexec", *cmd], capture_output=True, text=True, timeout=120) - sections = inventory.from_dict(json.loads(proc.stdout)) if proc.returncode == 0 else None - except Exception: - sections = None - self._result.emit(sections) - def _busy(self, text: str) -> None: self._status.setText(text) - for b in (self._refresh_btn, self._admin_btn, self._copy_btn, self._save_btn): + 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._admin_btn.setEnabled(shutil.which("pkexec") is not None) self._copy_btn.setEnabled(True) self._save_btn.setEnabled(True) - if sections is None: # admin run cancelled/failed — keep current - self._status.setText("admin run cancelled") + if sections is None: # collection failed — keep current + self._status.setText("collection failed") return self._sections = sections diff --git a/src/rigdoctor/gui/main_window.py b/src/rigdoctor/gui/main_window.py index 7c9d47f..2cd930b 100644 --- a/src/rigdoctor/gui/main_window.py +++ b/src/rigdoctor/gui/main_window.py @@ -26,7 +26,7 @@ from PySide6.QtWidgets import ( from .. import __version__ from ..config import load_config -from ..core import updates +from ..core import elevation, updates from .dashboard import Dashboard from .health_page import HealthPage from .inventory_page import InventoryPage @@ -42,11 +42,13 @@ 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) @@ -79,6 +81,13 @@ class MainWindow(QMainWindow): self._worker.sampled.connect(self.dashboard.update_sample) 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 @@ -88,7 +97,7 @@ class MainWindow(QMainWindow): self._update_applied.connect(self._on_update_applied) self._changelog_ready.connect(self._on_changelog) self._start_update_check() - minutes = float(load_config().get("update_check_minutes", 30) or 0) + 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)) @@ -196,6 +205,17 @@ class MainWindow(QMainWindow): 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 and Inventory now that root-only data (SMART/dmidecode) is available. + self.health_page._run() + self.inventory_page._run() + def _manual_check(self) -> None: if self._applied: return