Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c0971ac3 |
@@ -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
@@ -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,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"
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -234,11 +234,21 @@ 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()
|
||||||
|
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_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))
|
||||||
|
|||||||
@@ -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,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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user