Compare commits

...

9 Commits

Author SHA1 Message Date
jessey e3b20089f0 feat: alerts (M8), notifications config page, and app icon (0.4.0)
release / release (push) Successful in 14s
- feat(alerts): desktop notifications (notify-send) for overheat (GPU/CPU past a
  configurable threshold), GPU-lost, and a new-version-available alert (once per
  version). Edge-triggered with cooldown so it doesn't spam (core/alerts.py)
- feat(gui): Notifications page to configure alerts (enable, GPU/CPU thresholds,
  Send test); changes apply live and persist via config.save_config/update_config
- feat(gui): ship a RigDoctor icon; the GUI self-registers the icon + .desktop on
  launch and sets the Wayland app-id, so the dock shows it after an update + relaunch
  (no installer re-run); installer/uninstaller updated to manage the icon
- config: alerts_enabled, gpu_temp_alert, cpu_temp_alert; flat-TOML writer
- tests for the alert monitor and config round-trip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:22:13 +02:00
jessey 54c0971ac3 refactor(gui): one-time launch elevation instead of "Run with admin"
release / release (push) Successful in 14s
Remove the per-page "Run with admin" buttons. At launch the GUI asks for the
password once (pkexec) and collects root-only data (SMART + dmidecode board/
BIOS/RAM) via the internal `collect-priv` command, caching it for the session;
Health and Inventory read that cache so they always show the full picture.

- core/elevation.py: pkexec collect + session cache
- cli: hidden `collect-priv` command (SMART + dmidecode -> JSON)
- health/inventory: use the elevation cache when present, else non-root
- main_window: collect at launch (config elevate_on_launch), then refresh
  Health/Inventory; falls back silently if cancelled/unavailable
