Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 323451428b | |||
|
479189ee4e
|
|||
| 51133e4042 | |||
|
bcf6ac2656
|
|||
| d59261f021 | |||
|
44923b771a
|
|||
| eaaf14c58a | |||
| 7779131cf9 | |||
|
87fa678ccb
|
|||
| c5e24b3984 | |||
|
21cc6a4813
|
|||
| ee73049248 | |||
|
3a8ad5bd5d
|
|||
| e8b84bf046 | |||
|
2342dd83aa
|
|||
| a028fe6d38 | |||
|
a6453335e9
|
|||
| baec47dd4e | |||
| 47ecb702e7 | |||
| 944945ce72 | |||
|
dc719f6a89
|
|||
|
78cd417d0b
|
@@ -43,6 +43,9 @@ jobs:
|
||||
- name: Build self-extracting installer (.run)
|
||||
run: python packaging/make_run.py
|
||||
|
||||
- name: Build .deb
|
||||
run: python packaging/make_deb.py
|
||||
|
||||
- name: Read version
|
||||
id: ver
|
||||
run: |
|
||||
@@ -103,3 +106,26 @@ jobs:
|
||||
"${API}/releases/${rid}/assets?name=$(basename "$f")" >/dev/null
|
||||
done
|
||||
echo "Published ${TAG}."
|
||||
|
||||
- name: Publish .deb to the Gitea apt registry (optional — needs REGISTRY_TOKEN)
|
||||
env:
|
||||
PKG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${PKG_TOKEN:-}" ]; then
|
||||
echo "REGISTRY_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
|
||||
exit 0
|
||||
fi
|
||||
OWNER="${{ github.repository_owner }}"
|
||||
URL="${{ github.server_url }}/api/packages/${OWNER}/debian/pool/stable/main/upload"
|
||||
for f in dist/*.deb; do
|
||||
echo "Uploading $(basename "$f") to the apt registry…"
|
||||
code=$(curl -sS -o /tmp/apt_upload.txt -w '%{http_code}' \
|
||||
--user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL" || true)
|
||||
case "$code" in
|
||||
2*) echo " uploaded ($code)";;
|
||||
409) echo " already published ($code) — skipping (registry versions are immutable)";;
|
||||
*) echo " upload failed ($code):"; cat /tmp/apt_upload.txt || true; exit 1;;
|
||||
esac
|
||||
done
|
||||
echo "apt source: deb ${{ github.server_url }}/api/packages/${OWNER}/debian stable main"
|
||||
|
||||
@@ -5,6 +5,53 @@ 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.37.1] - 2026-05-22
|
||||
### Fixed
|
||||
- **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects
|
||||
apt (`.deb`), pip (venv/`.run`), or source installs (`updates.install_kind()`); only pip
|
||||
installs self-update in place. An apt install no longer fails with "No module named pip" —
|
||||
it (and the GUI Update button) shows `sudo apt update && sudo apt install --only-upgrade
|
||||
rigdoctor`; a source checkout points to `git pull`.
|
||||
|
||||
## [0.37.0] - 2026-05-22
|
||||
### Added
|
||||
- **Version footer** — a footer across the bottom of the window shows `RigDoctor v<version>` in
|
||||
the bottom-right (moved out of the sidebar).
|
||||
### Fixed
|
||||
- **Pages scroll when content doesn't fit, and the window is no longer pinned to the tallest
|
||||
page's height.** Long pages (Settings, Tuning, …) get a scrollbar when too tall — so controls
|
||||
like Uninstall are always reachable — and the window can now be resized smaller than the screen
|
||||
(min height dropped from "taller than the screen" to ~600px). Pages that manage their own
|
||||
scroll/fill (Dashboard, System Health, Inventory, Share) are unchanged.
|
||||
|
||||
## [0.36.1] - 2026-05-22
|
||||
### Fixed
|
||||
- `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent
|
||||
`python3-pyside6` package. Now it names the real split modules
|
||||
(`python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`).
|
||||
|
||||
## [0.36.0] - 2026-05-22
|
||||
### Fixed
|
||||
- **`.deb` now installs all dependencies automatically — no manual tool install.** The previous
|
||||
`Recommends: python3-pyside6` named a package that doesn't exist on Debian/Ubuntu (PySide6 is
|
||||
split per module), so apt silently skipped it and the GUI wouldn't start. Now it Recommends the
|
||||
actual modules the GUI imports — `python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`.
|
||||
### Changed
|
||||
- **`apt install rigdoctor` sets up the whole toolset.** The `.deb` also Recommends the optional
|
||||
diagnostic/gaming tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin,
|
||||
libsecret-tools, gamemode, mangohud) so they install by default — users never hand-install
|
||||
tools. `cpupower` is a Suggests (kernel-tied); `--no-install-recommends` still gives CLI-only.
|
||||
|
||||
## [0.35.0] - 2026-05-22
|
||||
### Added
|
||||
- **`.deb` package (M9 / D8)** — `packaging/make_deb.py` builds a `rigdoctor_<version>_all.deb`
|
||||
(pure-Python, `Architecture: all`) via `dpkg-deb`: `Depends: python3`, with the GUI deps
|
||||
(`python3-pyside6`, `python3-pyte`) as **Recommends** so `sudo apt install ./rigdoctor_*.deb`
|
||||
gives the full app and `--no-install-recommends` gives CLI-only. Installs the package, both
|
||||
launchers, the desktop entry, and the icon. CI (`release.yml`) builds it as a **release asset**
|
||||
every release, and optionally publishes it to the Gitea **apt registry** (set a `REGISTRY_TOKEN`
|
||||
secret) for `sudo apt install rigdoctor`. **M9 is now complete.**
|
||||
|
||||
## [0.34.0] - 2026-05-22
|
||||
### Added
|
||||
- **Event-based alerts (M8).** Beyond temperature + GPU-lost, RigDoctor now notifies on
|
||||
|
||||
@@ -1,132 +1,136 @@
|
||||
# RigDoctor
|
||||
|
||||
A **modular diagnostics, monitoring, and health-check toolkit for Linux gamers.**
|
||||
**Hardware monitoring & crash diagnostics for Linux gamers.** Live sensors, crash-safe
|
||||
logging, plain-language health reports, per-game diagnostics, and optional AI explanations —
|
||||
in a desktop app, a tray applet, or the terminal. Ubuntu/Debian + NVIDIA first.
|
||||
|
||||
> **Status:** 🟢 Phase 1 (MVP) complete. The **sensor core (M1)**, **crash-capture logger
|
||||
> (M3)**, and **health report (M4)** all work — live `snapshot`/`monitor`, crash-safe `record`
|
||||
> with a post-crash report, and `report` to scan logs/SMART/driver for likely causes. A
|
||||
> desktop GUI (M10) ties them together (dashboard, recording, health). See `docs/ROADMAP.md`.
|
||||
Linux gaming faults are hard to pin down — GPUs falling off the PCIe bus, black screens
|
||||
mid-game, silent thermal/VRAM throttling, driver/Proton mismatches. The useful data is
|
||||
scattered across `nvidia-smi`, `/sys`, `journalctl`, and SMART, and the readings right before a
|
||||
freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
|
||||
|
||||
## Why this exists
|
||||
## Features
|
||||
|
||||
Linux gaming hardware faults are hard to diagnose: GPUs falling off the PCIe bus, the screen
|
||||
suddenly going black mid-game, silent thermal/VRAM throttling, power transients,
|
||||
driver/library mismatches, Proton quirks, and CPU governor / power-profile misconfiguration.
|
||||
The data needed to diagnose them is scattered across `nvidia-smi`, `/sys/class/hwmon`,
|
||||
`journalctl`, SMART, and more — and the most useful readings (the ones right before a hard
|
||||
freeze) are usually lost because nothing flushed them to disk.
|
||||
- **Live monitoring** — a dark desktop **dashboard** (history graphs + per-subsystem cards), a
|
||||
**tray applet** with at-a-glance status, and a terminal view (`rigdoctor monitor`).
|
||||
- **Crash-safe recording** — background logger that `fsync`s every sample, so the state right
|
||||
before a hard freeze survives. Manual, always-on, or auto-start when a game launches.
|
||||
- **Health report** — scans `journalctl`/SMART/driver for likely causes (Xid, OOM, disk
|
||||
errors, throttling…) and explains them with suggested fixes.
|
||||
- **Per-game diagnostics** — pick a game, capture while you play, get a focused report; hard
|
||||
crashes are detected and analysed on next launch.
|
||||
- **Gaming tune-ups** — flags risky settings (CPU governor, PCIe ASPM, persistence mode…) with
|
||||
**one-click, reversible fixes**.
|
||||
- **Proactive alerts** — desktop notifications on overheating and critical kernel events
|
||||
(GPU-lost, Xid, out-of-memory, disk I/O).
|
||||
- **AI explanations** *(optional, opt-in)* — explain a diagnostic in plain language with a
|
||||
**local model (Ollama)** or **Claude**. Never automatic; only when you press the button.
|
||||
- **Shareable reports** — zip a diagnostic (logs, inventory, AI transcript) to hand to someone,
|
||||
or share a live **terminal session** for remote help.
|
||||
- **Self-updating** — `apt upgrade`, or the in-app updater.
|
||||
|
||||
RigDoctor pulls all of that into one modular tool: live monitoring, crash-safe logging, a
|
||||
one-shot health report, and an interactive installer that only sets up the modules a given
|
||||
user actually needs for their hardware.
|
||||
## Install
|
||||
|
||||
**Seed use cases:** an RTX 3070 that intermittently "falls off the bus" under heavy GPU load
|
||||
(Path of Exile on Linux, Escape from Tarkov on Windows), and a monitor going black mid-game.
|
||||
See `docs/SPEC.md` §1.
|
||||
### Debian / Ubuntu — `.deb`
|
||||
|
||||
## How you run it
|
||||
|
||||
RigDoctor is **GUI-first** — the desktop app is the primary way in — but every feature is
|
||||
also available headless:
|
||||
- **Desktop GUI** — graphical dashboard, recording controls, log browser, reports. The
|
||||
default interface for most users.
|
||||
- **Tray applet** — a small top-menu-bar applet with quick actions and at-a-glance status.
|
||||
- **CLI** — full functionality from the terminal; works over SSH and in scripts.
|
||||
|
||||
The GUI/tray are optional modules; a headless (CLI-only) install loses no capability.
|
||||
|
||||
## Key decisions (settled)
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Name | **RigDoctor** |
|
||||
| Language / stack | **Python 3 + Qt (PySide6)** — core/CLI/daemon stdlib-only; Qt only for GUI/tray |
|
||||
| Primary distro | **Ubuntu** (Debian via apt); others best-effort later |
|
||||
| Primary GPU | **NVIDIA** first; AMD, then Intel later |
|
||||
| MVP | **Sensor core + crash logger + health report** (NVIDIA-only, CLI-first) |
|
||||
| Distribution | **User-local install** (self-updating from the public repo, no root); **`.deb`** optional |
|
||||
| Scope of action | **Read-only + suggestions** (no auto-apply yet) |
|
||||
| Stress tests | **Out of scope** |
|
||||
|
||||
Full rationale and the still-open questions are in `docs/DECISIONS.md`.
|
||||
|
||||
## Repo layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/SPEC.md` | Product specification — vision, requirements, modules (the main planning doc) |
|
||||
| `docs/ARCHITECTURE.md` | Technical design — core engine, front-ends, daemon, installer |
|
||||
| `docs/MODULES.md` | Catalog of modules with scope, dependencies, status |
|
||||
| `docs/ROADMAP.md` | Phased milestones |
|
||||
| `docs/DECISIONS.md` | Decision log + remaining open questions |
|
||||
| `src/rigdoctor/` | Source code — `core/` engine + sources, `cli.py`, `render.py` |
|
||||
| `installer/` | Installer / `.deb` packaging (empty until Phase 4) |
|
||||
| `tests/` | Tests (stdlib `unittest`) |
|
||||
|
||||
## Install (user-local, no root)
|
||||
|
||||
RigDoctor installs into a private venv under `~/.local` — no root, self-updating:
|
||||
The simplest path: grab the latest **`rigdoctor_<version>_all.deb`** from the
|
||||
[releases page](https://git.jesseyvanofferen.com/jessey/rigdoctor/releases) and install it —
|
||||
apt pulls the GUI dependencies (PySide6, pyte) automatically:
|
||||
|
||||
```bash
|
||||
./install.sh # from a source checkout or the self-extracting .run
|
||||
./install.sh --ref v0.0.6 # install a specific released tag (needs a token)
|
||||
./install.sh --uninstall # remove it
|
||||
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
|
||||
```
|
||||
|
||||
This adds `rigdoctor` / `rigdoctor-gui` to `~/.local/bin` and a desktop entry. Each release
|
||||
also ships a one-file **`.run`** installer (download, `chmod +x`, run). Updates are gated to
|
||||
accounts on the Git server (a Personal Access Token); save one via the GUI **Setup → Update
|
||||
access** panel or `rigdoctor login`, then `rigdoctor update` (or the sidebar button).
|
||||
|
||||
## Run it (dev)
|
||||
|
||||
Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14):
|
||||
**Or add the apt repository** for `apt install` + automatic updates. The registry is public and
|
||||
GPG-signed — no token needed; just add the signing key and a deb822 source:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m rigdoctor snapshot # one-shot sensor read
|
||||
PYTHONPATH=src python3 -m rigdoctor snapshot --json
|
||||
PYTHONPATH=src python3 -m rigdoctor monitor -n 1 # live view (Ctrl-C to quit)
|
||||
PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources
|
||||
PYTHONPATH=src python3 -m unittest discover -s tests
|
||||
# signing key → dearmored into the keyring
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-jessey.gpg
|
||||
|
||||
# the source (modern deb822 format, GPG-verified, all-arch)
|
||||
sudo tee /etc/apt/sources.list.d/rigdoctor.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: https://git.jesseyvanofferen.com/api/packages/jessey/debian
|
||||
Suites: stable
|
||||
Components: main
|
||||
Architectures: all
|
||||
Signed-By: /etc/apt/keyrings/gitea-jessey.gpg
|
||||
EOF
|
||||
|
||||
sudo apt update && sudo apt install rigdoctor
|
||||
```
|
||||
|
||||
### Crash-capture logger (M3)
|
||||
Then `sudo apt upgrade` keeps it current.
|
||||
|
||||
A crash-safe background logger (JSONL, `fsync` per sample, bounded by rotation) for catching
|
||||
the state right before a freeze:
|
||||
### Any distro — self-extracting `.run` (no root)
|
||||
|
||||
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
|
||||
into a private virtualenv under `~/.local` (no root), adds the launchers + desktop entry, and
|
||||
opens the first-run setup wizard:
|
||||
|
||||
```bash
|
||||
rigdoctor record start # start logging in the background
|
||||
rigdoctor record status # is it running? latest readings, sample count
|
||||
rigdoctor record stop # stop it
|
||||
rigdoctor record report # post-crash summary: peaks, events, last samples
|
||||
rigdoctor record run # run in the foreground (the systemd-ready entrypoint)
|
||||
sh rigdoctor-*-installer.run
|
||||
```
|
||||
|
||||
Logs live in `~/.local/share/rigdoctor/logs/`. It detects GPU "lost"/hang (nvidia-smi query
|
||||
timeout) and writes an event marker. Trigger modes (always-on / game-launch) and the
|
||||
`systemd --user` service arrive in Phase 4.
|
||||
### Updating & removing
|
||||
|
||||
### Desktop GUI (M10)
|
||||
- **`.deb`:** `sudo apt upgrade` (or reinstall a newer `.deb`).
|
||||
- **`.run` / user-local:** the in-app **Update** button, or `rigdoctor update`.
|
||||
- **Remove:** `sudo apt remove rigdoctor`, or `rigdoctor uninstall` for the user-local install.
|
||||
|
||||
The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep:
|
||||
## Using it
|
||||
|
||||
Launch **RigDoctor** from your app menu, or:
|
||||
|
||||
```bash
|
||||
pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui`
|
||||
rigdoctor gui # or: rigdoctor-gui
|
||||
rigdoctor-gui # desktop app (+ tray)
|
||||
rigdoctor --help # everything from the terminal (works over SSH)
|
||||
```
|
||||
|
||||
It opens a dark-themed window with sidebar navigation and a **live dashboard** over the
|
||||
same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
|
||||
cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
|
||||
The **Logs** and **Health** sections are full pages (recording controls + post-crash report;
|
||||
and the kernel-log / SMART / driver scan). **Inventory** is a placeholder until M5 lands.
|
||||
Handy CLI commands:
|
||||
|
||||
Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI.
|
||||
```bash
|
||||
rigdoctor snapshot # one-shot reading of every sensor
|
||||
rigdoctor monitor # live terminal dashboard
|
||||
rigdoctor report # health report (logs / SMART / driver)
|
||||
rigdoctor diagnose start|finish # capture while gaming, then analyse
|
||||
rigdoctor gameenv # flag risky gaming settings + fixes
|
||||
rigdoctor inventory # hardware/OS inventory
|
||||
rigdoctor ai explain # AI explanation of the current findings (opt-in)
|
||||
rigdoctor bundle # zip the latest diagnostic into a shareable report
|
||||
```
|
||||
|
||||
## Start here
|
||||
## Requirements
|
||||
|
||||
1. Read `docs/SPEC.md` for what we're building.
|
||||
2. Read `docs/ROADMAP.md` for the build order (Phase 1 = the MVP).
|
||||
3. Read `docs/DECISIONS.md` for the settled decisions (D1–D15).
|
||||
</content>
|
||||
- **Linux** — Ubuntu/Debian first-class (the `.deb`); the `.run` works on any distro with
|
||||
Python ≥ 3.11.
|
||||
- **GPU** — NVIDIA fully supported (via `nvidia-smi`); AMD/Intel sensors are best-effort.
|
||||
- **CLI/daemon** need only Python 3 (stdlib). The **GUI/tray** add **PySide6** (`python3-pyside6`).
|
||||
- Optional tools unlock more: `smartmontools`, `lm-sensors`, `gamemode`, `mangohud`. The setup
|
||||
wizard offers to install them.
|
||||
|
||||
## Privacy
|
||||
|
||||
Everything stays on your machine — no telemetry, no phone-home. The AI assistant is **off by
|
||||
default** and runs only when you explicitly trigger it; with Ollama nothing leaves the machine,
|
||||
and the Claude option asks before sending. Reports are local files; they leave only if you share
|
||||
the zip.
|
||||
|
||||
## Development
|
||||
|
||||
RigDoctor's core is stdlib-only Python; the GUI/tray use PySide6.
|
||||
|
||||
```bash
|
||||
git clone https://git.jesseyvanofferen.com/jessey/rigdoctor && cd rigdoctor
|
||||
pip install -e ".[gui]" # core + GUI; omit [gui] for CLI-only
|
||||
python -m unittest discover -s tests # run the test suite
|
||||
PYTHONPATH=src python3 -m rigdoctor snapshot # run without installing
|
||||
```
|
||||
|
||||
Design docs live in `docs/` — `SPEC.md` (vision/requirements), `ARCHITECTURE.md`,
|
||||
`MODULES.md` (module catalog), `ROADMAP.md`, and `DECISIONS.md` (the decision log).
|
||||
Contributions: branch off `main`, keep tests green (CI runs them on PRs), and bump the version
|
||||
+ `CHANGELOG.md` for shipped changes.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="78%">
|
||||
<stop offset="0%" stop-color="#1b2230"/>
|
||||
<stop offset="100%" stop-color="#0d0f13"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<!-- gauge ring -->
|
||||
<circle cx="256" cy="256" r="168" fill="none" stroke="#2a2f39" stroke-width="28"/>
|
||||
<!-- accent sweep -->
|
||||
<path d="M256 88 a168 168 0 1 1 -118.8 49.2" fill="none" stroke="#38bdf8"
|
||||
stroke-width="28" stroke-linecap="round"/>
|
||||
<!-- heartbeat / monitoring trace -->
|
||||
<path d="M120 264 H200 L232 192 L280 336 L312 264 H392" fill="none" stroke="#e6e8eb"
|
||||
stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 798 B |
+1
-1
@@ -18,7 +18,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
| M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 |
|
||||
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
|
||||
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ |
|
||||
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
|
||||
| M9 | Installer (+ `.deb`) | (meta) | none | all | P1 | ✅ |
|
||||
| M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | ✅ |
|
||||
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ |
|
||||
| M14 | AI assistant (explain diagnostics) | (optional) | none (stdlib urllib; Ollama or Claude) | all | P3 | ✅ |
|
||||
|
||||
+6
-3
@@ -67,9 +67,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
||||
Settings "Recording trigger") incl. the zero-config **game-launch watcher**
|
||||
(`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
|
||||
trigger → readiness, auto-launched by install.sh and re-runnable from Settings; and a
|
||||
**`.deb`** (`packaging/make_deb.py`, `Architecture: all`, `Depends: python3`,
|
||||
`Recommends: python3-pyside6/pyte`) built + published in CI (release asset + optional
|
||||
Gitea apt registry). **M9 complete.**
|
||||
- [x] `.deb` packaging (D8) — built via `dpkg-deb` (no debhelper); GUI deps as Recommends so
|
||||
`apt install rigdoctor` includes the Desktop UI, `--no-install-recommends` = CLI only.
|
||||
|
||||
## Phase 5 — Breadth (later)
|
||||
- [ ] AMD GPU support in M1 (Steam Deck / Radeon)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Build a `.deb` for RigDoctor (M9 / D8) — dependency-light, no debhelper.
|
||||
|
||||
Pure-Python app, so it's `Architecture: all`: we stage the package into dist-packages, drop the
|
||||
two launchers in /usr/bin, install the desktop entry + icon, write a DEBIAN/control, and call
|
||||
`dpkg-deb`. The core is stdlib (`Depends: python3`); everything else is **Recommends** so a
|
||||
plain `apt install rigdoctor` sets up the whole toolset automatically (users never hand-install
|
||||
deps) — the GUI modules (Debian/Ubuntu split PySide6 per module, so we name
|
||||
`python3-pyside6.qt{widgets,gui,websockets,svg}`) + `python3-pyte`, plus the diagnostic/gaming
|
||||
tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
|
||||
mangohud). `--no-install-recommends` still yields a CLI-only install; `cpupower` is a Suggests
|
||||
(kernel-tied/heavy).
|
||||
|
||||
Run: `python packaging/make_deb.py` → `dist/rigdoctor_<version>_all.deb`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
MAINTAINER = "Jessey van Offeren <jjvanofferen@gmail.com>"
|
||||
HOMEPAGE = "https://git.jesseyvanofferen.com/jessey/rigdoctor"
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
text = (ROOT / "src" / "rigdoctor" / "__init__.py").read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if line.startswith("__version__"):
|
||||
return line.split('"')[1]
|
||||
raise SystemExit("could not read __version__")
|
||||
|
||||
|
||||
_LAUNCHER = """\
|
||||
#!/usr/bin/python3
|
||||
import sys
|
||||
from {module} import main
|
||||
sys.exit(main())
|
||||
"""
|
||||
|
||||
_DESKTOP = """\
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=RigDoctor
|
||||
Comment=Hardware monitoring & crash diagnostics for Linux gamers
|
||||
Exec=rigdoctor-gui
|
||||
Icon=rigdoctor
|
||||
Terminal=false
|
||||
Categories=System;Monitor;Utility;
|
||||
StartupWMClass=rigdoctor
|
||||
"""
|
||||
|
||||
_CONTROL = """\
|
||||
Package: rigdoctor
|
||||
Version: {version}
|
||||
Architecture: all
|
||||
Maintainer: {maintainer}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Depends: python3 (>= 3.11)
|
||||
Recommends: python3-pyside6.qtwidgets, python3-pyside6.qtgui, python3-pyside6.qtwebsockets, python3-pyside6.qtsvg, python3-pyte, smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode, mangohud
|
||||
Suggests: linux-tools-generic
|
||||
Homepage: {homepage}
|
||||
Description: Hardware monitoring & crash diagnostics for Linux gamers
|
||||
RigDoctor monitors GPU/CPU temperatures, load, and sensors, captures crash
|
||||
diagnostics while gaming, scans logs (Xid/SMART/kernel) for problems, and can
|
||||
explain them in plain language. The CLI and background daemon are pure Python
|
||||
(stdlib only); the optional desktop GUI and system-tray applet use PySide6,
|
||||
pulled in via Recommends. Install with --no-install-recommends for CLI only.
|
||||
"""
|
||||
|
||||
|
||||
def _write(path: Path, text: str, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
version = _version()
|
||||
DIST.mkdir(exist_ok=True)
|
||||
stage = DIST / f"rigdoctor_{version}_all"
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
|
||||
# Python package → dist-packages (importable system-wide), minus bytecode.
|
||||
pkg_dst = stage / "usr/lib/python3/dist-packages/rigdoctor"
|
||||
shutil.copytree(ROOT / "src" / "rigdoctor", pkg_dst,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
|
||||
# Launchers.
|
||||
_write(stage / "usr/bin/rigdoctor", _LAUNCHER.format(module="rigdoctor.cli"), 0o755)
|
||||
_write(stage / "usr/bin/rigdoctor-gui", _LAUNCHER.format(module="rigdoctor.gui.app"), 0o755)
|
||||
|
||||
# Desktop entry + icon.
|
||||
_write(stage / "usr/share/applications/rigdoctor.desktop", _DESKTOP)
|
||||
icon = ROOT / "src" / "rigdoctor" / "gui" / "assets" / "rigdoctor.svg"
|
||||
_write(stage / "usr/share/icons/hicolor/scalable/apps/rigdoctor.svg",
|
||||
icon.read_text(encoding="utf-8"))
|
||||
|
||||
# Refresh the desktop database on install/remove (best-effort).
|
||||
_write(stage / "DEBIAN/postinst",
|
||||
"#!/bin/sh\nset -e\nupdate-desktop-database -q 2>/dev/null || true\n", 0o755)
|
||||
_write(stage / "DEBIAN/postrm",
|
||||
"#!/bin/sh\nset -e\nupdate-desktop-database -q 2>/dev/null || true\n", 0o755)
|
||||
_write(stage / "DEBIAN/control",
|
||||
_CONTROL.format(version=version, maintainer=MAINTAINER, homepage=HOMEPAGE))
|
||||
|
||||
out = DIST / f"rigdoctor_{version}_all.deb"
|
||||
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", str(stage), str(out)], check=True)
|
||||
shutil.rmtree(stage)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = build()
|
||||
print(f"built {path}")
|
||||
sys.exit(0)
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.34.0"
|
||||
version = "0.37.1"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.34.0"
|
||||
__version__ = "0.37.1"
|
||||
|
||||
@@ -55,8 +55,9 @@ def cmd_gui(args) -> int:
|
||||
from .gui.app import main as gui_main
|
||||
except ImportError as exc:
|
||||
print("The GUI needs PySide6, which isn't installed.")
|
||||
print(" Install it with: pip install 'rigdoctor[gui]'")
|
||||
print(" or on Ubuntu: sudo apt install python3-pyside6")
|
||||
print(" Ubuntu/Debian: sudo apt install python3-pyside6.qtwidgets "
|
||||
"python3-pyside6.qtgui python3-pyside6.qtwebsockets python3-pyside6.qtsvg python3-pyte")
|
||||
print(" pip: pip install 'rigdoctor[gui]'")
|
||||
print(f" ({exc})")
|
||||
return 2
|
||||
return gui_main([sys.argv[0]])
|
||||
@@ -262,6 +263,10 @@ def cmd_update(args) -> int:
|
||||
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
|
||||
if args.check:
|
||||
return 0
|
||||
kind = updates.install_kind()
|
||||
if kind != "pip": # apt/source installs aren't pip-updatable — show the right command
|
||||
print(updates.update_hint(kind))
|
||||
return 0
|
||||
print(f"Installing {tag}…")
|
||||
rc, out = updates.apply_update(tag)
|
||||
print(out[-2000:])
|
||||
|
||||
@@ -8,11 +8,14 @@ state for the UI; `apply_update` performs the no-root self-update.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .. import __version__
|
||||
from ..config import load_token
|
||||
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
|
||||
AVAILABLE = "available"
|
||||
|
||||
|
||||
APT_PACKAGE = "rigdoctor"
|
||||
|
||||
|
||||
def _dpkg_owns(path: Path) -> bool:
|
||||
"""True if dpkg reports `path` belongs to a package (i.e. an apt/.deb install)."""
|
||||
if not shutil.which("dpkg"):
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(["dpkg", "-S", str(path)], capture_output=True, text=True, timeout=5)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return r.returncode == 0 and APT_PACKAGE in r.stdout
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def install_kind() -> str:
|
||||
"""How RigDoctor was installed: 'apt' (.deb), 'pip' (venv/.run), or 'dev' (source checkout).
|
||||
|
||||
Decides which updater to use: only 'pip' can self-update in place; apt is root/dpkg-managed
|
||||
and source is VCS-managed, so those are guided rather than auto-applied.
|
||||
"""
|
||||
pkg = Path(__file__).resolve().parents[1] # .../rigdoctor
|
||||
if _dpkg_owns(pkg / "__init__.py"):
|
||||
return "apt"
|
||||
if sys.prefix != sys.base_prefix: # inside a venv → the pip/.run install
|
||||
return "pip"
|
||||
if (pkg.parents[1] / "pyproject.toml").exists(): # repo checkout
|
||||
return "dev"
|
||||
if str(pkg).startswith("/usr/") or "/dist-packages/" in str(pkg):
|
||||
return "apt" # system-managed but no dpkg record — still don't pip
|
||||
return "pip"
|
||||
|
||||
|
||||
def update_hint(kind: str | None = None) -> str:
|
||||
"""Human guidance for installs that can't self-update via pip (apt / source)."""
|
||||
kind = kind or install_kind()
|
||||
if kind == "apt":
|
||||
return ("Installed via apt — update with:\n"
|
||||
f" sudo apt update && sudo apt install --only-upgrade {APT_PACKAGE}")
|
||||
if kind == "dev":
|
||||
return "Running from a source checkout — update with `git pull`."
|
||||
return ""
|
||||
|
||||
|
||||
def _parse(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit())
|
||||
|
||||
@@ -100,11 +147,16 @@ def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str
|
||||
|
||||
|
||||
def apply_update(tag: str) -> tuple[int, str]:
|
||||
"""Self-update the current (user-local) install to `tag` via authenticated pip.
|
||||
"""Update to `tag` using the method matching how RigDoctor was installed.
|
||||
|
||||
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@…/rigdoctor.git@<tag>` into
|
||||
the running environment. Returns (exit_code, output) with the token scrubbed.
|
||||
Only pip/venv installs are upgraded in place (authenticated pip install of
|
||||
`rigdoctor[gui] @ git+https://oauth2:<token>@…/rigdoctor.git@<tag>`). apt and source
|
||||
installs can't be (root/dpkg- or VCS-managed), so they return guidance instead of
|
||||
attempting pip. Returns (exit_code, output) with the token scrubbed.
|
||||
"""
|
||||
kind = install_kind()
|
||||
if kind != "pip":
|
||||
return (1, update_hint(kind))
|
||||
token = load_token()
|
||||
if not token:
|
||||
return (1, "No update token configured. Run `rigdoctor login`.")
|
||||
|
||||
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTextEdit,
|
||||
@@ -51,6 +52,10 @@ _NAV = [
|
||||
("App", ["Settings", "Share"]),
|
||||
]
|
||||
_PAGES = [name for _section, names in _NAV for name in names]
|
||||
# Pages that manage their own scrolling (pinned header + inner scroll) or must fill the
|
||||
# viewport (the Share terminal) — these are added to the stack as-is; every other page is
|
||||
# wrapped in a QScrollArea so it scrolls when too tall and doesn't pin the window's height.
|
||||
_NO_WRAP = {"Dashboard", "System Health", "Inventory", "Share"}
|
||||
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
|
||||
|
||||
|
||||
@@ -68,7 +73,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QHBoxLayout(central)
|
||||
outer = QVBoxLayout(central)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
body = QWidget()
|
||||
layout = QHBoxLayout(body)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
@@ -100,11 +109,14 @@ class MainWindow(QMainWindow):
|
||||
"Share": self.share_page,
|
||||
}
|
||||
for name in _PAGES:
|
||||
self._stack.addWidget(self._pages[name])
|
||||
page = self._pages[name]
|
||||
self._stack.addWidget(page if name in _NO_WRAP else self._scrollable(page))
|
||||
content_layout.addWidget(self._stack)
|
||||
|
||||
layout.addWidget(self._build_sidebar())
|
||||
layout.addWidget(content, 1)
|
||||
outer.addWidget(body, 1)
|
||||
outer.addWidget(self._build_footer())
|
||||
|
||||
self._worker = SamplerWorker(interval=interval)
|
||||
self._worker.sampled.connect(self.dashboard.update_sample)
|
||||
@@ -216,9 +228,6 @@ class MainWindow(QMainWindow):
|
||||
v.addStretch(1)
|
||||
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
|
||||
v.addWidget(live)
|
||||
version = QLabel(f"v{__version__}")
|
||||
version.setObjectName("Muted")
|
||||
v.addWidget(version)
|
||||
changelog_btn = QPushButton("Changelog")
|
||||
changelog_btn.setObjectName("LinkButton")
|
||||
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
@@ -248,6 +257,27 @@ class MainWindow(QMainWindow):
|
||||
v.addWidget(self._restart_btn)
|
||||
return bar
|
||||
|
||||
def _scrollable(self, page: QWidget) -> QScrollArea:
|
||||
"""Wrap a page so it scrolls when taller than the window — and so the window can shrink
|
||||
below the page's natural height instead of being pinned to it."""
|
||||
area = QScrollArea()
|
||||
area.setWidget(page)
|
||||
area.setWidgetResizable(True)
|
||||
area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
return area
|
||||
|
||||
def _build_footer(self) -> QFrame:
|
||||
bar = QFrame()
|
||||
bar.setObjectName("Footer")
|
||||
h = QHBoxLayout(bar)
|
||||
h.setContentsMargins(14, 5, 16, 5)
|
||||
h.addStretch(1)
|
||||
version = QLabel(f"RigDoctor v{__version__}")
|
||||
version.setObjectName("Muted")
|
||||
h.addWidget(version)
|
||||
return bar
|
||||
|
||||
def _restart(self) -> None:
|
||||
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
|
||||
if os.path.exists(gui):
|
||||
@@ -259,6 +289,9 @@ class MainWindow(QMainWindow):
|
||||
def _apply_update(self) -> None:
|
||||
if not self._latest_tag:
|
||||
return
|
||||
if updates.install_kind() != "pip": # apt/source: can't pip-update — show the command
|
||||
QMessageBox.information(self, "Update RigDoctor", updates.update_hint())
|
||||
return
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle(f"Update to {self._latest_tag}")
|
||||
box.setText(f"Update RigDoctor to {self._latest_tag}?")
|
||||
@@ -424,7 +457,7 @@ class MainWindow(QMainWindow):
|
||||
self._update_label.setText("update check unavailable")
|
||||
elif state == updates.AVAILABLE:
|
||||
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
|
||||
self._update_btn.setText(f"Update to {tag}")
|
||||
self._update_btn.setText(f"Update to {tag}" if updates.install_kind() == "pip" else "How to update")
|
||||
self._update_btn.setVisible(True)
|
||||
if self._alert_monitor.enabled and tag != self._notified_update_tag:
|
||||
self._notified_update_tag = tag # once per version, not every poll
|
||||
|
||||
@@ -68,6 +68,8 @@ QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
|
||||
QLabel {{ background: transparent; }}
|
||||
|
||||
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
|
||||
#Footer {{ background: {SIDEBAR}; border-top: 1px solid {CARD_BORDER}; }}
|
||||
#Footer QLabel {{ font-size: 11px; }}
|
||||
#AppTitle {{ font-size: 17px; font-weight: 800; }}
|
||||
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the M13 updater: install detection + routing the update to the right method."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core import updates
|
||||
|
||||
|
||||
class InstallKindTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
updates.install_kind.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
updates.install_kind.cache_clear()
|
||||
|
||||
def test_apt_when_dpkg_owns_the_package(self):
|
||||
with mock.patch.object(updates, "_dpkg_owns", return_value=True):
|
||||
self.assertEqual(updates.install_kind(), "apt")
|
||||
|
||||
def test_pip_when_running_in_a_venv(self):
|
||||
with mock.patch.object(updates, "_dpkg_owns", return_value=False), \
|
||||
mock.patch.object(updates.sys, "prefix", "/opt/venv"), \
|
||||
mock.patch.object(updates.sys, "base_prefix", "/usr"):
|
||||
self.assertEqual(updates.install_kind(), "pip")
|
||||
|
||||
|
||||
class ApplyUpdateRoutingTests(unittest.TestCase):
|
||||
def test_apt_returns_guidance_and_never_runs_pip(self):
|
||||
with mock.patch.object(updates, "install_kind", return_value="apt"), \
|
||||
mock.patch("subprocess.run") as run:
|
||||
rc, out = updates.apply_update("v9.9.9")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("apt install --only-upgrade", out)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_dev_returns_guidance_and_never_runs_pip(self):
|
||||
with mock.patch.object(updates, "install_kind", return_value="dev"), \
|
||||
mock.patch("subprocess.run") as run:
|
||||
rc, out = updates.apply_update("v9.9.9")
|
||||
self.assertIn("git pull", out)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_pip_install_runs_pip(self):
|
||||
proc = mock.Mock(returncode=0, stdout="Successfully installed", stderr="")
|
||||
with mock.patch.object(updates, "install_kind", return_value="pip"), \
|
||||
mock.patch.object(updates, "load_token", return_value="TOK"), \
|
||||
mock.patch("subprocess.run", return_value=proc) as run:
|
||||
rc, _out = updates.apply_update("v1.2.3")
|
||||
self.assertEqual(rc, 0)
|
||||
cmd = run.call_args[0][0]
|
||||
self.assertIn("pip", cmd)
|
||||
self.assertIn("install", cmd)
|
||||
|
||||
|
||||
class UpdateHintTests(unittest.TestCase):
|
||||
def test_apt_hint_names_the_apt_command(self):
|
||||
self.assertIn("apt install --only-upgrade rigdoctor", updates.update_hint("apt"))
|
||||
|
||||
def test_dev_hint_says_git_pull(self):
|
||||
self.assertIn("git pull", updates.update_hint("dev"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user