Merge pull request 'feat(m9): graphical first-run setup wizard — 0.26.0' (#21) from feat/share-terminal into main
release / release (push) Successful in 13s

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-05-22 08:18:32 +00:00
12 changed files with 323 additions and 5 deletions
+9
View File
@@ -5,6 +5,15 @@ 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.26.0] - 2026-05-22
### Added
- **Graphical setup wizard (M9).** A first-run GUI wizard (`gui/setup_wizard.py`) walks through:
environment summary → pick **dependency bundles** (Diagnostics / Monitoring / Gaming / Updates,
from the component catalog) → install the missing apt packages → choose the **recording
trigger** → a readiness summary. It shows automatically on first launch (until done), is
re-runnable from **Settings → Run setup wizard** or `rigdoctor-gui --setup`, and `install.sh`
launches it after a fresh install when a desktop session is present.
## [0.25.0] - 2026-05-22
### Changed
- **Share is now terminal-only (D23, amends D16).** The Share page is a single shared-terminal
+4 -1
View File
@@ -65,7 +65,10 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
(no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger
modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI
Settings "Recording trigger") incl. the zero-config **game-launch watcher**
(`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install.
(`core/watcher.py`, `rigdoctor watch`); and a **graphical first-run setup wizard**
(`gui/setup_wizard.py`): environment → dependency-bundle selection → install → recording
trigger → readiness, auto-launched by install.sh and re-runnable from Settings.
*Pending:* `.deb` packaging (next bullet).
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later)
+8
View File
@@ -115,3 +115,11 @@ case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";;
esac
# Launch the graphical setup wizard if a desktop session is available (first run shows it).
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && [ -x "$VENV/bin/rigdoctor-gui" ]; then
echo " Opening the setup wizard…"
("$VENV/bin/rigdoctor-gui" --setup >/dev/null 2>&1 &)
else
echo " Run 'rigdoctor-gui' to finish setup."
fi
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.25.0"
version = "0.26.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.25.0"
__version__ = "0.26.0"
+1
View File
@@ -155,6 +155,7 @@ DEFAULTS: dict = {
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet
"trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
"setup_done": False, # first-run GUI setup wizard completed (M9)
}
+8
View File
@@ -65,3 +65,11 @@ COMPONENTS: tuple[Component, ...] = (
def by_id(component_id: str) -> Component | None:
"""Look up a catalog component by its id (None if unknown)."""
return next((c for c in COMPONENTS if c.id == component_id), None)
def by_bundle() -> dict[str, list[Component]]:
"""Components grouped by bundle, preserving catalog order (for the setup wizard)."""
groups: dict[str, list[Component]] = {}
for c in COMPONENTS:
groups.setdefault(c.bundle, []).append(c)
return groups
+7 -1
View File
@@ -28,7 +28,8 @@ def main(argv: list[str] | None = None) -> int:
app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET)
interval = float(load_config().get("interval", 1.0))
cfg = load_config()
interval = float(cfg.get("interval", 1.0))
window = MainWindow(interval=interval)
# `--tray` starts hidden to the system tray (for autostart); if no tray is available,
# fall back to showing the window so the app is never invisible-and-unreachable.
@@ -37,6 +38,11 @@ def main(argv: list[str] | None = None) -> int:
window.start_minimized_note()
else:
window.show()
# First run (or `--setup`): the graphical setup wizard (M9).
if "--setup" in args or not cfg.get("setup_done", False):
from .setup_wizard import SetupWizard
SetupWizard(window).exec()
return app.exec()
+10
View File
@@ -87,8 +87,11 @@ class SetupPage(QWidget):
self._install_btn.clicked.connect(self._install)
self._refresh_btn = QPushButton("Re-check")
self._refresh_btn.clicked.connect(self._refresh)
wizard_btn = QPushButton("Run setup wizard")
wizard_btn.clicked.connect(self._run_wizard)
controls.addWidget(self._install_btn)
controls.addWidget(self._refresh_btn)
controls.addWidget(wizard_btn)
controls.addStretch(1)
comp_layout.addLayout(controls)
root.addWidget(comp_card)
@@ -202,6 +205,13 @@ class SetupPage(QWidget):
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._refresh_update_status()
def _run_wizard(self) -> None:
from .setup_wizard import SetupWizard
SetupWizard(self).exec()
self._refresh()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
# --- recording trigger (M9) -----------------------------------------------
def _apply_trigger(self) -> None:
mode = self._trigger.currentText()
+259
View File
@@ -0,0 +1,259 @@
"""First-run GUI setup wizard (M9): the full graphical installer/setup.
Bootstrap (Python venv + PySide6) is done by install.sh/.run; this wizard handles the rest
graphically environment summary pick dependency bundles install the missing apt packages
choose the recording trigger readiness summary. Shown automatically on first launch (until
`setup_done`), re-runnable from Settings, and launched by install.sh after a fresh install.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from .. import config
from ..core import catalog, installer, service, sysenv
class SetupWizard(QDialog):
_installed = Signal(int, str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("RigDoctor Setup")
self.resize(620, 560)
self.setObjectName("Page")
self._installed.connect(self._on_installed)
self._bundle_checks: dict[str, QCheckBox] = {}
self._installing = False
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 16)
root.setSpacing(14)
self._stack = QStackedWidget()
self._stack.addWidget(self._page_welcome()) # 0
self._stack.addWidget(self._page_bundles()) # 1
self._stack.addWidget(self._page_install()) # 2
self._stack.addWidget(self._page_trigger()) # 3
self._stack.addWidget(self._page_finish()) # 4
root.addWidget(self._stack, 1)
nav = QHBoxLayout()
self._skip_btn = QPushButton("Skip")
self._skip_btn.clicked.connect(self._skip)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(lambda: self._go(-1))
self._next_btn = QPushButton("Next")
self._next_btn.setObjectName("PrimaryButton")
self._next_btn.clicked.connect(lambda: self._go(1))
nav.addWidget(self._skip_btn)
nav.addStretch(1)
nav.addWidget(self._back_btn)
nav.addWidget(self._next_btn)
root.addLayout(nav)
self._index = 0
self._update_nav()
# --- pages -----------------------------------------------------------------
def _page(self, title: str, subtitle: str = "") -> tuple[QWidget, QVBoxLayout]:
page = QWidget()
v = QVBoxLayout(page)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(10)
head = QLabel(title)
head.setObjectName("PageTitle")
v.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Muted")
sub.setWordWrap(True)
v.addWidget(sub)
return page, v
def _page_welcome(self) -> QWidget:
page, v = self._page(
"Welcome to RigDoctor",
"Let's set up monitoring and diagnostics for your machine. This takes a minute and "
"needs no root for the app itself — only installing optional tools may ask for your "
"password.",
)
env = QLabel(
f"Detected:\n"
f" • Distro: {sysenv.distro_name()}\n"
f" • Package manager: {sysenv.package_manager() or 'none (apt required for extras)'}\n"
f" • GPU: {', '.join(sysenv.gpu_vendors()) or 'unknown'}"
)
env.setObjectName("Muted")
v.addWidget(env)
v.addStretch(1)
return page
def _page_bundles(self) -> QWidget:
page, v = self._page(
"Choose what to set up",
"Pick the optional tool bundles to install. Core monitoring, crash capture, and the "
"health report work without any of these — they just add capability.",
)
present = {c.id: ok for c, ok in installer.component_status()}
for bundle, comps in catalog.by_bundle().items():
missing = [c for c in comps if not present.get(c.id)]
names = ", ".join(c.name for c in comps)
tag = " — all installed ✓" if not missing else f"{len(missing)} to install"
cb = QCheckBox(f"{bundle}: {names}{tag}")
cb.setChecked(bool(missing)) # default-check bundles with something to add
cb.setEnabled(bool(missing) and sysenv.package_manager() == "apt")
self._bundle_checks[bundle] = cb
v.addWidget(cb)
if sysenv.package_manager() != "apt":
note = QLabel("Only apt is supported for installing tools, so these are read-only here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_install(self) -> QWidget:
page, v = self._page("Install tools", "Installing the selected packages…")
self._install_status = QLabel("")
self._install_status.setObjectName("Muted")
self._install_status.setWordWrap(True)
v.addWidget(self._install_status)
self._install_output = QTextEdit()
self._install_output.setObjectName("Report")
self._install_output.setReadOnly(True)
v.addWidget(self._install_output, 1)
return page
def _page_trigger(self) -> QWidget:
page, v = self._page(
"Recording trigger",
"When the crash logger runs. You can change this any time in Settings.",
)
self._trigger_group = QButtonGroup(self)
labels = {
"manual": "Manual — start/stop recording yourself.",
"always-on": "Always-on — a background service records continuously.",
"game-launch": "Game-launch — auto-record while a Steam game runs.",
}
for i, (mode, text) in enumerate(labels.items()):
rb = QRadioButton(text)
rb.setProperty("mode", mode)
rb.setChecked(mode == config.load_config().get("trigger_mode", "manual"))
self._trigger_group.addButton(rb, i)
v.addWidget(rb)
if not service.available():
note = QLabel("systemd --user isn't available, so always-on / game-launch can't be enabled here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_finish(self) -> QWidget:
page, v = self._page("You're all set", "")
self._finish_summary = QLabel("")
self._finish_summary.setObjectName("Muted")
self._finish_summary.setWordWrap(True)
v.addWidget(self._finish_summary)
v.addStretch(1)
return page
# --- navigation ------------------------------------------------------------
def _go(self, delta: int) -> None:
if self._installing:
return
new = self._index + delta
if new < 0:
return
if new >= self._stack.count(): # past the last page → finish
self._finish()
return
self._index = new
self._stack.setCurrentIndex(new)
self._update_nav()
if new == 2: # entering the install page
self._run_install()
elif new == 4: # entering the finish page
self._fill_summary()
def _update_nav(self) -> None:
self._back_btn.setEnabled(self._index > 0 and not self._installing)
last = self._index == self._stack.count() - 1
self._next_btn.setText("Finish" if last else "Next")
self._skip_btn.setVisible(not last)
def _selected_components(self):
present = {c.id: ok for c, ok in installer.component_status()}
chosen = []
for bundle, comps in catalog.by_bundle().items():
if self._bundle_checks.get(bundle) and self._bundle_checks[bundle].isChecked():
chosen += [c for c in comps if not present.get(c.id)]
return chosen
def _run_install(self) -> None:
packages = installer.missing_packages(self._selected_components())
if not packages:
self._install_status.setText("Nothing to install — your selected tools are already present.")
self._install_output.setVisible(False)
return
self._installing = True
self._update_nav()
self._next_btn.setEnabled(False)
self._install_status.setText("Installing… you may be asked for your password.")
self._install_output.setVisible(True)
self._install_output.setPlainText(f"Installing: {' '.join(packages)}\n")
threading.Thread(target=lambda: self._installed.emit(*installer.install_packages(packages)), daemon=True).start()
def _on_installed(self, rc: int, out: str) -> None:
self._installing = False
self._install_output.setPlainText(out[-4000:])
self._install_status.setText("Done." if rc == 0 else "Some packages may not have installed — see the log.")
self._next_btn.setEnabled(True)
self._update_nav()
def _fill_summary(self) -> None:
from ..core.sources import available_sources
status = installer.component_status()
present = sum(1 for _c, ok in status if ok)
sources = len(available_sources())
mode = self._chosen_mode()
self._finish_summary.setText(
f"• Optional tools present: {present}/{len(status)}\n"
f"• Sensor sources detected: {sources}\n"
f"• Recording trigger: {mode}\n\n"
"You can re-run this wizard or change anything from Settings."
)
def _chosen_mode(self) -> str:
btn = self._trigger_group.checkedButton()
return btn.property("mode") if btn else "manual"
def _finish(self) -> None:
mode = self._chosen_mode()
if service.available():
service.apply_mode(mode)
else:
config.update_config(trigger_mode=mode)
config.update_config(setup_done=True)
self.accept()
def _skip(self) -> None:
config.update_config(setup_done=True)
self.reject()
+7
View File
@@ -64,6 +64,13 @@ class GuiSmokeTests(unittest.TestCase):
self.assertIn("14.2 / 31.0 GB", tray._mem_act.text())
self.assertEqual(tray._status_act.text(), "● Normal")
def test_setup_wizard_constructs(self):
from rigdoctor.gui.setup_wizard import SetupWizard
wizard = SetupWizard()
self.assertEqual(wizard._stack.count(), 5) # welcome/bundles/install/trigger/finish
self.assertTrue(wizard._bundle_checks)
if __name__ == "__main__":
unittest.main()
+8 -1
View File
@@ -2,7 +2,7 @@
import unittest
from rigdoctor.core import installer
from rigdoctor.core import catalog, installer
from rigdoctor.core.catalog import Component
from rigdoctor.core.updates import is_newer
@@ -31,6 +31,13 @@ class InstallerTests(unittest.TestCase):
rc, _ = installer.install_packages([])
self.assertEqual(rc, 0)
def test_by_bundle_groups_all_components(self):
groups = catalog.by_bundle()
flat = [c for comps in groups.values() for c in comps]
self.assertEqual(len(flat), len(catalog.COMPONENTS))
self.assertIn("Gaming", groups)
self.assertIn("Diagnostics", groups)
class UpdateTests(unittest.TestCase):
def test_is_newer(self):