- config: elevate_on_launch (default true)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:11:15 +02:00
jessey 9ae2e22b44 fix(gui): render changelog and release notes as Markdown
release / release (push) Successful in 14s
The changelog dialog and update prompt showed raw Markdown (literal #, **)
instead of rendered styling, making notes hard to read. Render the in-app
changelog with QTextEdit.setMarkdown() and the update prompt's notes as rich
text (Markdown -> HTML via QTextDocument).

Closes #1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:26 +02:00
jessey 89ebb6c61e feat: system inventory (M5)
release / release (push) Successful in 14s
CPU, GPU (model/driver/VBIOS/VRAM/PCIe), motherboard/BIOS, RAM (total +
modules), storage (real disks only), kernel, and display server.

- core/inventory.py: collect() + Markdown/JSON/text renderers + dict round-trip;
  stdlib + nvidia-smi/lspci/lsblk/dmidecode, all degrading gracefully
- cli: `rigdoctor inventory` (--json / --markdown / -o)
- gui: Inventory tab (fills the last empty tab) with Copy-as-Markdown, Save, and
  "Run with admin" (pkexec) for dmidecode board/BIOS/RAM details
- tests for collect/render/round-trip; remove unused placeholder-page helper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:55:46 +02:00
jessey c8f7d66349 feat(gui): add a "Check for updates" button to the sidebar
release / release (push) Successful in 13s
Force an immediate version check instead of waiting for the 30-minute poll.
Reuses the background update check; no-op while awaiting restart after an update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:48:10 +02:00
jessey 6215181d23 fix(gui): make dialogs readable (dark theme)
release / release (push) Successful in 14s
The update prompt and changelog dialogs inherited the app's light text color on a
default light Fusion background, making them unreadable. Style QDialog/QMessageBox
with the dark theme and readable text.
2026-05-21 18:45:57 +02:00
jessey 09cbc57b8c feat: in-app uninstaller, changelog viewer, version automation (0.1.0)
release / release (push) Successful in 13s
First milestone release — a complete, installable, self-updating RigDoctor:
live monitoring, crash capture + health report, desktop GUI, user-local
install/uninstall, and token-gated self-update with real release notes.

- feat(gui): in-app uninstaller — Setup "Uninstall RigDoctor" button and
  `rigdoctor uninstall [--purge]`; removes venv/launchers/desktop entry
  (detached so it can delete its own venv), with optional purge of
  settings/token/logs (core/uninstall.py)
- feat(gui): in-app changelog — sidebar "Changelog" link listing release
  history fetched from the update server (updates.list_releases)
- chore: versioning rules + automation (D21) — git-cliff --bumped-version,
  packaging/bump.sh, cliff.toml [bump] (pre-1.0: breaking -> minor)
- chore(release): stamp 0.1.0; milestone policy recorded in D19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:42:29 +02:00
jessey f3021c4ddb feat: real release notes, restart button, reliable .run installer (0.0.10)
release / release (push) Successful in 14s
- feat(ci): set each Gitea release body from the matching CHANGELOG section
  (was hardcoded "Automated release for…")
- feat(updater): show "What's new" — release notes dialog before applying (GUI)
  and in `rigdoctor update` (CLI); fetch_latest/update_state now return notes
- feat(gui): "Restart now" button relaunches the app after an update is applied
- fix(packaging): build the self-extracting .run with a pure-Python extractor
  (packaging/make_run.py) instead of makeself, so it attaches to every release
  (it was silently skipped before)
- chore: adopt Conventional Commits + git-cliff (cliff.toml, packaging/
  changelog.sh) for changelog generation going forward (D20)
- chore(gui): drop internal module refs (M4, M5, …) from Setup descriptions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:31:28 +02:00
jessey ca4bc4c64f Release 0.0.8: periodic update checks + "Run with admin" health checks
release / release (push) Successful in 22s
- GUI re-checks for new releases while running (every update_check_minutes,
  default 30; 0 disables), so a newly published version is detected without a
  restart; re-checks pause after an update is applied (awaiting restart)
- Health page "Run with admin" button: runs all checks incl. root-only SMART
  via `pkexec rigdoctor report --json`, so the full report is available from the
  UI (cancel keeps prior results)
- version 0.0.8, CHANGELOG

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:18:03 +02:00
33 changed files with 1486 additions and 108 deletions
+26 -4
View File
@@ -28,9 +28,7 @@ jobs:
python -m build python -m build
- name: Build self-extracting installer (.run) - name: Build self-extracting installer (.run)
run: | run: python packaging/make_run.py
(apt-get update && apt-get install -y makeself && sh packaging/make-run.sh) \
|| echo "makeself unavailable — skipping .run"
- name: Read version - name: Read version
id: ver id: ver
@@ -38,6 +36,30 @@ jobs:
V=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") V=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
echo "version=$V" >> "$GITHUB_OUTPUT" echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Build release notes
run: |
python - <<'PY'
import json
version = "${{ steps.ver.outputs.version }}"
tag = f"v{version}"
out, capturing = [], False
try:
for line in open("CHANGELOG.md", encoding="utf-8").read().splitlines():
if line.startswith("## "):
if capturing:
break
capturing = line.startswith(f"## [{version}]")
continue
if capturing:
out.append(line)
except OSError:
pass
body = "\n".join(out).strip() or f"Release {tag}."
payload = {"tag_name": tag, "target_commitish": "${{ github.sha }}", "name": tag, "body": body}
open("/tmp/release.json", "w", encoding="utf-8").write(json.dumps(payload))
print(f"release notes: {len(body)} chars")
PY
- name: Publish Gitea release - name: Publish Gitea release
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -57,7 +79,7 @@ jobs:
rid=$(curl -sS -X POST \ rid=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${TAG}\",\"body\":\"Automated release for ${TAG}. See CHANGELOG.md.\"}" \ -d @/tmp/release.json \
"${API}/releases" | python -c "import sys, json; print(json.load(sys.stdin)['id'])") "${API}/releases" | python -c "import sys, json; print(json.load(sys.stdin)['id'])")
for f in dist/*; do for f in dist/*; do
+78
View File
@@ -5,6 +5,84 @@ 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.4.0] - 2026-05-21
### Added
- **Alerts (M8)**: desktop notifications (via `notify-send`) for **overheat** (GPU/CPU past a
threshold), **GPU-lost** (nvidia-smi timeout), and a **new version available** (fired once
per version). Edge-triggered with a cooldown so it doesn't spam. Degrades gracefully if
`notify-send` isn't installed.
- **Notifications page**: configure alerts (enable/disable, GPU/CPU temperature thresholds)
with a "Send test" button; changes apply live and persist to `config.toml`.
- **App icon**: ships a RigDoctor icon and shows it in the dock/launcher. The GUI
**self-registers** the icon + `.desktop` on launch (and sets the Wayland app-id), so a
self-update + relaunch picks it up — no need to re-run the installer.
## [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
### Fixed
- Changelog/release notes now **render Markdown** instead of showing raw `#`/`**` markup —
the in-app changelog uses `QTextEdit.setMarkdown()` and the update prompt renders notes as
rich text (closes #1).
## [0.3.0] - 2026-05-21
### Added
- **System inventory (M5)**: CPU, GPU (model/driver/VBIOS/VRAM/PCIe), motherboard/BIOS, RAM
(total + modules), storage, kernel, and display server. CLI `rigdoctor inventory`
(`--json` / `--markdown` / `--output`) and a GUI **Inventory** tab with Copy-as-Markdown,
Save, and "Run with admin" (for `dmidecode` board/BIOS/RAM details). Fills the last GUI tab.
## [0.2.0] - 2026-05-21
### Added
- **"Check for updates" button** in the sidebar — force an immediate version check instead of
waiting for the 30-minute poll.
## [0.1.1] - 2026-05-21
### Fixed
- Dialogs (the update prompt and changelog) were light-on-light and unreadable — they now use
the dark theme with readable text.
## [0.1.0] - 2026-05-21
_First milestone release — a complete, installable, self-updating RigDoctor: live monitoring,
crash capture + health report, desktop GUI, user-local install/uninstall, and updates._
### Added
- **In-app uninstaller**: "Uninstall RigDoctor" button on the Setup page (and
`rigdoctor uninstall [--purge]`) — removes the venv, launchers, and desktop entry, with an
option to also wipe settings/token/logs. Runs detached so it can delete its own venv.
- **In-app changelog**: a "Changelog" link in the sidebar opens the release history (tags +
notes) fetched from the update server.
## [0.0.10] - 2026-05-21
### Added
- **"Restart now" button** after a successful in-app update — relaunches RigDoctor for you
instead of asking you to restart manually.
- **Real release notes**: CI now sets each Gitea release's body from the matching CHANGELOG
section (instead of "Automated release for…"), and the updater shows **"What's new"** — a
notes dialog before applying (GUI) and in `rigdoctor update` (CLI).
### Changed
- Setup page / `rigdoctor install`: dropped internal module references (M4, M5, …) from the
component descriptions — end users don't need them.
- Adopting **Conventional Commits** + **git-cliff** (`cliff.toml`, `packaging/changelog.sh`)
to generate CHANGELOG entries from commit history going forward (D20).
### Fixed
- The self-extracting **`.run` installer** is now built **without makeself** (a pure-Python
self-extractor, `packaging/make_run.py`), so it reliably attaches to every release — it was
silently skipped before because the CI runner couldn't install makeself.
## [0.0.8] - 2026-05-21
### Added
- **Periodic update checks**: the GUI now re-checks for new releases while running (every
`update_check_minutes`, default 30; 0 disables), so a newly published version is detected
without restarting. After applying an update, re-checks stop until restart.
- **"Run with admin" on the Health page**: runs all checks (including root-only SMART) via
`pkexec rigdoctor report --json`, so the full report — not just "SMART needs root" — is
available from the UI.
## [0.0.7] - 2026-05-21 ## [0.0.7] - 2026-05-21
### Added ### Added
- **User-local installer** `install.sh` (no root): creates a private venv, links - **User-local installer** `install.sh` (no root): creates a private venv, links
+42
View File
@@ -0,0 +1,42 @@
# git-cliff configuration — generate CHANGELOG.md from Conventional Commits (D20).
# Run via packaging/changelog.sh.
[changelog]
header = """
# Changelog
All notable changes to RigDoctor are recorded here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versioning is SemVer-style
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions).
"""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
## {{ group | upper_first }}
{% for commit in commits %}\
- {{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^docs", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Changed" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore|^build|^ci|^style|^test", group = "Internal" },
{ message = ".*", group = "Other" },
]
tag_pattern = "v[0-9]*"
sort_commits = "oldest"
[bump]
# Pre-1.0 rules (D21): feat -> minor, fix -> patch, breaking -> minor (not major).
features_always_bump_minor = true
breaking_always_bump_major = false
+31 -2
View File
@@ -191,12 +191,41 @@ desirable — access control is delegated to Gitea.
PATCH for ordinary changes, MINOR for larger milestones). `__version__` PATCH for ordinary changes, MINOR for larger milestones). `__version__`
(`rigdoctor/__init__.py`) and `pyproject.toml` are the single source of truth and **must match (`rigdoctor/__init__.py`) and `pyproject.toml` are the single source of truth and **must match
the git release tag** so the auto-updater (D18) can compare versions. Every change updates the git release tag** so the auto-updater (D18) can compare versions. Every change updates
`CHANGELOG.md` (Keep a Changelog style). *Note:* an early placeholder `0.1.0` was corrected to `CHANGELOG.md` — now generated from **Conventional Commits** via git-cliff (see D20).
*Milestone policy (pre-1.0):* **0.0.x** = early development; **0.1.0** = first complete,
installable, self-updating release (reached 2026-05-21); **0.x.0** = each later milestone
(AMD/Intel, unattended logger auto-start, session sharing…); **1.0.0** = broadly stable
(multi-vendor/distro, no major caveats). PATCH (`0.x.PATCH`) for fixes/small changes. *Note:* an early placeholder `0.1.0` was corrected to
follow the released **0.0.x** line — first release was **V0.0.1**; current is **0.0.2**. follow the released **0.0.x** line — first release was **V0.0.1**; current is **0.0.2**.
### D20 — Automated changelog & release notes — *DECIDED 2026-05-21*
**Release notes are generated from our changes, surfaced in the auto-updater.**
- *Release body:* CI sets each Gitea release's `body` from the matching `CHANGELOG.md`
section (was a hardcoded "Automated release for…"). The updater fetches the release `body`
and shows **"What's new"** — a dialog before applying (GUI) and in `rigdoctor update` (CLI).
- *Generation:* adopt **Conventional Commits** (`feat:`/`fix:`/`docs:`/`chore:` …) and
**git-cliff** (`cliff.toml`, `packaging/changelog.sh`) to generate `CHANGELOG.md` from
commit history. Refines D19's "hand-write CHANGELOG" to "generate it from conventional
commits"; `__version__`/`pyproject.toml`/tag still the source of truth for the version.
- *CI does not auto-commit the changelog* (avoids push loops) — it's regenerated by the dev
via the script when cutting a version; CI only reads the section for the release body.
### D21 — Versioning rules & automation — *DECIDED 2026-05-21*
The next version is **determined by the Conventional Commit types** since the last release
(D20), so it can be auto-computed instead of guessed:
- `fix:` / `perf:` → bump **PATCH**.
- `feat:` → bump **MINOR** (pre-1.0: `0.MINOR.0`).
- breaking (`feat!:` / `BREAKING CHANGE:`) → pre-1.0: bump **MINOR** (not major); post-1.0: MAJOR.
- `docs:` / `chore:` / `refactor:` / `ci:` / `test:` / `style:` alone → **PATCH** (no feature release).
- Milestone overrides by hand are allowed (e.g., jumping to `1.0.0`); see the milestone policy in D19.
*Automation:* `git-cliff --bumped-version` computes the next version from history;
`packaging/bump.sh` writes it into `__init__.py` + `pyproject.toml`. Rules live in
`cliff.toml [bump]` (pre-1.0: `breaking_always_bump_major = false`).
## Open ## Open
None currently — all tracked decisions (D1D19) are resolved. New questions will be added None currently — all tracked decisions (D1D21) are resolved. New questions will be added
here as they arise. Remaining detail to flesh out during build: the tray's supporting-action here as they arise. Remaining detail to flesh out during build: the tray's supporting-action
set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's
update mechanism (APT repo vs. self-installed `.deb`). update mechanism (APT repo vs. self-installed `.deb`).
+4 -3
View File
@@ -12,8 +12,8 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | | M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 |
| M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ |
| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | | | M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 |
| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | | | M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 |
| M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ | | M6 | Gaming env checks | Diagnostics | none | all | P2 | ⬜ |
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | 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 | ⬜ | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ |
@@ -59,7 +59,8 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
and dependency install via pkexec/sudo — `rigdoctor install [--check] [-y]` + GUI Setup tab. and dependency install via pkexec/sudo — `rigdoctor install [--check] [-y]` + GUI Setup tab.
The **user-local app install** is `install.sh` (private venv + `~/.local/bin` launchers + The **user-local app install** is `install.sh` (private venv + `~/.local/bin` launchers +
desktop entry, no root; handles the `python3-venv` prerequisite) plus a self-extracting desktop entry, no root; handles the `python3-venv` prerequisite) plus a self-extracting
**`.run`** (makeself, built by CI). *Pending:* config/module selection + `systemd --user` **`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:*
config/module selection + `systemd --user`
service enable. service enable.
- **M12 Session sharing / remote assist** (D16) — let a helper inspect a user's machine, in - **M12 Session sharing / remote assist** (D16) — let a helper inspect a user's machine, in
an escalating ladder: (1) **diagnostic bundle export** (inventory + recent log + report, an escalating ladder: (1) **diagnostic bundle export** (inventory + recent log + report,
+16 -2
View File
@@ -16,7 +16,8 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
uninstall() { uninstall() {
echo "Removing RigDoctor user-local install…" echo "Removing RigDoctor user-local install…"
rm -rf "$VENV" rm -rf "$VENV"
rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE" \
"$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)" echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)"
} }
@@ -81,6 +82,17 @@ mkdir -p "$BIN_DIR"
ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor" ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor"
ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui" ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui"
# Install the app icon (for the dock/launcher); fall back to a stock icon.
ICON_NAME=utilities-system-monitor
ICON_SRC=$("$VENV/bin/python" -c "import os, rigdoctor.gui as g; print(os.path.join(os.path.dirname(g.__file__), 'assets', 'rigdoctor.svg'))" 2>/dev/null || true)
if [ -n "$ICON_SRC" ] && [ -f "$ICON_SRC" ]; then
ICON_DST="$DATA_HOME/icons/hicolor/scalable/apps/rigdoctor.svg"
mkdir -p "$(dirname "$ICON_DST")"
cp "$ICON_SRC" "$ICON_DST"
ICON_NAME=rigdoctor
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -qtf "$DATA_HOME/icons/hicolor" 2>/dev/null || true
fi
mkdir -p "$DESKTOP_DIR" mkdir -p "$DESKTOP_DIR"
cat > "$DESKTOP_FILE" <<EOF cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry] [Desktop Entry]
@@ -88,10 +100,12 @@ Type=Application
Name=RigDoctor Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec=$VENV/bin/rigdoctor-gui Exec=$VENV/bin/rigdoctor-gui
Icon=utilities-system-monitor Icon=$ICON_NAME
Terminal=false Terminal=false
Categories=System;Monitor;Utility; Categories=System;Monitor;Utility;
StartupWMClass=rigdoctor
EOF EOF
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
echo echo
echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed." echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed."
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env sh
# Auto-set the next version from Conventional Commits (git-cliff), per D21.
# Run after committing your feat:/fix: changes; it updates __init__.py + pyproject.toml.
# Then update CHANGELOG.md, commit as `chore(release): vX.Y.Z`, and push (CI tags + releases).
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found. Install: pip install git-cliff"; exit 1; }
NEXT=$(git-cliff --bumped-version | sed 's/^v//')
[ -n "$NEXT" ] || { echo "Could not compute the next version."; exit 1; }
python3 - "$NEXT" <<'PY'
import pathlib, re, sys
version = sys.argv[1]
init = pathlib.Path("src/rigdoctor/__init__.py")
init.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{version}"', init.read_text()))
proj = pathlib.Path("pyproject.toml")
proj.write_text(re.sub(r'(?m)^version = "[^"]+"', f'version = "{version}"', proj.read_text(), count=1))
PY
echo "Set version to $NEXT."
echo "Next: add a '## [$NEXT]' CHANGELOG section, then commit as 'chore(release): v$NEXT'."
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env sh
# Regenerate CHANGELOG.md from Conventional Commits using git-cliff (D20).
# Install once: pip install git-cliff (ships prebuilt binaries)
# Usage: packaging/changelog.sh [--tag vX.Y.Z]
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
command -v git-cliff >/dev/null 2>&1 || {
echo "git-cliff not found. Install it: pip install git-cliff"
exit 1
}
if [ "${1:-}" = "--tag" ] && [ -n "${2:-}" ]; then
git-cliff --tag "$2" -o CHANGELOG.md
else
git-cliff -o CHANGELOG.md
fi
echo "Wrote CHANGELOG.md"
+2 -32
View File
@@ -1,33 +1,3 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Build a self-extracting .run installer: bundles the wheel + install.sh so a user # Build the self-extracting .run installer (delegates to make_run.py — no makeself).
# can download one file, run it, and get a no-root user-local install. exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/make_run.py" "$@"
#
# Requires `makeself` (apt install makeself). Run from the repo root.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
command -v makeself >/dev/null 2>&1 || {
echo "makeself not found. Install it: sudo apt install makeself"
exit 1
}
VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
mkdir -p dist
# Build the wheel if it isn't already in dist/.
if ! ls dist/rigdoctor-"$VERSION"-py3-none-any.whl >/dev/null 2>&1; then
python3 -m build --wheel
fi
STAGE=$(mktemp -d)
cp dist/rigdoctor-"$VERSION"-py3-none-any.whl "$STAGE"/
cp install.sh "$STAGE"/install.sh
chmod +x "$STAGE/install.sh"
OUT="dist/rigdoctor-$VERSION-installer.run"
makeself --notemp-suffix "$STAGE" "$OUT" "RigDoctor $VERSION installer" ./install.sh
rm -rf "$STAGE"
echo "Built $OUT"
echo "Run it with: chmod +x $OUT && ./$OUT"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Build a dependency-free self-extracting .run installer (no makeself).
Produces dist/rigdoctor-<version>-installer.run: a POSIX shell stub with an appended
tar.gz of the wheel + install.sh. Running it extracts to a temp dir and runs install.sh.
"""
from __future__ import annotations
import io
import os
import subprocess
import sys
import tarfile
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MARKER = "__RIGDOCTOR_ARCHIVE__"
STUB = f"""#!/bin/sh
# RigDoctor self-extracting installer. Extracts the embedded archive and runs install.sh.
set -eu
SKIP=$(awk '/^{MARKER}$/ {{ print NR + 1; exit 0 }}' "$0")
TMP=$(mktemp -d)
tail -n +"$SKIP" "$0" | tar -xz -C "$TMP"
sh "$TMP/install.sh" "$@"
RET=$?
rm -rf "$TMP"
exit $RET
{MARKER}
"""
def main() -> int:
version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
dist = ROOT / "dist"
dist.mkdir(exist_ok=True)
wheel = dist / f"rigdoctor-{version}-py3-none-any.whl"
if not wheel.exists():
subprocess.run([sys.executable, "-m", "build", "--wheel"], cwd=ROOT, check=True)
if not wheel.exists():
print(f"wheel not found: {wheel}", file=sys.stderr)
return 1
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
tar.add(wheel, arcname=wheel.name)
tar.add(ROOT / "install.sh", arcname="install.sh")
out = dist / f"rigdoctor-{version}-installer.run"
with open(out, "wb") as f:
f.write(STUB.encode())
f.write(buf.getvalue())
os.chmod(out, 0o755)
print(f"Built {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.0.7" version = "0.4.0"
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"
@@ -21,3 +21,6 @@ rigdoctor-gui = "rigdoctor.gui.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
rigdoctor = ["gui/assets/*.svg"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.0.7" __version__ = "0.4.0"
+65 -2
View File
@@ -228,7 +228,7 @@ def cmd_login(args) -> int:
print("No token provided.") print("No token provided.")
return 1 return 1
config.save_token(token) config.save_token(token)
state, tag = updates.update_state() state, tag, _notes = updates.update_state()
if state == updates.AUTH: if state == updates.AUTH:
print("Token saved, but the server rejected it (check scope/permissions).") print("Token saved, but the server rejected it (check scope/permissions).")
return 1 return 1
@@ -248,7 +248,7 @@ def cmd_logout(args) -> int:
def cmd_update(args) -> int: def cmd_update(args) -> int:
from .core import updates from .core import updates
state, tag = updates.update_state() state, tag, notes = updates.update_state()
if state == updates.NO_TOKEN: if state == updates.NO_TOKEN:
print("No update token. Run `rigdoctor login` after creating one at:") print("No update token. Run `rigdoctor login` after creating one at:")
print(f" {updates.TOKEN_PAGE}") print(f" {updates.TOKEN_PAGE}")
@@ -264,6 +264,8 @@ def cmd_update(args) -> int:
return 0 return 0
# AVAILABLE # AVAILABLE
print(f"Update available: {tag} (current v{__version__}).") print(f"Update available: {tag} (current v{__version__}).")
if notes:
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check: if args.check:
return 0 return 0
print(f"Installing {tag}") print(f"Installing {tag}")
@@ -276,6 +278,53 @@ def cmd_update(args) -> int:
return rc return rc
def cmd_uninstall(args) -> int:
from .core import uninstall as uninstaller
scope = "everything (app + settings, token, and logs)" if args.purge else "the app (settings/logs kept)"
if not args.yes:
try:
reply = input(f"Uninstall RigDoctor — remove {scope}? [y/N] ").strip().lower()
except EOFError:
reply = "n"
if reply not in ("y", "yes"):
print("Aborted.")
return 1
uninstaller.uninstall(purge=args.purge)
print("Uninstalling… RigDoctor will be removed momentarily.")
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:
from .core import inventory
sections = inventory.collect()
if args.json:
text = inventory.render_json(sections)
elif args.markdown:
text = inventory.render_markdown(sections)
else:
text = inventory.render_text(sections)
if args.output:
Path(args.output).write_text(text)
print(f"Wrote {args.output}")
else:
print(text)
return 0
def cmd_report(args) -> int: def cmd_report(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -323,6 +372,11 @@ def build_parser() -> argparse.ArgumentParser:
upd.add_argument("--check", action="store_true", help="only report, don't apply") upd.add_argument("--check", action="store_true", help="only report, don't apply")
upd.set_defaults(func=cmd_update) upd.set_defaults(func=cmd_update)
unin = sub.add_parser("uninstall", help="remove the user-local install")
unin.add_argument("--purge", action="store_true", help="also remove settings, token, and logs")
unin.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
unin.set_defaults(func=cmd_uninstall)
rec = sub.add_parser("record", help="crash-capture logger (M3)") rec = sub.add_parser("record", help="crash-capture logger (M3)")
rec_sub = rec.add_subparsers(dest="record_cmd", required=True) rec_sub = rec.add_subparsers(dest="record_cmd", required=True)
@@ -347,6 +401,15 @@ def build_parser() -> argparse.ArgumentParser:
rep = sub.add_parser("report", help="health report (M4): scan logs/SMART/driver for issues") rep = sub.add_parser("report", help="health report (M4): scan logs/SMART/driver for issues")
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.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("-o", "--output", default=None, help="write to a file instead of stdout")
inv.set_defaults(func=cmd_inventory)
return p return p
+29
View File
@@ -137,6 +137,11 @@ DEFAULTS: dict = {
"interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR) "interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR)
"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)
"elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode)
"alerts_enabled": True, # desktop notifications on overheat / GPU-lost / new version
"gpu_temp_alert": 90.0, # °C — alert when GPU reaches this
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
} }
@@ -152,3 +157,27 @@ def load_config() -> dict:
except Exception: except Exception:
pass pass
return cfg return cfg
def _toml_value(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_config(values: dict) -> None:
"""Write a flat config.toml (stdlib has no TOML writer)."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
lines = ["# RigDoctor config — edit in the app (Notifications) or here."]
lines += [f"{key} = {_toml_value(value)}" for key, value in values.items()]
CONFIG_FILE.write_text("\n".join(lines) + "\n")
def update_config(**changes) -> dict:
"""Merge changes into the current effective config and persist them."""
cfg = load_config()
cfg.update(changes)
save_config(cfg)
return cfg
+91
View File
@@ -0,0 +1,91 @@
"""Desktop alerts (M8): notify on overheat / GPU-lost / new version via notify-send.
Edge-triggered: an alert fires when a condition becomes true (not every sample), and
can fire again only after it has cleared and a cooldown has passed — so a hot GPU or a
1-Hz sample loop doesn't spam notifications. Degrades to a no-op if notify-send is absent.
"""
from __future__ import annotations
import shutil
import subprocess
import time
from .sample import Sample
APP_NAME = "RigDoctor"
_ICON = "utilities-system-monitor"
def available() -> bool:
return shutil.which("notify-send") is not None
def notify(title: str, message: str, urgency: str = "normal") -> bool:
"""Send a desktop notification (best-effort). urgency: low|normal|critical."""
if not available():
return False
try:
subprocess.run(
["notify-send", "-a", APP_NAME, "-u", urgency, "-i", _ICON, title, message],
timeout=10,
check=False,
)
return True
except (subprocess.SubprocessError, OSError):
return False
class AlertMonitor:
"""Evaluate samples and raise edge-triggered desktop alerts."""
def __init__(self, gpu_temp: float = 90.0, cpu_temp: float = 95.0, cooldown: float = 300.0):
self.gpu_temp = gpu_temp
self.cpu_temp = cpu_temp
self.cooldown = cooldown
self.enabled = True
self._active: dict[str, bool] = {}
self._last: dict[str, float] = {}
def _fire(self, key: str, title: str, message: str, urgency: str = "critical") -> None:
if self._active.get(key):
return # already alerting; wait until it clears
now = time.time()
if now - self._last.get(key, 0.0) < self.cooldown:
return
self._active[key] = True
self._last[key] = now
notify(title, message, urgency)
def _clear(self, key: str) -> None:
self._active[key] = False
def check(self, sample: Sample) -> None:
if not self.enabled:
return
gpu_t = next(
(r.value for r in sample.readings
if r.source == "gpu" and r.metric == "temp" and r.label == "" and r.value is not None),
None,
)
if gpu_t is not None:
if gpu_t >= self.gpu_temp:
self._fire("gpu_temp", "GPU overheating", f"GPU at {gpu_t:.0f} °C")
else:
self._clear("gpu_temp")
cpu_temps = [r.value for r in sample.readings
if r.source == "cpu" and r.metric == "temp" and r.value is not None]
if cpu_temps:
cpu_t = max(cpu_temps)
if cpu_t >= self.cpu_temp:
self._fire("cpu_temp", "CPU overheating", f"CPU at {cpu_t:.0f} °C")
else:
self._clear("cpu_temp")
lost = any(r.source == "gpu" and r.metric == "status" and r.label == "query-timeout"
for r in sample.readings)
if lost:
self._fire("gpu_lost", "GPU not responding", "nvidia-smi query timed out — the GPU may have dropped")
else:
self._clear("gpu_lost")
+4 -4
View File
@@ -23,7 +23,7 @@ class Component:
COMPONENTS: tuple[Component, ...] = ( COMPONENTS: tuple[Component, ...] = (
Component( Component(
"smartmontools", "SMART disk health", "Diagnostics", "smartmontools", "SMART disk health", "Diagnostics",
"Disk health (SMART) in the health report (M4)", ("smartmontools",), "smartctl", "Disk health (SMART) in the health report", ("smartmontools",), "smartctl",
), ),
Component( Component(
"lm-sensors", "lm-sensors", "Diagnostics", "lm-sensors", "lm-sensors", "Diagnostics",
@@ -31,7 +31,7 @@ COMPONENTS: tuple[Component, ...] = (
), ),
Component( Component(
"dmidecode", "dmidecode", "Diagnostics", "dmidecode", "dmidecode", "Diagnostics",
"Motherboard / BIOS / RAM details for system inventory (M5)", ("dmidecode",), "dmidecode", "Motherboard / BIOS / RAM details for system inventory", ("dmidecode",), "dmidecode",
), ),
Component( Component(
"pciutils", "pciutils", "Diagnostics", "pciutils", "pciutils", "Diagnostics",
@@ -39,10 +39,10 @@ COMPONENTS: tuple[Component, ...] = (
), ),
Component( Component(
"libnotify", "Desktop notifications", "Monitoring", "libnotify", "Desktop notifications", "Monitoring",
"Desktop alert notifications (M8)", ("libnotify-bin",), "notify-send", "Desktop alert notifications", ("libnotify-bin",), "notify-send",
), ),
Component( Component(
"libsecret", "Encrypted token storage", "Updates", "libsecret", "Encrypted token storage", "Updates",
"Store the update token in the OS keyring, encrypted (M13)", ("libsecret-tools",), "secret-tool", "Store the update token in the OS keyring, encrypted", ("libsecret-tools",), "secret-tool",
), ),
) )
+51
View File
@@ -0,0 +1,51 @@
"""Session privilege elevation.
At GUI launch the app asks for the password once (pkexec) and collects the data that
needs root — SMART health + dmidecode (board/BIOS/RAM) — caching it for the session so
Health and Inventory can always show the full picture without per-action prompts.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
_privileged: dict | None = None
def privileged() -> dict | None:
"""Cached root-collected data ({"smart": [...], "dmidecode": {...}}), or None."""
return _privileged
def set_privileged(data: dict | None) -> None:
global _privileged
_privileged = data
def available() -> bool:
return shutil.which("pkexec") is not None and os.geteuid() != 0
def _cli() -> list[str]:
candidate = os.path.join(os.path.dirname(sys.executable), "rigdoctor")
return [candidate] if os.path.exists(candidate) else [sys.executable, "-m", "rigdoctor"]
def collect_via_pkexec(timeout: float = 120.0) -> dict | None:
"""Run one elevated collection (single password prompt). None if unavailable/cancelled."""
if not available():
return None
try:
proc = subprocess.run(
["pkexec", *_cli(), "collect-priv"],
capture_output=True, text=True, timeout=timeout,
)
if proc.returncode == 0 and proc.stdout.strip():
return json.loads(proc.stdout)
except (subprocess.SubprocessError, OSError, ValueError):
pass
return None
+12 -2
View File
@@ -234,12 +234,22 @@ def check_live_temps() -> list[Finding]:
def run_health_checks() -> list[Finding]: def run_health_checks() -> list[Finding]:
"""Run all checks and return findings sorted by severity (worst first).""" """Run all checks and return findings sorted by severity (worst first).
SMART needs root; if the session collected it via launch elevation, use that
instead of re-running smartctl (which would just report "needs root").
"""
from . import elevation
findings: list[Finding] = [] findings: list[Finding] = []
findings += check_nvidia_driver() findings += check_nvidia_driver()
findings += check_journal() findings += check_journal()
findings += check_journal_persistence() findings += check_journal_persistence()
findings += check_smart() priv = elevation.privileged()
if priv is not None and priv.get("smart") is not None:
findings += [Finding(**d) for d in priv["smart"]]
else:
findings += check_smart()
findings += check_live_temps() findings += check_live_temps()
findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) findings.sort(key=lambda f: _ORDER.get(f.severity, 9))
return findings return findings
+206
View File
@@ -0,0 +1,206 @@
"""System inventory (M5): collect hardware/OS details, exportable to Markdown/JSON.
Stdlib + tools already used elsewhere (nvidia-smi, lspci, lsblk, dmidecode). Every
probe degrades gracefully; board/BIOS/RAM-module details need dmidecode as root.
"""
from __future__ import annotations
import json
import os
import platform
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from .. import __version__
from . import sysenv
@dataclass
class Section:
title: str
items: list[tuple[str, str]]
def _run(cmd: list[str], timeout: float = 12.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
return proc.stdout
except (subprocess.SubprocessError, OSError):
pass
return ""
def _system() -> Section:
u = os.uname()
return Section("System", [
("Distro", sysenv.distro_name()),
("Kernel", u.release),
("Architecture", u.machine),
("Hostname", u.nodename),
("Python", platform.python_version()),
("RigDoctor", __version__),
])
def _cpu() -> Section:
model = "?"
threads = 0
core_ids: set[tuple[str, str]] = set()
phys = "0"
try:
for line in Path("/proc/cpuinfo").read_text().splitlines():
if line.startswith("model name") and model == "?":
model = line.split(":", 1)[1].strip()
elif line.startswith("processor"):
threads += 1
elif line.startswith("physical id"):
phys = line.split(":", 1)[1].strip()
elif line.startswith("core id"):
core_ids.add((phys, line.split(":", 1)[1].strip()))
except OSError:
pass
items = [("Model", model)]
if core_ids:
items.append(("Cores", str(len(core_ids))))
items.append(("Threads", str(threads or os.cpu_count() or "?")))
return Section("CPU", items)
def _firmware(dmi: dict) -> Section:
board = dmi.get("baseboard", {})
bios = dmi.get("bios", {})
items: list[tuple[str, str]] = []
if board:
items.append(("Motherboard", f"{board.get('Manufacturer', '')} {board.get('Product Name', '')}".strip()))
if bios:
items.append(("BIOS", f"{bios.get('Vendor', '')} {bios.get('Version', '')}".strip()))
if bios.get("Release Date"):
items.append(("BIOS date", bios["Release Date"]))
if not items:
items = [("Motherboard / BIOS", "run with admin (dmidecode needs root)")]
return Section("Firmware", items)
def _memory(dmi: dict) -> Section:
items: list[tuple[str, str]] = []
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if line.startswith("MemTotal"):
items.append(("Total", f"{int(line.split()[1]) / 1024 / 1024:.1f} GB"))
break
except (OSError, ValueError, IndexError):
pass
modules = dmi.get("memory", [])
if modules:
items.append(("Modules", str(len(modules))))
for i, m in enumerate(modules):
desc = " · ".join(p for p in (m.get("Size"), m.get("Type"), m.get("Speed"), m.get("Part Number")) if p)
items.append((f"Slot {i}", desc))
elif shutil.which("dmidecode"):
items.append(("Modules", "run with admin for module details"))
return Section("Memory", items)
def _gpu() -> Section:
if shutil.which("nvidia-smi"):
out = _run([
"nvidia-smi",
"--query-gpu=name,driver_version,vbios_version,memory.total,pcie.link.gen.max,pcie.link.width.max",
"--format=csv,noheader",
])
line = out.strip().splitlines()[0] if out.strip() else ""
if line:
cols = [c.strip() for c in line.split(",")]
keys = ["Name", "Driver", "VBIOS", "VRAM", "PCIe gen (max)", "PCIe width (max)"]
return Section("GPU", list(zip(keys, cols)))
out = _run(["lspci"])
gpus = [ln.split(":", 2)[-1].strip() for ln in out.splitlines()
if "VGA compatible controller" in ln or "3D controller" in ln]
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")])
def _storage() -> Section:
items: list[tuple[str, str]] = []
# TYPE first so MODEL (which can contain spaces) is the trailing field.
out = _run(["lsblk", "-dn", "-o", "TYPE,NAME,SIZE,MODEL"])
for line in out.strip().splitlines():
parts = line.split(None, 3)
if len(parts) < 3 or parts[0] != "disk": # skip loop/zram/rom devices
continue
name, size = parts[1], parts[2]
model = parts[3] if len(parts) > 3 else ""
items.append((name, f"{model} ({size})".strip()))
return Section("Storage", items or [("Disks", "unknown")])
def _display() -> Section:
return Section("Display", [
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
])
def _dmidecode() -> dict:
if not shutil.which("dmidecode"):
return {}
out = _run(["dmidecode", "-t", "baseboard", "-t", "bios", "-t", "memory"], timeout=15)
if not out.strip():
return {}
result: dict = {"baseboard": {}, "bios": {}, "memory": []}
for block in out.split("Handle "):
lines = block.splitlines()
if len(lines) < 2:
continue
title = lines[1].strip()
kv: dict[str, str] = {}
for ln in lines[2:]:
if ln.startswith("\t") and ":" in ln:
key, _, value = ln.strip().partition(":")
kv[key.strip()] = value.strip()
if title == "Base Board Information":
result["baseboard"] = kv
elif title == "BIOS Information":
result["bios"] = kv
elif title == "Memory Device" and kv.get("Size") and kv["Size"] != "No Module Installed":
result["memory"].append(kv)
return result
def collect() -> list[Section]:
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()]
def to_dict(sections: list[Section]) -> dict:
return {s.title: dict(s.items) for s in sections}
def from_dict(data: dict) -> list[Section]:
return [Section(title, list(items.items())) for title, items in data.items()]
def render_markdown(sections: list[Section]) -> str:
out = ["# RigDoctor system inventory", ""]
for s in sections:
out.append(f"## {s.title}")
out += [f"- **{k}:** {v}" for k, v in s.items]
out.append("")
return "\n".join(out).strip() + "\n"
def render_text(sections: list[Section]) -> str:
blocks = []
for s in sections:
blocks.append("\n".join([s.title] + [f" {k:<18} {v}" for k, v in s.items]))
return "\n\n".join(blocks)
def render_json(sections: list[Section]) -> str:
return json.dumps(to_dict(sections), indent=2, ensure_ascii=False)
+42
View File
@@ -0,0 +1,42 @@
"""Uninstall the user-local RigDoctor install (app files; optionally all data).
Mirrors `install.sh --uninstall`. The removal runs in a detached shell so it can
delete the venv the current process is running from once we exit.
"""
from __future__ import annotations
import shlex
import subprocess
from pathlib import Path
from .. import config
from . import reccontrol
def targets(purge: bool = False) -> list[Path]:
"""Paths removed by an uninstall. With purge, also config/state/logs."""
home = Path.home()
share = config.DATA_DIR.parent # ~/.local/share
items = [
config.DATA_DIR / "venv",
home / ".local" / "bin" / "rigdoctor",
home / ".local" / "bin" / "rigdoctor-gui",
share / "applications" / "rigdoctor.desktop",
share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg",
]
if purge:
items += [config.CONFIG_DIR, config.STATE_DIR, config.DATA_DIR]
return items
def uninstall(purge: bool = False) -> None:
"""Stop the recorder, clear the token if purging, and remove the install."""
reccontrol.stop_background()
if purge:
config.clear_token() # removes keyring entry + any file fallback
paths = " ".join(shlex.quote(str(p)) for p in targets(purge))
subprocess.Popen(
["/bin/sh", "-c", f"sleep 1; rm -rf {paths}"],
start_new_session=True,
)
+39 -17
View File
@@ -1,9 +1,9 @@
"""Update check (M13): ask the Gitea releases API for the latest version. """Update check (M13): ask the Gitea releases API for the latest version + notes.
Stdlib-only (urllib). The Gitea instance requires sign-in, so updates are gated to Stdlib-only (urllib). The Gitea instance requires sign-in, so updates are gated to
account holders via a Personal Access Token (D18): set $RIGDOCTOR_TOKEN or save one account holders via a Personal Access Token (D18): set $RIGDOCTOR_TOKEN or save one
with `rigdoctor login`. Self-update (apply) is built on top of this; this module with `rigdoctor login`. Returns the latest tag, its release notes (body), and a clear
handles detection and exposes a clear state for the UI. state for the UI; `apply_update` performs the no-root self-update.
""" """
from __future__ import annotations from __future__ import annotations
@@ -42,11 +42,11 @@ def is_newer(latest: str, current: str = __version__) -> bool:
return False return False
def fetch_latest(timeout: float = 5.0) -> tuple[str | None, str | None]: def fetch_latest(timeout: float = 5.0) -> tuple[str | None, str, str | None]:
"""Return (tag, error). error is one of NO_TOKEN / AUTH / NETWORK, or None on success.""" """Return (tag, notes, error). error is NO_TOKEN/AUTH/NETWORK, or None on success."""
token = load_token() token = load_token()
if not token: if not token:
return (None, NO_TOKEN) return (None, "", NO_TOKEN)
req = urllib.request.Request( req = urllib.request.Request(
LATEST_API, LATEST_API,
headers={"Accept": "application/json", "Authorization": f"token {token}"}, headers={"Accept": "application/json", "Authorization": f"token {token}"},
@@ -54,27 +54,49 @@ def fetch_latest(timeout: float = 5.0) -> tuple[str | None, str | None]:
try: try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https)
data = json.load(resp) data = json.load(resp)
return (data.get("tag_name") or None, None) return (data.get("tag_name") or None, (data.get("body") or "").strip(), None)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return (None, AUTH if exc.code in (401, 403) else NETWORK) return (None, "", AUTH if exc.code in (401, 403) else NETWORK)
except Exception: except Exception:
return (None, NETWORK) return (None, "", NETWORK)
def check_latest(timeout: float = 5.0) -> str | None: def check_latest(timeout: float = 5.0) -> str | None:
"""Convenience: latest tag or None (ignores error reason).""" """Convenience: latest tag or None (ignores notes/error)."""
tag, _ = fetch_latest(timeout) tag, _notes, _error = fetch_latest(timeout)
return tag return tag
def update_state(timeout: float = 5.0) -> tuple[str, str | None]: def update_state(timeout: float = 5.0) -> tuple[str, str | None, str]:
"""Return (state, tag). state in NO_TOKEN/AUTH/NETWORK/UP_TO_DATE/AVAILABLE.""" """Return (state, tag, notes). state in NO_TOKEN/AUTH/NETWORK/UP_TO_DATE/AVAILABLE."""
tag, error = fetch_latest(timeout) tag, notes, error = fetch_latest(timeout)
if error: if error:
return (error, None) return (error, None, "")
if tag and is_newer(tag): if tag and is_newer(tag):
return (AVAILABLE, tag) return (AVAILABLE, tag, notes)
return (UP_TO_DATE, tag) return (UP_TO_DATE, tag, notes)
def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str, str, str]], str | None]:
"""Return ([(tag, date, notes), …], error) for the in-app changelog."""
token = load_token()
if not token:
return ([], NO_TOKEN)
req = urllib.request.Request(
f"{GITEA_BASE}/api/v1/repos/{REPO}/releases?limit={limit}",
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https)
data = json.load(resp)
return ([
(r.get("tag_name") or "?", (r.get("published_at") or "")[:10], (r.get("body") or "").strip())
for r in data
], None)
except urllib.error.HTTPError as exc:
return ([], AUTH if exc.code in (401, 403) else NETWORK)
except Exception:
return ([], NETWORK)
def apply_update(tag: str) -> tuple[int, str]: def apply_update(tag: str) -> tuple[int, str]:
+10
View File
@@ -3,18 +3,28 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ..config import load_config from ..config import load_config
from . import desktop
from .main_window import MainWindow from .main_window import MainWindow
from .theme import STYLESHEET from .theme import STYLESHEET
ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
desktop.ensure() # self-register icon + .desktop so updates show it without re-installing
app = QApplication(argv if argv is not None else sys.argv) app = QApplication(argv if argv is not None else sys.argv)
app.setApplicationName("RigDoctor") app.setApplicationName("RigDoctor")
app.setApplicationDisplayName("RigDoctor") app.setApplicationDisplayName("RigDoctor")
# Match the installed rigdoctor.desktop so the dock/launcher shows our icon (Wayland app-id).
app.setDesktopFileName("rigdoctor")
if ICON.exists():
app.setWindowIcon(QIcon(str(ICON)))
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect x="8" y="8" width="240" height="240" rx="52" fill="#15181e"/>
<circle cx="128" cy="128" r="84" fill="none" stroke="#2a2f39" stroke-width="14"/>
<path d="M128 44 a84 84 0 1 1 -59.4 24.6" fill="none" stroke="#38bdf8"
stroke-width="14" stroke-linecap="round"/>
<path d="M60 132 H100 L116 96 L140 168 L156 132 H196" fill="none" stroke="#e6e8eb"
stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

+51
View File
@@ -0,0 +1,51 @@
"""Best-effort desktop integration: install our icon + .desktop so the dock shows it.
Runs at GUI launch (idempotent), so a self-update + relaunch refreshes the icon without
re-running install.sh. No-op for non-installed (dev) runs where the launcher is absent.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from .. import config
_ICON_SRC = Path(__file__).parent / "assets" / "rigdoctor.svg"
_DESKTOP = """[Desktop Entry]
Type=Application
Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec={exec}
Icon=rigdoctor
Terminal=false
Categories=System;Monitor;Utility;
StartupWMClass=rigdoctor
"""
def ensure() -> None:
share = config.DATA_DIR.parent # ~/.local/share
try:
if _ICON_SRC.exists():
icon_dst = share / "icons" / "hicolor" / "scalable" / "apps" / "rigdoctor.svg"
icon_dst.parent.mkdir(parents=True, exist_ok=True)
if not icon_dst.exists() or icon_dst.read_bytes() != _ICON_SRC.read_bytes():
shutil.copyfile(_ICON_SRC, icon_dst)
except OSError:
pass
gui_exec = Path(sys.executable).with_name("rigdoctor-gui")
if not gui_exec.exists(): # dev / not a normal install — don't fabricate a .desktop
return
try:
desktop = share / "applications" / "rigdoctor.desktop"
content = _DESKTOP.format(exec=gui_exec)
desktop.parent.mkdir(parents=True, exist_ok=True)
if not desktop.exists() or desktop.read_text() != content:
desktop.write_text(content)
except OSError:
pass
+5 -1
View File
@@ -107,6 +107,11 @@ class HealthPage(QWidget):
self._result.emit(findings) self._result.emit(findings)
def _render_findings(self, findings) -> None: def _render_findings(self, findings) -> None:
self._run_btn.setEnabled(True)
if findings is None: # collection failed — keep current results
self._status.setText("check failed")
return
while self._list.count(): while self._list.count():
item = self._list.takeAt(0) item = self._list.takeAt(0)
w = item.widget() w = item.widget()
@@ -122,4 +127,3 @@ class HealthPage(QWidget):
for finding in findings: for finding in findings:
self._list.addWidget(_finding_widget(finding)) self._list.addWidget(_finding_widget(finding))
self._list.addStretch(1) self._list.addStretch(1)
self._run_btn.setEnabled(True)
+145
View File
@@ -0,0 +1,145 @@
"""Inventory page (M5 in the GUI): system inventory with copy/save + admin re-collect."""
from __future__ import annotations
import os
import threading
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..core import inventory
from .theme import MUTED
def _section_card(section) -> QFrame:
card = QFrame()
card.setObjectName("Card")
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(6)
title = QLabel(section.title)
title.setStyleSheet("font-weight: 700; background: transparent;")
layout.addWidget(title)
grid = QGridLayout()
grid.setColumnStretch(1, 1)
grid.setHorizontalSpacing(14)
grid.setVerticalSpacing(4)
for row, (key, value) in enumerate(section.items):
k = QLabel(key)
k.setObjectName("Muted")
v = QLabel(value)
v.setWordWrap(True)
v.setStyleSheet("background: transparent;")
grid.addWidget(k, row, 0)
grid.addWidget(v, row, 1)
layout.addLayout(grid)
return card
class InventoryPage(QWidget):
_result = Signal(object) # list[Section]
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._sections: list = []
self._result.connect(self._render)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
header = QHBoxLayout()
title = QLabel("Inventory")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
self._status = QLabel("")
self._status.setObjectName("Muted")
header.addWidget(self._status)
self._copy_btn = QPushButton("Copy Markdown")
self._copy_btn.clicked.connect(self._copy)
header.addWidget(self._copy_btn)
self._save_btn = QPushButton("Save…")
self._save_btn.clicked.connect(self._save)
header.addWidget(self._save_btn)
self._refresh_btn = QPushButton("Refresh")
self._refresh_btn.setObjectName("PrimaryButton")
self._refresh_btn.clicked.connect(self._run)
header.addWidget(self._refresh_btn)
root.addLayout(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
self._container = QWidget()
self._list = QVBoxLayout(self._container)
self._list.setContentsMargins(0, 0, 0, 0)
self._list.setSpacing(12)
self._list.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(self._container)
root.addWidget(scroll, 1)
QTimer.singleShot(300, self._run)
def _run(self) -> None:
self._busy("Collecting…")
threading.Thread(target=self._work, daemon=True).start()
def _work(self) -> None:
try:
sections = inventory.collect()
except Exception:
sections = []
self._result.emit(sections)
def _busy(self, text: str) -> None:
self._status.setText(text)
for b in (self._refresh_btn, self._copy_btn, self._save_btn):
b.setEnabled(False)
def _render(self, sections) -> None:
self._refresh_btn.setEnabled(True)
self._copy_btn.setEnabled(True)
self._save_btn.setEnabled(True)
if sections is None: # collection failed — keep current
self._status.setText("collection failed")
return
self._sections = sections
while self._list.count():
item = self._list.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
for section in sections:
self._list.addWidget(_section_card(section))
self._list.addStretch(1)
self._status.setText("")
def _copy(self) -> None:
if self._sections:
QApplication.clipboard().setText(inventory.render_markdown(self._sections))
self._status.setText("copied as Markdown")
def _save(self) -> None:
if not self._sections:
return
path, _ = QFileDialog.getSaveFileName(self, "Save inventory", "rigdoctor-inventory.md", "Markdown (*.md)")
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(inventory.render_markdown(self._sections))
self._status.setText(f"saved {os.path.basename(path)}")
+156 -35
View File
@@ -2,44 +2,54 @@
from __future__ import annotations from __future__ import annotations
import os
import sys
import threading import threading
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, QProcess, QTimer, Signal
from PySide6.QtGui import QTextDocument
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QButtonGroup, QButtonGroup,
QDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QPushButton, QPushButton,
QStackedWidget, QStackedWidget,
QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import __version__ from .. import __version__
from ..core import updates from ..config import load_config
from ..core import alerts, 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 .notifications_page import NotificationsPage
from .recorder_page import RecorderPage from .recorder_page import RecorderPage
from .setup_page import SetupPage from .setup_page import SetupPage
from .theme import ACCENT, GOOD, MUTED from .theme import ACCENT, GOOD, MUTED
from .worker import SamplerWorker from .worker import SamplerWorker
_NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory"] _NAV_ITEMS = ["Dashboard", "Logs", "Health", "Setup", "Inventory", "Notifications"]
_PLACEHOLDERS = {
"Inventory": "System inventory (M5) — CPU/GPU/board/RAM/drivers — lands here.",
}
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
_update_checked = Signal(object) # (state, tag) _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)
_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)
@@ -57,11 +67,15 @@ class MainWindow(QMainWindow):
self.recorder_page = RecorderPage() self.recorder_page = RecorderPage()
self.health_page = HealthPage() self.health_page = HealthPage()
self.setup_page = SetupPage() self.setup_page = SetupPage()
self.inventory_page = InventoryPage()
self.notifications_page = NotificationsPage()
self.notifications_page.changed.connect(self._apply_alert_settings)
self._stack.addWidget(self.dashboard) # 0 Dashboard self._stack.addWidget(self.dashboard) # 0 Dashboard
self._stack.addWidget(self.recorder_page) # 1 Logs self._stack.addWidget(self.recorder_page) # 1 Logs
self._stack.addWidget(self.health_page) # 2 Health self._stack.addWidget(self.health_page) # 2 Health
self._stack.addWidget(self.setup_page) # 3 Setup self._stack.addWidget(self.setup_page) # 3 Setup
self._stack.addWidget(self._placeholder_page("Inventory", _PLACEHOLDERS["Inventory"])) # 4 self._stack.addWidget(self.inventory_page) # 4 Inventory
self._stack.addWidget(self.notifications_page) # 5 Notifications
content_layout.addWidget(self._stack) content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar()) layout.addWidget(self._build_sidebar())
@@ -69,13 +83,39 @@ class MainWindow(QMainWindow):
self._worker = SamplerWorker(interval=interval) self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
# Desktop alerts (M8): overheat / GPU-lost from the sample stream, new-version below.
# Configurable on the Notifications page; gated by AlertMonitor.enabled.
self._notified_update_tag = None
self._alert_monitor = alerts.AlertMonitor(
gpu_temp=float(cfg.get("gpu_temp_alert", 90.0)),
cpu_temp=float(cfg.get("cpu_temp_alert", 95.0)),
)
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
self._worker.sampled.connect(self._alert_monitor.check)
self._worker.start() self._worker.start()
# Background update check (M13); result lands in the sidebar. # 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
# release is detected without restarting (interval from config; 0 disables).
self._latest_tag = None self._latest_tag = None
self._latest_notes = ""
self._applied = False
self._update_checked.connect(self._show_update_state) self._update_checked.connect(self._show_update_state)
self._update_applied.connect(self._on_update_applied) self._update_applied.connect(self._on_update_applied)
threading.Thread(target=self._check_updates, daemon=True).start() self._changelog_ready.connect(self._on_changelog)
self._start_update_check()
minutes = float(cfg.get("update_check_minutes", 30) or 0)
if minutes > 0:
self._update_timer = QTimer(self)
self._update_timer.setInterval(int(minutes * 60_000))
self._update_timer.timeout.connect(self._start_update_check)
self._update_timer.start()
def _build_sidebar(self) -> QFrame: def _build_sidebar(self) -> QFrame:
bar = QFrame() bar = QFrame()
@@ -111,6 +151,16 @@ class MainWindow(QMainWindow):
version = QLabel(f"v{__version__}") version = QLabel(f"v{__version__}")
version.setObjectName("Muted") version.setObjectName("Muted")
v.addWidget(version) v.addWidget(version)
changelog_btn = QPushButton("Changelog")
changelog_btn.setObjectName("LinkButton")
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
changelog_btn.clicked.connect(self._show_changelog)
v.addWidget(changelog_btn)
check_btn = QPushButton("Check for updates")
check_btn.setObjectName("LinkButton")
check_btn.setCursor(Qt.CursorShape.PointingHandCursor)
check_btn.clicked.connect(self._manual_check)
v.addWidget(check_btn)
# Update state (filled in by the background check). # Update state (filled in by the background check).
self._update_label = QLabel("checking for updates…") self._update_label = QLabel("checking for updates…")
@@ -122,11 +172,35 @@ class MainWindow(QMainWindow):
self._update_btn.clicked.connect(self._apply_update) self._update_btn.clicked.connect(self._apply_update)
self._update_btn.setVisible(False) self._update_btn.setVisible(False)
v.addWidget(self._update_btn) v.addWidget(self._update_btn)
self._restart_btn = QPushButton("Restart now")
self._restart_btn.setObjectName("PrimaryButton")
self._restart_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._restart_btn.clicked.connect(self._restart)
self._restart_btn.setVisible(False)
v.addWidget(self._restart_btn)
return bar return bar
def _restart(self) -> None:
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
if os.path.exists(gui):
QProcess.startDetached(gui)
else: # dev / not installed next to python
QProcess.startDetached(sys.executable, sys.argv)
QApplication.instance().quit()
def _apply_update(self) -> None: def _apply_update(self) -> None:
if not self._latest_tag: if not self._latest_tag:
return return
box = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor to {self._latest_tag}?")
notes_doc = QTextDocument()
notes_doc.setMarkdown(self._latest_notes or "_(no release notes)_")
box.setInformativeText(notes_doc.toHtml()) # render Markdown as rich text (#1)
box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
box.button(QMessageBox.StandardButton.Ok).setText("Update")
if box.exec() != QMessageBox.StandardButton.Ok:
return
self._update_btn.setEnabled(False) self._update_btn.setEnabled(False)
self._update_label.setText("updating…") self._update_label.setText("updating…")
tag = self._latest_tag tag = self._latest_tag
@@ -134,18 +208,85 @@ class MainWindow(QMainWindow):
def _on_update_applied(self, rc: int) -> None: def _on_update_applied(self, rc: int) -> None:
if rc == 0: if rc == 0:
self._update_label.setText("updated — restart RigDoctor") self._applied = True
self._update_label.setText("update installed")
self._update_btn.setVisible(False) self._update_btn.setVisible(False)
self._restart_btn.setVisible(True)
if hasattr(self, "_update_timer"):
self._update_timer.stop()
else: else:
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 _apply_alert_settings(self) -> None:
cfg = load_config()
self._alert_monitor.enabled = bool(cfg.get("alerts_enabled", True))
self._alert_monitor.gpu_temp = float(cfg.get("gpu_temp_alert", 90.0))
self._alert_monitor.cpu_temp = float(cfg.get("cpu_temp_alert", 95.0))
def _manual_check(self) -> None:
if self._applied:
return
self._update_label.setText("checking for updates…")
self._start_update_check()
def _start_update_check(self) -> None:
threading.Thread(target=self._check_updates, daemon=True).start()
def _show_changelog(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("RigDoctor — Changelog")
dialog.resize(560, 540)
layout = QVBoxLayout(dialog)
view = QTextEdit()
view.setObjectName("Report")
view.setReadOnly(True)
view.setPlainText("Loading…")
layout.addWidget(view)
self._changelog_view = view
dialog.show()
threading.Thread(target=self._fetch_changelog, daemon=True).start()
def _fetch_changelog(self) -> None:
self._changelog_ready.emit(updates.list_releases())
def _on_changelog(self, result) -> None:
view = getattr(self, "_changelog_view", None)
if view is None:
return
releases, error = result
if error == updates.NO_TOKEN:
view.setPlainText("Add an update token (Setup → Update access) to load the changelog.")
return
if error or not releases:
view.setPlainText("Couldn't load the changelog from the update server.")
return
blocks = []
for tag, date, notes in releases:
title = f"## {tag}" + (f"{date}" if date else "")
blocks.append(f"{title}\n\n{notes or '_(no notes)_'}")
view.setMarkdown("\n\n".join(blocks)) # render Markdown instead of raw text (#1)
def _check_updates(self) -> None: def _check_updates(self) -> None:
self._update_checked.emit(updates.update_state()) self._update_checked.emit(updates.update_state())
def _show_update_state(self, result) -> None: def _show_update_state(self, result) -> None:
state, tag = result if self._applied: # an update was applied this session; awaiting restart
return
state, tag, notes = result
self._latest_tag = tag self._latest_tag = tag
self._latest_notes = notes
self._update_btn.setVisible(False) self._update_btn.setVisible(False)
if state == updates.NO_TOKEN: if state == updates.NO_TOKEN:
self._update_label.setText("connect to update server") self._update_label.setText("connect to update server")
@@ -157,32 +298,12 @@ class MainWindow(QMainWindow):
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>') 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}")
self._update_btn.setVisible(True) 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
alerts.notify("Update available", f"RigDoctor {tag} is available — open RigDoctor to update.")
else: # UP_TO_DATE else: # UP_TO_DATE
self._update_label.setText("up-to-date") self._update_label.setText("up-to-date")
def _placeholder_page(self, title: str, description: str) -> QWidget:
page = QWidget()
page.setObjectName("Page")
v = QVBoxLayout(page)
v.setContentsMargins(20, 18, 20, 18)
v.setSpacing(16)
head = QLabel(title)
head.setObjectName("PageTitle")
v.addWidget(head)
card = QFrame()
card.setObjectName("Card")
cv = QVBoxLayout(card)
cv.setContentsMargins(24, 48, 24, 48)
msg = QLabel(description)
msg.setObjectName("Muted")
msg.setWordWrap(True)
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
cv.addWidget(msg)
v.addWidget(card)
v.addStretch(1)
return page
def closeEvent(self, event) -> None: # noqa: N802 (Qt override) def closeEvent(self, event) -> None: # noqa: N802 (Qt override)
self._worker.stop() self._worker.stop()
super().closeEvent(event) super().closeEvent(event)
+108
View File
@@ -0,0 +1,108 @@
"""Notifications page (M8 config): user-configurable alert settings."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..config import load_config, update_config
from ..core import alerts
class NotificationsPage(QWidget):
changed = Signal() # settings saved — main window re-applies them live
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
title = QLabel("Notifications")
title.setObjectName("PageTitle")
root.addWidget(title)
card = QFrame()
card.setObjectName("Card")
v = QVBoxLayout(card)
v.setContentsMargins(16, 14, 16, 14)
v.setSpacing(10)
head = QLabel("Alerts")
head.setStyleSheet("font-weight: 700; background: transparent;")
v.addWidget(head)
self._enabled = QCheckBox("Enable desktop notifications")
v.addWidget(self._enabled)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setColumnStretch(2, 1)
self._gpu = self._spin()
self._cpu = self._spin()
grid.addWidget(QLabel("GPU temperature alert"), 0, 0)
grid.addWidget(self._gpu, 0, 1)
grid.addWidget(QLabel("CPU temperature alert"), 1, 0)
grid.addWidget(self._cpu, 1, 1)
v.addLayout(grid)
note = QLabel("GPU-lost and new-version alerts are included whenever notifications are enabled.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
buttons = QHBoxLayout()
save = QPushButton("Save")
save.setObjectName("PrimaryButton")
save.clicked.connect(self._save)
test = QPushButton("Send test")
test.clicked.connect(self._test)
buttons.addWidget(save)
buttons.addWidget(test)
buttons.addStretch(1)
v.addLayout(buttons)
self._status = QLabel("")
self._status.setObjectName("Muted")
v.addWidget(self._status)
root.addWidget(card)
root.addStretch(1)
self._load()
@staticmethod
def _spin() -> QDoubleSpinBox:
spin = QDoubleSpinBox()
spin.setRange(40, 110)
spin.setDecimals(0)
spin.setSingleStep(1)
spin.setSuffix(" °C")
return spin
def _load(self) -> None:
cfg = load_config()
self._enabled.setChecked(bool(cfg.get("alerts_enabled", True)))
self._gpu.setValue(float(cfg.get("gpu_temp_alert", 90.0)))
self._cpu.setValue(float(cfg.get("cpu_temp_alert", 95.0)))
def _save(self) -> None:
update_config(
alerts_enabled=self._enabled.isChecked(),
gpu_temp_alert=self._gpu.value(),
cpu_temp_alert=self._cpu.value(),
)
self.changed.emit()
self._status.setText("Saved.")
def _test(self) -> None:
ok = alerts.notify("RigDoctor", "Test notification — alerts are working.")
self._status.setText("Test notification sent." if ok else "notify-send not found — install libnotify-bin (Setup).")
+36 -2
View File
@@ -7,10 +7,12 @@ import threading
from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QTextEdit, QTextEdit,
@@ -19,7 +21,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import installer, sysenv, updates from ..core import installer, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -113,9 +115,41 @@ class SetupPage(QWidget):
root.addWidget(self._output) root.addWidget(self._output)
root.addStretch(1) root.addStretch(1)
danger = QHBoxLayout()
danger.addStretch(1)
uninstall_btn = QPushButton("Uninstall RigDoctor")
uninstall_btn.setObjectName("DangerButton")
uninstall_btn.clicked.connect(self._uninstall)
danger.addWidget(uninstall_btn)
root.addLayout(danger)
self._refresh() self._refresh()
self._refresh_update_status() self._refresh_update_status()
def _uninstall(self) -> None:
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
box.setWindowTitle("Uninstall RigDoctor")
box.setText("Uninstall RigDoctor?")
box.setInformativeText(
"This removes the app. Choose “Remove all” to also delete your settings, "
"update token, and captured logs."
)
remove_all = box.addButton("Remove all", QMessageBox.ButtonRole.DestructiveRole)
app_only = box.addButton("Uninstall", QMessageBox.ButtonRole.AcceptRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
clicked = box.clickedButton()
if clicked is remove_all:
purge = True
elif clicked is app_only:
purge = False
else:
return
uninstall.uninstall(purge=purge)
QMessageBox.information(self, "RigDoctor", "Uninstalling… RigDoctor will close now.")
QApplication.instance().quit()
def _refresh(self) -> None: def _refresh(self) -> None:
self._env.setText( self._env.setText(
f"Distro: {sysenv.distro_name()} " f"Distro: {sysenv.distro_name()} "
@@ -185,7 +219,7 @@ class SetupPage(QWidget):
self._upd_state.emit((config.token_backend(), updates.update_state())) self._upd_state.emit((config.token_backend(), updates.update_state()))
def _on_upd_state(self, result) -> None: def _on_upd_state(self, result) -> None:
backend, (state, tag) = result backend, (state, tag, _notes) = result
msg = { msg = {
updates.NO_TOKEN: "paste a token below to enable updates", updates.NO_TOKEN: "paste a token below to enable updates",
updates.AUTH: "token rejected — check its scope/permissions", updates.AUTH: "token rejected — check its scope/permissions",
+17
View File
@@ -107,4 +107,21 @@ QDoubleSpinBox, QSpinBox {{
QTextEdit#Report {{ QTextEdit#Report {{
background: #0d0f13; color: #cfd3da; border: 1px solid {CARD_BORDER}; border-radius: 8px; background: #0d0f13; color: #cfd3da; border: 1px solid {CARD_BORDER}; border-radius: 8px;
}} }}
QPushButton#DangerButton {{
background: transparent; color: {CRIT}; border: 1px solid {CRIT};
border-radius: 8px; padding: 7px 14px;
}}
QPushButton#DangerButton:hover {{ background: {CRIT}; color: #1a0d0d; }}
QPushButton#LinkButton {{
background: transparent; border: none; color: {MUTED};
text-align: left; padding: 0; text-decoration: underline;
}}
QPushButton#LinkButton:hover {{ color: {TEXT}; }}
/* Dialogs (update prompt, changelog) — match the dark theme so text is readable. */
QDialog {{ background: {BG}; }}
QMessageBox {{ background: {CARD}; }}
QDialog QLabel, QMessageBox QLabel {{ color: {TEXT}; background: transparent; }}
""" """
+38
View File
@@ -0,0 +1,38 @@
"""Tests for the M8 alert monitor (edge-triggered; notify mocked)."""
import unittest
from unittest import mock
from rigdoctor.core import alerts
from rigdoctor.core.sample import Reading, Sample
def _gpu(temp):
return Sample(readings=[Reading("gpu", "temp", temp, "°C")])
class AlertTests(unittest.TestCase):
@mock.patch.object(alerts, "notify")
def test_edge_triggered_no_repeat(self, m):
mon = alerts.AlertMonitor(gpu_temp=90.0, cooldown=0.0)
mon.check(_gpu(95)) # fires
mon.check(_gpu(96)) # still hot — no repeat while active
self.assertEqual(m.call_count, 1)
mon.check(_gpu(50)) # clears
mon.check(_gpu(95)) # hot again — fires
self.assertEqual(m.call_count, 2)
@mock.patch.object(alerts, "notify")
def test_no_alert_below_threshold(self, m):
alerts.AlertMonitor(gpu_temp=90.0).check(_gpu(70))
m.assert_not_called()
@mock.patch.object(alerts, "notify")
def test_gpu_lost(self, m):
mon = alerts.AlertMonitor()
mon.check(Sample(readings=[Reading("gpu", "status", None, "", "query-timeout")]))
m.assert_called_once()
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -0,0 +1,32 @@
"""Tests for config save/load (flat TOML writer)."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor import config
class ConfigTests(unittest.TestCase):
def test_save_load_round_trip(self):
with tempfile.TemporaryDirectory() as d:
cf = Path(d) / "config.toml"
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
config.save_config({"alerts_enabled": False, "gpu_temp_alert": 88.0, "update_check_minutes": 5})
loaded = config.load_config()
self.assertIs(loaded["alerts_enabled"], False)
self.assertEqual(loaded["gpu_temp_alert"], 88.0)
self.assertEqual(loaded["update_check_minutes"], 5)
def test_update_config_merges_and_keeps_defaults(self):
with tempfile.TemporaryDirectory() as d:
cf = Path(d) / "config.toml"
with mock.patch.object(config, "CONFIG_FILE", cf), mock.patch.object(config, "CONFIG_DIR", Path(d)):
config.update_config(cpu_temp_alert=70.0)
self.assertEqual(config.load_config()["cpu_temp_alert"], 70.0)
self.assertEqual(config.load_config()["gpu_temp_alert"], 90.0) # default preserved
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
import unittest
from rigdoctor.core import inventory
from rigdoctor.core.inventory import Section
class InventoryTests(unittest.TestCase):
def test_collect_returns_sections(self):
sections = inventory.collect()
self.assertTrue(sections)
titles = {s.title for s in sections}
self.assertIn("System", titles)
self.assertIn("CPU", titles)
def test_dict_round_trip(self):
sections = [Section("System", [("Kernel", "7.0.0"), ("Distro", "Ubuntu")])]
restored = inventory.from_dict(inventory.to_dict(sections))
self.assertEqual(restored[0].title, "System")
self.assertEqual(restored[0].items, [("Kernel", "7.0.0"), ("Distro", "Ubuntu")])
def test_render_markdown(self):
md = inventory.render_markdown([Section("CPU", [("Model", "Test CPU")])])
self.assertIn("## CPU", md)
self.assertIn("- **Model:** Test CPU", md)
if __name__ == "__main__":
unittest.main()