refactor(gui): one-time launch elevation instead of "Run with admin"
release / release (push) Successful in 14s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:11:15 +02:00
parent 9ae2e22b44
commit 54c0971ac3
11 changed files with 119 additions and 68 deletions
+7
View File
@@ -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 (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). 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 ## [0.3.1] - 2026-05-21
### Fixed ### Fixed
- Changelog/release notes now **render Markdown** instead of showing raw `#`/`**` markup — - Changelog/release notes now **render Markdown** instead of showing raw `#`/`**` markup —
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.3.1" version = "0.3.2"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.3.1" __version__ = "0.3.2"
+15
View File
@@ -295,6 +295,18 @@ def cmd_uninstall(args) -> int:
return 0 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: def cmd_inventory(args) -> int:
from .core import inventory 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.add_argument("--json", action="store_true", help="output JSON instead of text")
rep.set_defaults(func=cmd_report) 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 = 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("--json", action="store_true", help="output JSON")
inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)") inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)")
+1
View File
@@ -138,6 +138,7 @@ DEFAULTS: dict = {
"log_max_bytes": 20_000_000, # rotate a log segment past this size "log_max_bytes": 20_000_000, # rotate a log segment past this size
"log_backups": 10, # keep this many rotated segments (bounds disk use) "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) "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)
} }
+51
View File
@@ -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
+12 -2
View File
@@ -234,12 +234,22 @@ def check_live_temps() -> list[Finding]:
def run_health_checks() -> 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: list[Finding] = []
findings += check_nvidia_driver() findings += check_nvidia_driver()
findings += check_journal() findings += check_journal()
findings += check_journal_persistence() 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 += check_live_temps()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings return findings
+4 -1
View File
@@ -171,7 +171,10 @@ def _dmidecode() -> dict:
def collect() -> list[Section]: 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()] return [_system(), _cpu(), _firmware(dmi), _memory(dmi), _gpu(), _storage(), _display()]
+2 -34
View File
@@ -2,11 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import threading import threading
import time import time
@@ -77,11 +72,6 @@ class HealthPage(QWidget):
self._status = QLabel("") self._status = QLabel("")
self._status.setObjectName("Muted") self._status.setObjectName("Muted")
header.addWidget(self._status) 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 = QPushButton("Run health report")
self._run_btn.setObjectName("PrimaryButton") self._run_btn.setObjectName("PrimaryButton")
self._run_btn.clicked.connect(self._run) self._run_btn.clicked.connect(self._run)
@@ -116,32 +106,10 @@ class HealthPage(QWidget):
findings = [] findings = []
self._result.emit(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: def _render_findings(self, findings) -> None:
self._run_btn.setEnabled(True) self._run_btn.setEnabled(True)
self._admin_btn.setEnabled(shutil.which("pkexec") is not None) if findings is None: # collection failed — keep current results
if findings is None: # elevated run cancelled/failed — keep current results self._status.setText("check failed")
self._status.setText("admin run cancelled")
return return
while self._list.count(): while self._list.count():
+3 -27
View File
@@ -2,11 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import shutil
import subprocess
import sys
import threading import threading
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
@@ -73,11 +69,6 @@ class InventoryPage(QWidget):
self._status = QLabel("") self._status = QLabel("")
self._status.setObjectName("Muted") self._status.setObjectName("Muted")
header.addWidget(self._status) 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 = QPushButton("Copy Markdown")
self._copy_btn.clicked.connect(self._copy) self._copy_btn.clicked.connect(self._copy)
header.addWidget(self._copy_btn) header.addWidget(self._copy_btn)
@@ -115,32 +106,17 @@ class InventoryPage(QWidget):
sections = [] sections = []
self._result.emit(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: def _busy(self, text: str) -> None:
self._status.setText(text) 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) b.setEnabled(False)
def _render(self, sections) -> None: def _render(self, sections) -> None:
self._refresh_btn.setEnabled(True) self._refresh_btn.setEnabled(True)
self._admin_btn.setEnabled(shutil.which("pkexec") is not None)
self._copy_btn.setEnabled(True) self._copy_btn.setEnabled(True)
self._save_btn.setEnabled(True) self._save_btn.setEnabled(True)
if sections is None: # admin run cancelled/failed — keep current if sections is None: # collection failed — keep current
self._status.setText("admin run cancelled") self._status.setText("collection failed")
return return
self._sections = sections self._sections = sections
+22 -2
View File
@@ -26,7 +26,7 @@ from PySide6.QtWidgets import (
from .. import __version__ from .. import __version__
from ..config import load_config from ..config import load_config
from ..core import updates from ..core import elevation, updates
from .dashboard import Dashboard from .dashboard import Dashboard
from .health_page import HealthPage from .health_page import HealthPage
from .inventory_page import InventoryPage from .inventory_page import InventoryPage
@@ -42,11 +42,13 @@ class MainWindow(QMainWindow):
_update_checked = Signal(object) # (state, tag, notes) _update_checked = Signal(object) # (state, tag, notes)
_update_applied = Signal(int) # pip exit code _update_applied = Signal(int) # pip exit code
_changelog_ready = Signal(object) # ([(tag, date, notes)], error) _changelog_ready = Signal(object) # ([(tag, date, notes)], error)
_elevated = Signal() # privileged data collected at launch
def __init__(self, interval: float = 1.0) -> None: def __init__(self, interval: float = 1.0) -> None:
super().__init__() super().__init__()
self.setWindowTitle("RigDoctor") self.setWindowTitle("RigDoctor")
self.resize(1000, 680) self.resize(1000, 680)
cfg = load_config()
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -79,6 +81,13 @@ class MainWindow(QMainWindow):
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
self._worker.start() 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 # Update check (M13): once at launch, then periodically so a newly published
# release is detected without restarting (interval from config; 0 disables). # release is detected without restarting (interval from config; 0 disables).
self._latest_tag = None self._latest_tag = None
@@ -88,7 +97,7 @@ class MainWindow(QMainWindow):
self._update_applied.connect(self._on_update_applied) self._update_applied.connect(self._on_update_applied)
self._changelog_ready.connect(self._on_changelog) self._changelog_ready.connect(self._on_changelog)
self._start_update_check() 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: if minutes > 0:
self._update_timer = QTimer(self) self._update_timer = QTimer(self)
self._update_timer.setInterval(int(minutes * 60_000)) self._update_timer.setInterval(int(minutes * 60_000))
@@ -196,6 +205,17 @@ class MainWindow(QMainWindow):
self._update_label.setText("update failed") self._update_label.setText("update failed")
self._update_btn.setEnabled(True) 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: def _manual_check(self) -> None:
if self._applied: if self._applied:
return return