Compare commits

...

4 Commits

Author SHA1 Message Date
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
18 changed files with 499 additions and 70 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
+37
View File
@@ -5,6 +5,43 @@ 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.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 ## [0.0.8] - 2026-05-21
### Added ### Added
- **Periodic update checks**: the GUI now re-checks for new releases while running (every - **Periodic update checks**: the GUI now re-checks for new releases while running (every
+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`).
+2 -1
View File
@@ -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,
+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())
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.0.8" version = "0.2.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"
+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.8" __version__ = "0.2.0"
+26 -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,23 @@ 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_report(args) -> int: def cmd_report(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -323,6 +342,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)
+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",
), ),
) )
+41
View File
@@ -0,0 +1,41 @@
"""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",
]
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]:
+87 -4
View File
@@ -2,17 +2,23 @@
from __future__ import annotations from __future__ import annotations
import os
import sys
import threading import threading
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QProcess, QTimer, Signal
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,
) )
@@ -34,8 +40,9 @@ _PLACEHOLDERS = {
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)
def __init__(self, interval: float = 1.0) -> None: def __init__(self, interval: float = 1.0) -> None:
super().__init__() super().__init__()
@@ -75,9 +82,11 @@ class MainWindow(QMainWindow):
# Update check (M13): once at launch, then periodically so a newly published # Update check (M13): once at launch, then periodically so a newly published
# release is detected without restarting (interval from config; 0 disables). # release is detected without restarting (interval from config; 0 disables).
self._latest_tag = None self._latest_tag = None
self._latest_notes = ""
self._applied = False 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)
self._changelog_ready.connect(self._on_changelog)
self._start_update_check() self._start_update_check()
minutes = float(load_config().get("update_check_minutes", 30) or 0) minutes = float(load_config().get("update_check_minutes", 30) or 0)
if minutes > 0: if minutes > 0:
@@ -120,6 +129,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…")
@@ -131,11 +150,33 @@ 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}?")
box.setInformativeText(self._latest_notes or "(no release notes)")
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
@@ -144,25 +185,67 @@ 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._applied = True self._applied = True
self._update_label.setText("updated — restart RigDoctor") 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"): if hasattr(self, "_update_timer"):
self._update_timer.stop() 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 _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: def _start_update_check(self) -> None:
threading.Thread(target=self._check_updates, daemon=True).start() 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:
head = tag + (f" ({date})" if date else "")
blocks.append(f"{head}\n{'' * len(head)}\n{notes or '(no notes)'}")
view.setPlainText("\n\n".join(blocks))
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:
if self._applied: # an update was applied this session; awaiting restart if self._applied: # an update was applied this session; awaiting restart
return return
state, tag = result 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")
+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; }}
""" """