Compare commits

..

4 Commits

Author SHA1 Message Date
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
jessey 46ba53631a Release 0.0.7: user-local installer + self-update apply
release / release (push) Successful in 22s
- install.sh: no-root user-local install (private venv + ~/.local/bin launchers
  + desktop entry); --ref <tag> to install a specific release, --uninstall to
  remove; auto-installs the python3-venv prerequisite with consent
- packaging/make-run.sh: build a self-extracting .run installer (makeself)
  bundling the wheel + install.sh; release workflow builds and attaches it
- M13 self-update apply: `rigdoctor update` runs an authenticated pip upgrade
  (rigdoctor[gui] @ git+https://oauth2:<token>@...@<tag>), token scrubbed; GUI
  sidebar "Update to v…" button applies it and prompts to restart
- version 0.0.7, CHANGELOG, docs (M9/M13, ROADMAP, README install section)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:12:53 +02:00
jessey 4e3f6aa94e Release 0.0.6: token-gated updates (M13) with encrypted storage
release / release (push) Successful in 13s
- updates gated to Gitea account holders via a Personal Access Token (D18
  revised: anonymous HTTP -> authenticated HTTP, since the instance requires
  sign-in for all anonymous access)
- token stored encrypted in the OS keyring (secret-tool) when available, with
  a 0600-file fallback; $RIGDOCTOR_TOKEN override; auto-migrate file->keyring
  once libsecret-tools is installed
- core/updates: token-aware fetch_latest + update_state (no-token/auth/network/
  up-to-date/available)
- CLI: rigdoctor login / logout / update [--check]
- GUI: Setup "Update access" panel (token field, get-a-token, backend status);
  sidebar update states; libsecret-tools added to the installer catalog
- token storage tests (file fallback + env override, keyring mocked)
- version 0.0.6, CHANGELOG, docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:47:01 +02:00
21 changed files with 860 additions and 50 deletions
+28 -1
View File
@@ -27,12 +27,39 @@ jobs:
python -m pip install --upgrade build python -m pip install --upgrade build
python -m build python -m build
- name: Build self-extracting installer (.run)
run: python packaging/make_run.py
- name: Read version - name: Read version
id: ver id: ver
run: | run: |
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 }}
@@ -52,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
+50
View File
@@ -5,6 +5,56 @@ 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.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
### Added
- **User-local installer** `install.sh` (no root): creates a private venv, links
`rigdoctor`/`rigdoctor-gui` into `~/.local/bin`, and adds a desktop entry. Re-run to
upgrade; `--uninstall` to remove.
- **Self-extracting `.run` installer** via `packaging/make-run.sh` (makeself) — one
download-and-run executable bundling the wheel + `install.sh`; built and attached to each
release by CI.
- **Self-update apply (M13)**: `rigdoctor update` now installs the newer version via
authenticated pip (`rigdoctor[gui] @ git+https://oauth2:<token>@…@<tag>`); the GUI sidebar
"Update to v…" button applies it and prompts to restart. Token is scrubbed from output.
## [0.0.6] - 2026-05-21
### Added
- **Token-gated updates (M13)**: store a Gitea Personal Access Token, **encrypted in the OS
keyring** (Secret Service / GNOME Keyring via `secret-tool`) with a 0600-file fallback.
`rigdoctor login` / `logout` / `update [--check]`; GUI **Setup → Update access** panel
(token field, "Get a token", backend status) and sidebar states (connect / up-to-date /
"Update to v…" / access denied). Updates are gated to accounts on the Gitea server (D18).
- `libsecret-tools` added to the installer catalog (enables encrypted token storage).
### Changed
- D18 update mechanism revised from anonymous public HTTP to **authenticated HTTP (token)**
the Gitea instance requires sign-in for all anonymous access.
## [0.0.5] - 2026-05-21 ## [0.0.5] - 2026-05-21
### Added ### Added
- **M9 installer (first cut)**: detects distro / package manager / GPU; a catalog of optional - **M9 installer (first cut)**: detects distro / package manager / GPU; a catalog of optional
+15
View File
@@ -63,6 +63,21 @@ Full rationale and the still-open questions are in `docs/DECISIONS.md`.
| `installer/` | Installer / `.deb` packaging (empty until Phase 4) | | `installer/` | Installer / `.deb` packaging (empty until Phase 4) |
| `tests/` | Tests (stdlib `unittest`) | | `tests/` | Tests (stdlib `unittest`) |
## Install (user-local, no root)
RigDoctor installs into a private venv under `~/.local` — no root, self-updating:
```bash
./install.sh # from a source checkout or the self-extracting .run
./install.sh --ref v0.0.6 # install a specific released tag (needs a token)
./install.sh --uninstall # remove it
```
This adds `rigdoctor` / `rigdoctor-gui` to `~/.local/bin` and a desktop entry. Each release
also ships a one-file **`.run`** installer (download, `chmod +x`, run). Updates are gated to
accounts on the Git server (a Personal Access Token); save one via the GUI **Setup → Update
access** panel or `rigdoctor login`, then `rigdoctor update` (or the sidebar button).
## Run it (dev) ## Run it (dev)
Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14): Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14):
+37
View File
@@ -0,0 +1,37 @@
# 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"
+29 -4
View File
@@ -152,9 +152,22 @@ reachable from it. This **supersedes the earlier "CLI-first / terminal-first" fr
- *No change to layering (D2):* the core, CLI, and daemon stay **stdlib-only** and must run - *No change to layering (D2):* the core, CLI, and daemon stay **stdlib-only** and must run
without Qt. "GUI-first" is about emphasis and front-end parity, not dropping headless support. without Qt. "GUI-first" is about emphasis and front-end parity, not dropping headless support.
### D18 — Auto-update (M13) — *PLANNED 2026-05-21* ### D18 — Auto-update (M13) — *PLANNED 2026-05-21; mechanism revised 2026-05-21*
RigDoctor should **check for a newer version on launch and self-update** (new module **M13**). RigDoctor should **check for a newer version on launch and self-update** (new module **M13**).
**Mechanism (chosen): user-local, no-root self-update from the public repo.** **Mechanism (revised): user-local, no-root self-update over authenticated HTTP (token).**
*Why revised:* the Gitea instance requires sign-in for **all** anonymous access (repo page,
releases feed, raw, API all 303/403 anonymously), so the original "public HTTP" plan can't
work. Updates are therefore **gated to people with an account on the Gitea server**, which is
desirable — access control is delegated to Gitea.
- *Auth:* each user creates a **Personal Access Token** (scope `read:repository`); RigDoctor
stores it at `~/.config/rigdoctor/token` (mode 0600) or reads `RIGDOCTOR_TOKEN`. Requests
send `Authorization: token <PAT>`. Finer access = repo visibility/collaborators on Gitea.
- *Check:* `GET /api/v1/repos/jessey/rigdoctor/releases/latest` with the token; compare tags.
- *Apply:* `pip install --upgrade "git+https://oauth2:<token>@…/rigdoctor.git@<tag>"` into the
user-local venv, then restart (incl. the daemon). No root.
- *States surfaced:* no-token → "connect to update server"; auth error → "access denied";
newer → "Update to v…"; else "up-to-date".
- *Original (now-superseded) plan was anonymous public HTTP:*
- *Install model (D8 revised):* primary install is **user-local** (`~/.local`), so the running - *Install model (D8 revised):* primary install is **user-local** (`~/.local`), so the running
app can replace its own files and update with **no apt, no root, no password prompt**. app can replace its own files and update with **no apt, no root, no password prompt**.
- *Check:* on launch, query the **public Gitea releases API** - *Check:* on launch, query the **public Gitea releases API**
@@ -178,12 +191,24 @@ RigDoctor should **check for a newer version on launch and self-update** (new mo
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). *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.
## Open ## Open
None currently — all tracked decisions (D1D19) are resolved. New questions will be added None currently — all tracked decisions (D1D20) 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`).
+14 -5
View File
@@ -57,17 +57,26 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/ resolution; enables the logger service and trigger mode. *Implemented (first cut):* distro/
package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`), package-manager/GPU detection (`core/sysenv`), an optional-component catalog (`core/catalog`),
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.
*Pending:* writing config/module selection and enabling the `systemd --user` service. 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
**`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:*
config/module selection + `systemd --user`
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,
one-way), (2) **live read-only view** over a user-chosen tunnel (Tailscale/cloudflared/SSH, one-way), (2) **live read-only view** over a user-chosen tunnel (Tailscale/cloudflared/SSH,
no hosted relay), (3) **gated interactive terminal** wrapping tmate/sshx (read-only by no hosted relay), (3) **gated interactive terminal** wrapping tmate/sshx (read-only by
default; read-write only on explicit consent — a deliberate exception to D9). Per-session default; read-write only on explicit consent — a deliberate exception to D9). Per-session
consent, ephemeral revocable tokens, audit log. consent, ephemeral revocable tokens, audit log.
- **M13 Auto-update** (D18) — *check half implemented:* on GUI launch, `core/updates` queries - **M13 Auto-update** (D18) — *check + auth implemented:* updates are **gated to Gitea account
the Gitea releases API and the sidebar shows up-to-date / an "Update to v…" button / "update holders** via a Personal Access Token, stored **encrypted in the OS keyring** (`secret-tool`)
check unavailable" (the instance currently requires sign-in for anonymous API calls). The with a 0600-file fallback (`config.load_token`/`save_token`/`token_backend`). `core/updates`
no-root **self-update** (download → verify → atomic swap → restart) is still pending. queries the releases API with the token; CLI `login`/`logout`/`update`; GUI Setup "Update
access" panel + sidebar states. The no-root **self-update apply** is implemented:
`rigdoctor update` runs an authenticated `pip install --upgrade "rigdoctor[gui] @
git+https://oauth2:<token>@…@<tag>"` into the user-local venv (GUI "Update to v…" button +
restart prompt; token scrubbed). Installed via the user-local **`install.sh`** /
self-extracting **`.run`** (M9).
*Original plan:* On launch, check the public Gitea releases API and *Original plan:* On launch, check the public Gitea releases API and
**self-update a user-local install with no root** (download → verify checksum/signature → **self-update a user-local install with no root** (download → verify checksum/signature →
atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no
+6 -5
View File
@@ -40,16 +40,17 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
`rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher `rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher
(Steam RunningAppID + /proc) and GameMode hook follow) (Steam RunningAppID + /proc) and GameMode hook follow)
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install - [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
(`rigdoctor install`, GUI Setup tab). *Pending:* module-selection config + `systemd --user` (`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
service enable + trigger-mode pick. (no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
config + `systemd --user` service enable + trigger-mode pick.
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI - [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later) ## Phase 5 — Breadth (later)
- [ ] AMD GPU support in M1 (Steam Deck / Radeon) - [ ] AMD GPU support in M1 (Steam Deck / Radeon)
- [ ] Intel GPU best-effort - [ ] Intel GPU best-effort
- [~] M13 auto-update (D18) — *done:* launch-time version check shown in the GUI sidebar - [x] M13 auto-update (D18) — launch-time version check (GUI sidebar) + no-root self-update
(up-to-date / "Update to v…" / unavailable). *Pending:* no-root self-update of the apply (`rigdoctor update` / sidebar button → authenticated pip upgrade), token-gated.
user-local install from the public Gitea releases; `rigdoctor update`. Restart-after-update is manual for now.
- [ ] (Later, separate milestone) Optional auto-apply of suggested fixes behind explicit - [ ] (Later, separate milestone) Optional auto-apply of suggested fixes behind explicit
consent — currently out of scope (D9) consent — currently out of scope (D9)
Executable
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env sh
# RigDoctor user-local installer (no root). Creates a private venv, links the
# `rigdoctor` / `rigdoctor-gui` commands into ~/.local/bin, and adds a desktop
# entry. Installs from a bundled wheel (the .run installer) or from a source
# checkout. Re-run to upgrade; `./install.sh --uninstall` to remove.
set -eu
APP_NAME=rigdoctor
DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
VENV="$DATA_HOME/$APP_NAME/venv"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$DATA_HOME/applications"
DESKTOP_FILE="$DESKTOP_DIR/rigdoctor.desktop"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
uninstall() {
echo "Removing RigDoctor user-local install…"
rm -rf "$VENV"
rm -f "$BIN_DIR/rigdoctor" "$BIN_DIR/rigdoctor-gui" "$DESKTOP_FILE"
echo "Done. (Config and logs under ~/.config/rigdoctor and ~/.local/share/rigdoctor were kept.)"
}
REF=""
while [ $# -gt 0 ]; do
case "$1" in
--uninstall) uninstall; exit 0 ;;
--ref) REF="${2:-}"; [ -n "$REF" ] || { echo "--ref needs a tag"; exit 1; }; shift 2 ;;
-h|--help) echo "Usage: install.sh [--ref <tag>] [--uninstall]"; exit 0 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
PY=python3
command -v "$PY" >/dev/null 2>&1 || { echo "python3 not found — install Python 3.11+."; exit 1; }
"$PY" - <<'EOF' || { echo "Python 3.11+ is required."; exit 1; }
import sys
sys.exit(0 if sys.version_info >= (3, 11) else 1)
EOF
# venv support (ensurepip) is required; install python3-venv if it's missing.
if ! "$PY" -c "import ensurepip" >/dev/null 2>&1; then
PYVER=$("$PY" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
PKGS="python3-venv python${PYVER}-venv"
echo "Python venv support is missing — needs: $PKGS"
if command -v pkexec >/dev/null 2>&1; then ESC=pkexec
elif command -v sudo >/dev/null 2>&1; then ESC=sudo
else ESC=""; fi
if [ -n "$ESC" ] && command -v apt-get >/dev/null 2>&1; then
echo "Installing $PKGS (you may be prompted for your password)…"
"$ESC" sh -c "apt-get update && apt-get install -y $PKGS" \
|| { echo "Failed. Install manually: sudo apt install $PKGS"; exit 1; }
else
echo "Install it manually, then re-run: sudo apt install $PKGS"
exit 1
fi
fi
# Where to install from: a specific released tag (--ref), a bundled wheel, or source.
WHEEL=$(ls "$SCRIPT_DIR"/rigdoctor-*.whl 2>/dev/null | head -n1 || true)
if [ -n "$REF" ]; then
CONF="${XDG_CONFIG_HOME:-$HOME/.config}/rigdoctor/token"
TOKEN="${RIGDOCTOR_TOKEN:-$(cat "$CONF" 2>/dev/null || true)}"
[ -n "$TOKEN" ] || { echo "--ref needs a token (run 'rigdoctor login' or set RIGDOCTOR_TOKEN)."; exit 1; }
SRC="rigdoctor[gui] @ git+https://oauth2:$TOKEN@git.jesseyvanofferen.com/jessey/rigdoctor.git@$REF"
elif [ -n "$WHEEL" ]; then
SRC="$WHEEL[gui]"
elif [ -f "$SCRIPT_DIR/pyproject.toml" ]; then
SRC="$SCRIPT_DIR[gui]"
else
echo "No bundled wheel or source found next to the installer."
exit 1
fi
echo "Creating venv at $VENV"
"$PY" -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip >/dev/null
echo "Installing RigDoctor (pulls in PySide6 — this can take a minute)…"
"$VENV/bin/pip" install "$SRC"
mkdir -p "$BIN_DIR"
ln -sf "$VENV/bin/rigdoctor" "$BIN_DIR/rigdoctor"
ln -sf "$VENV/bin/rigdoctor-gui" "$BIN_DIR/rigdoctor-gui"
mkdir -p "$DESKTOP_DIR"
cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry]
Type=Application
Name=RigDoctor
Comment=Hardware monitoring & crash diagnostics for Linux gamers
Exec=$VENV/bin/rigdoctor-gui
Icon=utilities-system-monitor
Terminal=false
Categories=System;Monitor;Utility;
EOF
echo
echo "RigDoctor $("$VENV/bin/rigdoctor" --version 2>/dev/null | awk '{print $2}') installed."
echo " GUI: rigdoctor-gui (or find 'RigDoctor' in your app menu)"
echo " CLI: rigdoctor --help"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";;
esac
+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"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
# Build the self-extracting .run installer (delegates to make_run.py — no makeself).
exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/make_run.py" "$@"
+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.5" version = "0.0.10"
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.5" __version__ = "0.0.10"
+75
View File
@@ -212,6 +212,72 @@ def cmd_install(args) -> int:
return rc return rc
def cmd_login(args) -> int:
from getpass import getpass
from .core import updates
token = args.token
if not token:
print(f"Create a token (scope read:repository) at: {updates.TOKEN_PAGE}")
try:
token = getpass("Paste token: ").strip()
except (EOFError, KeyboardInterrupt):
token = ""
if not token:
print("No token provided.")
return 1
config.save_token(token)
state, tag, _notes = updates.update_state()
if state == updates.AUTH:
print("Token saved, but the server rejected it (check scope/permissions).")
return 1
if state in (updates.UP_TO_DATE, updates.AVAILABLE):
print(f"Token saved and verified. Latest release: {tag}.")
return 0
print("Token saved (couldn't reach the server to verify right now).")
return 0
def cmd_logout(args) -> int:
config.clear_token()
print("Update token removed.")
return 0
def cmd_update(args) -> int:
from .core import updates
state, tag, notes = updates.update_state()
if state == updates.NO_TOKEN:
print("No update token. Run `rigdoctor login` after creating one at:")
print(f" {updates.TOKEN_PAGE}")
return 1
if state == updates.AUTH:
print("The update server rejected your token (check scope/permissions).")
return 1
if state == updates.NETWORK:
print("Couldn't reach the update server.")
return 1
if state == updates.UP_TO_DATE:
print(f"Up to date (v{__version__}).")
return 0
# AVAILABLE
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:
return 0
print(f"Installing {tag}")
rc, out = updates.apply_update(tag)
print(out[-2000:])
if rc == 0:
print(f"\nUpdated to {tag}. Restart RigDoctor to use the new version.")
return 0
print(f"\nUpdate failed (exit {rc}).")
return rc
def cmd_report(args) -> int: def cmd_report(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -250,6 +316,15 @@ def build_parser() -> argparse.ArgumentParser:
inst.add_argument("-y", "--yes", action="store_true", help="install without confirmation") inst.add_argument("-y", "--yes", action="store_true", help="install without confirmation")
inst.set_defaults(func=cmd_install) inst.set_defaults(func=cmd_install)
login = sub.add_parser("login", help="save a Gitea token for updates (M13)")
login.add_argument("--token", default=None, help="token (prompted if omitted)")
login.set_defaults(func=cmd_login)
sub.add_parser("logout", help="remove the saved update token").set_defaults(func=cmd_logout)
upd = sub.add_parser("update", help="check for / apply a newer version (M13)")
upd.add_argument("--check", action="store_true", help="only report, don't apply")
upd.set_defaults(func=cmd_update)
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)
+109
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import subprocess
from pathlib import Path from pathlib import Path
APP = "rigdoctor" APP = "rigdoctor"
@@ -25,10 +27,117 @@ STATUS_FILE = STATE_DIR / "recorder.json"
PID_FILE = STATE_DIR / "recorder.pid" PID_FILE = STATE_DIR / "recorder.pid"
SPAWN_LOG = STATE_DIR / "recorder.out" SPAWN_LOG = STATE_DIR / "recorder.out"
# Update access token (M13) — gates updates to Gitea account holders (D18).
# Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when
# available — encrypted at rest, unlocked with the login session — else a 0600 file.
TOKEN_FILE = CONFIG_DIR / "token"
_SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"]
def _secret_tool() -> str | None:
return shutil.which("secret-tool")
def keyring_available() -> bool:
"""True if an encrypted OS keyring (secret-tool) is usable."""
return _secret_tool() is not None
def _keyring_store(token: str) -> bool:
tool = _secret_tool()
if not tool:
return False
try:
proc = subprocess.run(
[tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS],
input=token, text=True, capture_output=True, timeout=20,
)
return proc.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
def _keyring_lookup() -> str | None:
tool = _secret_tool()
if not tool:
return None
try:
proc = subprocess.run(
[tool, "lookup", *_SECRET_ATTRS], text=True, capture_output=True, timeout=20
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
except (subprocess.SubprocessError, OSError):
pass
return None
def _keyring_clear() -> None:
tool = _secret_tool()
if not tool:
return
try:
subprocess.run([tool, "clear", *_SECRET_ATTRS], capture_output=True, timeout=20)
except (subprocess.SubprocessError, OSError):
pass
def load_token() -> str | None:
"""Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file."""
env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip():
return env.strip()
from_keyring = _keyring_lookup()
if from_keyring:
return from_keyring
try:
token = TOKEN_FILE.read_text().strip()
return token or None
except OSError:
return None
def save_token(token: str) -> None:
"""Save to the OS keyring if possible (encrypted); else a 0600 file."""
token = token.strip()
if _keyring_store(token):
try: # don't leave a plaintext copy once it's in the keyring
TOKEN_FILE.unlink()
except OSError:
pass
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(token + "\n")
try:
TOKEN_FILE.chmod(0o600)
except OSError:
pass
def clear_token() -> None:
_keyring_clear()
try:
TOKEN_FILE.unlink()
except OSError:
pass
def token_backend() -> str:
"""Where the active token lives: 'env' | 'keyring' | 'file' | 'none'."""
env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip():
return "env"
if _keyring_lookup() is not None:
return "keyring"
if TOKEN_FILE.exists():
return "file"
return "none"
DEFAULTS: dict = { 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)
} }
+7 -3
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,6 +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(
"libsecret", "Encrypted token storage", "Updates",
"Store the update token in the OS keyring, encrypted", ("libsecret-tools",), "secret-tool",
), ),
) )
+67 -11
View File
@@ -1,21 +1,34 @@
"""Update check (M13, check half): 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). Self-update isn't built yet; this only *detects* a newer Stdlib-only (urllib). The Gitea instance requires sign-in, so updates are gated to
release. Any failure (network, or the instance requiring sign-in for the API) account holders via a Personal Access Token (D18): set $RIGDOCTOR_TOKEN or save one
returns None so callers can degrade gracefully. with `rigdoctor login`. Returns the latest tag, its release notes (body), and a clear
state for the UI; `apply_update` performs the no-root self-update.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import subprocess
import sys
import urllib.error
import urllib.request import urllib.request
from .. import __version__ from .. import __version__
from ..config import load_token
GITEA_BASE = "https://git.jesseyvanofferen.com" GITEA_BASE = "https://git.jesseyvanofferen.com"
REPO = "jessey/rigdoctor" REPO = "jessey/rigdoctor"
LATEST_API = f"{GITEA_BASE}/api/v1/repos/{REPO}/releases/latest" LATEST_API = f"{GITEA_BASE}/api/v1/repos/{REPO}/releases/latest"
RELEASES_PAGE = f"{GITEA_BASE}/{REPO}/releases" RELEASES_PAGE = f"{GITEA_BASE}/{REPO}/releases"
TOKEN_PAGE = f"{GITEA_BASE}/user/settings/applications"
# Update states
NO_TOKEN = "no-token"
AUTH = "auth"
NETWORK = "network"
UP_TO_DATE = "up-to-date"
AVAILABLE = "available"
def _parse(version: str) -> tuple[int, ...]: def _parse(version: str) -> tuple[int, ...]:
@@ -29,13 +42,56 @@ def is_newer(latest: str, current: str = __version__) -> bool:
return False return False
def check_latest(timeout: float = 4.0) -> str | None: def fetch_latest(timeout: float = 5.0) -> tuple[str | None, str, str | None]:
"""Return the latest release tag (e.g. 'v0.0.5'), or None if it can't be determined.""" """Return (tag, notes, error). error is NO_TOKEN/AUTH/NETWORK, or None on success."""
token = load_token()
if not token:
return (None, "", NO_TOKEN)
req = urllib.request.Request(
LATEST_API,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try: try:
req = urllib.request.Request(LATEST_API, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (https only)
data = json.load(resp) data = json.load(resp)
tag = data.get("tag_name") return (data.get("tag_name") or None, (data.get("body") or "").strip(), None)
return tag or None except urllib.error.HTTPError as exc:
return (None, "", AUTH if exc.code in (401, 403) else NETWORK)
except Exception: except Exception:
return None return (None, "", NETWORK)
def check_latest(timeout: float = 5.0) -> str | None:
"""Convenience: latest tag or None (ignores notes/error)."""
tag, _notes, _error = fetch_latest(timeout)
return tag
def update_state(timeout: float = 5.0) -> tuple[str, str | None, str]:
"""Return (state, tag, notes). state in NO_TOKEN/AUTH/NETWORK/UP_TO_DATE/AVAILABLE."""
tag, notes, error = fetch_latest(timeout)
if error:
return (error, None, "")
if tag and is_newer(tag):
return (AVAILABLE, tag, notes)
return (UP_TO_DATE, tag, notes)
def apply_update(tag: str) -> tuple[int, str]:
"""Self-update the current (user-local) install to `tag` via authenticated pip.
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@…/rigdoctor.git@<tag>` into
the running environment. Returns (exit_code, output) with the token scrubbed.
"""
token = load_token()
if not token:
return (1, "No update token configured. Run `rigdoctor login`.")
host = GITEA_BASE.split("://", 1)[1]
ref = f"rigdoctor[gui] @ git+https://oauth2:{token}@{host}/{REPO}.git@{tag}"
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", ref]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
out = (proc.stdout + proc.stderr).replace(token, "***")
return (proc.returncode, out)
except (subprocess.SubprocessError, OSError) as exc:
return (1, str(exc).replace(token, "***"))
+37 -1
View File
@@ -2,6 +2,11 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import threading import threading
import time import time
@@ -72,6 +77,11 @@ class HealthPage(QWidget):
self._status = QLabel("") self._status = QLabel("")
self._status.setObjectName("Muted") self._status.setObjectName("Muted")
header.addWidget(self._status) header.addWidget(self._status)
self._admin_btn = QPushButton("Run with admin")
self._admin_btn.setToolTip("Run all checks with root (SMART needs it) — prompts for your password")
self._admin_btn.clicked.connect(self._run_admin)
self._admin_btn.setEnabled(shutil.which("pkexec") is not None)
header.addWidget(self._admin_btn)
self._run_btn = QPushButton("Run health report") self._run_btn = QPushButton("Run health report")
self._run_btn.setObjectName("PrimaryButton") self._run_btn.setObjectName("PrimaryButton")
self._run_btn.clicked.connect(self._run) self._run_btn.clicked.connect(self._run)
@@ -106,7 +116,34 @@ class HealthPage(QWidget):
findings = [] findings = []
self._result.emit(findings) self._result.emit(findings)
def _run_admin(self) -> None:
self._run_btn.setEnabled(False)
self._admin_btn.setEnabled(False)
self._status.setText("Running all checks with admin (you'll be prompted)…")
threading.Thread(target=self._work_admin, daemon=True).start()
def _work_admin(self) -> None:
from ..core.health import Finding
cli = os.path.join(os.path.dirname(sys.executable), "rigdoctor")
if os.path.exists(cli):
cmd = ["pkexec", cli, "report", "--json"]
else: # dev / not on PATH next to python
cmd = ["pkexec", sys.executable, "-m", "rigdoctor", "report", "--json"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
findings = [Finding(**d) for d in json.loads(proc.stdout)] if proc.returncode == 0 else None
except Exception:
findings = None # pkexec cancelled / failed / unparsable
self._result.emit(findings)
def _render_findings(self, findings) -> None: def _render_findings(self, findings) -> None:
self._run_btn.setEnabled(True)
self._admin_btn.setEnabled(shutil.which("pkexec") is not None)
if findings is None: # elevated run cancelled/failed — keep current results
self._status.setText("admin run cancelled")
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 +159,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)
+85 -15
View File
@@ -2,16 +2,19 @@
from __future__ import annotations from __future__ import annotations
import os
import sys
import threading import threading
from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtCore import Qt, QProcess, QTimer, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QButtonGroup, QButtonGroup,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QPushButton, QPushButton,
QStackedWidget, QStackedWidget,
QVBoxLayout, QVBoxLayout,
@@ -19,6 +22,7 @@ from PySide6.QtWidgets import (
) )
from .. import __version__ from .. import __version__
from ..config import load_config
from ..core import updates from ..core import updates
from .dashboard import Dashboard from .dashboard import Dashboard
from .health_page import HealthPage from .health_page import HealthPage
@@ -34,7 +38,8 @@ _PLACEHOLDERS = {
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
_update_checked = Signal(object) # latest tag (str) or None _update_checked = Signal(object) # (state, tag)
_update_applied = Signal(int) # pip exit code
def __init__(self, interval: float = 1.0) -> None: def __init__(self, interval: float = 1.0) -> None:
super().__init__() super().__init__()
@@ -71,9 +76,20 @@ class MainWindow(QMainWindow):
self._worker.sampled.connect(self.dashboard.update_sample) self._worker.sampled.connect(self.dashboard.update_sample)
self._worker.start() self._worker.start()
# Background update check (M13); result lands in the sidebar. # 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_notes = ""
self._applied = False
self._update_checked.connect(self._show_update_state) self._update_checked.connect(self._show_update_state)
threading.Thread(target=self._check_updates, daemon=True).start() self._update_applied.connect(self._on_update_applied)
self._start_update_check()
minutes = float(load_config().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()
@@ -117,23 +133,77 @@ class MainWindow(QMainWindow):
self._update_btn = QPushButton() self._update_btn = QPushButton()
self._update_btn.setObjectName("PrimaryButton") self._update_btn.setObjectName("PrimaryButton")
self._update_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._update_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._update_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(updates.RELEASES_PAGE))) 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 _check_updates(self) -> None: def _restart(self) -> None:
self._update_checked.emit(updates.check_latest()) 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 _show_update_state(self, latest) -> None: def _apply_update(self) -> None:
if not latest: if not self._latest_tag:
self._update_label.setText("update check unavailable")
return return
if updates.is_newer(latest, __version__): box = QMessageBox(self)
self._update_label.setText(f'<span style="color:{GOOD};">{latest} available</span>') box.setWindowTitle(f"Update to {self._latest_tag}")
self._update_btn.setText(f"Update to {latest}") box.setText(f"Update RigDoctor to {self._latest_tag}?")
self._update_btn.setVisible(True) 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_label.setText("updating…")
tag = self._latest_tag
threading.Thread(target=lambda: self._update_applied.emit(updates.apply_update(tag)[0]), daemon=True).start()
def _on_update_applied(self, rc: int) -> None:
if rc == 0:
self._applied = True
self._update_label.setText("update installed")
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_btn.setEnabled(True)
def _start_update_check(self) -> None:
threading.Thread(target=self._check_updates, daemon=True).start()
def _check_updates(self) -> None:
self._update_checked.emit(updates.update_state())
def _show_update_state(self, result) -> None:
if self._applied: # an update was applied this session; awaiting restart
return
state, tag, notes = result
self._latest_tag = tag
self._latest_notes = notes
self._update_btn.setVisible(False)
if state == updates.NO_TOKEN:
self._update_label.setText("connect to update server")
elif state == updates.AUTH:
self._update_label.setText("update access denied")
elif state == updates.NETWORK:
self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}")
self._update_btn.setVisible(True)
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: def _placeholder_page(self, title: str, description: str) -> QWidget:
+75 -3
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import threading import threading
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QTextEdit, QTextEdit,
@@ -16,8 +18,9 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ..core import installer, sysenv from .. import config
from .theme import GOOD, MUTED from ..core import installer, sysenv, updates
from .theme import GOOD, MUTED, WARN
def _panel(title: str) -> tuple[QFrame, QVBoxLayout]: def _panel(title: str) -> tuple[QFrame, QVBoxLayout]:
@@ -33,13 +36,23 @@ def _panel(title: str) -> tuple[QFrame, QVBoxLayout]:
return frame, layout return frame, layout
_BACKEND_DESC = {
"env": "token from $RIGDOCTOR_TOKEN",
"keyring": "token stored in the OS keyring (encrypted)",
"file": "token stored in a 0600 file — install libsecret-tools to encrypt it",
"none": "no token saved",
}
class SetupPage(QWidget): class SetupPage(QWidget):
_installed = Signal(int, str) _installed = Signal(int, str)
_upd_state = Signal(object)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._installed.connect(self._on_installed) self._installed.connect(self._on_installed)
self._upd_state.connect(self._on_upd_state)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -71,6 +84,27 @@ class SetupPage(QWidget):
comp_layout.addLayout(controls) comp_layout.addLayout(controls)
root.addWidget(comp_card) root.addWidget(comp_card)
# Update access (M13): token gating updates to Gitea account holders.
upd_card, upd_layout = _panel("Update access")
self._upd_status = QLabel("")
self._upd_status.setObjectName("Muted")
self._upd_status.setWordWrap(True)
upd_layout.addWidget(self._upd_status)
token_row = QHBoxLayout()
self._token_input = QLineEdit()
self._token_input.setEchoMode(QLineEdit.EchoMode.Password)
self._token_input.setPlaceholderText("Paste a Gitea token (scope: read:repository)")
save_btn = QPushButton("Save token")
save_btn.setObjectName("PrimaryButton")
save_btn.clicked.connect(self._save_token)
get_btn = QPushButton("Get a token")
get_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(updates.TOKEN_PAGE)))
token_row.addWidget(self._token_input, 1)
token_row.addWidget(save_btn)
token_row.addWidget(get_btn)
upd_layout.addLayout(token_row)
root.addWidget(upd_card)
self._output = QTextEdit() self._output = QTextEdit()
self._output.setObjectName("Report") self._output.setObjectName("Report")
self._output.setReadOnly(True) self._output.setReadOnly(True)
@@ -80,6 +114,7 @@ class SetupPage(QWidget):
root.addStretch(1) root.addStretch(1)
self._refresh() self._refresh()
self._refresh_update_status()
def _refresh(self) -> None: def _refresh(self) -> None:
self._env.setText( self._env.setText(
@@ -126,3 +161,40 @@ class SetupPage(QWidget):
self._output.setPlainText(out[-4000:]) self._output.setPlainText(out[-4000:])
self._install_btn.setText("Install missing") self._install_btn.setText("Install missing")
self._refresh() self._refresh()
# If libsecret-tools was just installed, move a file token into the keyring.
if config.token_backend() == "file" and config.keyring_available():
token = config.load_token()
if token:
config.save_token(token)
self._refresh_update_status()
# --- update access (token) ------------------------------------------------
def _save_token(self) -> None:
token = self._token_input.text().strip()
if not token:
return
config.save_token(token)
self._token_input.clear()
self._refresh_update_status()
def _refresh_update_status(self) -> None:
self._upd_status.setText(f"{_BACKEND_DESC[config.token_backend()]} · checking…")
threading.Thread(target=self._check_update, daemon=True).start()
def _check_update(self) -> None:
self._upd_state.emit((config.token_backend(), updates.update_state()))
def _on_upd_state(self, result) -> None:
backend, (state, tag, _notes) = result
msg = {
updates.NO_TOKEN: "paste a token below to enable updates",
updates.AUTH: "token rejected — check its scope/permissions",
updates.NETWORK: "couldn't reach the update server",
updates.UP_TO_DATE: f"up to date ({tag})" if tag else "up to date",
updates.AVAILABLE: f"update available: {tag}",
}[state]
color = GOOD if state == updates.AVAILABLE else (WARN if state == updates.AUTH else MUTED)
self._upd_status.setText(
f"<span style='color:{MUTED}'>{_BACKEND_DESC[backend]}</span> · "
f"<span style='color:{color}'>{msg}</span>"
)
+36
View File
@@ -0,0 +1,36 @@
"""Tests for update-token storage (file fallback + env override), keyring mocked out."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor import config
class TokenStorageTests(unittest.TestCase):
def test_file_fallback_roundtrip(self):
with tempfile.TemporaryDirectory() as d:
token_file = Path(d) / "token"
with mock.patch.object(config, "_secret_tool", return_value=None), \
mock.patch.object(config, "TOKEN_FILE", token_file), \
mock.patch.dict(os.environ, {}, clear=True):
self.assertIsNone(config.load_token())
config.save_token("abc123")
self.assertEqual(config.load_token(), "abc123")
self.assertEqual(config.token_backend(), "file")
self.assertEqual(token_file.stat().st_mode & 0o777, 0o600)
config.clear_token()
self.assertIsNone(config.load_token())
self.assertEqual(config.token_backend(), "none")
def test_env_override_wins(self):
with mock.patch.object(config, "_secret_tool", return_value=None), \
mock.patch.dict(os.environ, {"RIGDOCTOR_TOKEN": "envtok"}, clear=True):
self.assertEqual(config.load_token(), "envtok")
self.assertEqual(config.token_backend(), "env")
if __name__ == "__main__":
unittest.main()