From 4386838b691f9e3b69b8454ff0dc152a923d3c41 Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 10:13:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(m9):=20graphical=20first-run=20setup=20wiz?= =?UTF-8?q?ard=20=E2=80=94=200.26.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full installer experience as a GUI wizard (gui/setup_wizard.py): environment summary → pick dependency bundles (from the catalog, grouped) → install missing apt packages → choose recording trigger → readiness summary. - Shown on first launch (config setup_done) and via `rigdoctor-gui --setup`; re-runnable from Settings → Run setup wizard. - install.sh launches it after a fresh install when a desktop session is present. - catalog.by_bundle() groups components; config gains setup_done. - Tests: by_bundle grouping + wizard construction smoke. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ docs/ROADMAP.md | 5 +- install.sh | 8 + pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/config.py | 1 + src/rigdoctor/core/catalog.py | 8 + src/rigdoctor/gui/app.py | 8 +- src/rigdoctor/gui/setup_page.py | 10 ++ src/rigdoctor/gui/setup_wizard.py | 259 ++++++++++++++++++++++++++++++ tests/test_gui_smoke.py | 7 + tests/test_installer.py | 9 +- 12 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/rigdoctor/gui/setup_wizard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 186f1ad..88940cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 74a52b0..12b9891 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/install.sh b/install.sh index 83bf791..79bee6d 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml index bd7f1da..cad39e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index cc1470e..7b0b19e 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.25.0" +__version__ = "0.26.0" diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index c54486e..cac58ad 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -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) } diff --git a/src/rigdoctor/core/catalog.py b/src/rigdoctor/core/catalog.py index 423c337..6c1026c 100644 --- a/src/rigdoctor/core/catalog.py +++ b/src/rigdoctor/core/catalog.py @@ -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 diff --git a/src/rigdoctor/gui/app.py b/src/rigdoctor/gui/app.py index 5d0febe..be5e67d 100644 --- a/src/rigdoctor/gui/app.py +++ b/src/rigdoctor/gui/app.py @@ -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() diff --git a/src/rigdoctor/gui/setup_page.py b/src/rigdoctor/gui/setup_page.py index 43d7dec..61eeb72 100644 --- a/src/rigdoctor/gui/setup_page.py +++ b/src/rigdoctor/gui/setup_page.py @@ -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() diff --git a/src/rigdoctor/gui/setup_wizard.py b/src/rigdoctor/gui/setup_wizard.py new file mode 100644 index 0000000..2f347b3 --- /dev/null +++ b/src/rigdoctor/gui/setup_wizard.py @@ -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() diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py index 786c45a..976d884 100644 --- a/tests/test_gui_smoke.py +++ b/tests/test_gui_smoke.py @@ -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() diff --git a/tests/test_installer.py b/tests/test_installer.py index 15a9c50..3b28779 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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):