Compare commits

...

5 Commits

Author SHA1 Message Date
jessey 2684e5c8ab Merge pull request 'feat(m9): graphical first-run setup wizard — 0.26.0' (#21) from feat/share-terminal into main
release / release (push) Successful in 13s
Reviewed-on: #21
2026-05-22 08:18:32 +00:00
jessey 4386838b69 feat(m9): graphical first-run setup wizard — 0.26.0
The full installer experience as a GUI wizard (gui/setup_wizard.py): environment
summary → pick dependency bundles (from the catalog, grouped) → install missing
apt packages → choose recording trigger → readiness summary.

- Shown on first launch (config setup_done) and via `rigdoctor-gui --setup`;
  re-runnable from Settings → Run setup wizard.
- install.sh launches it after a fresh install when a desktop session is present.
- catalog.by_bundle() groups components; config gains setup_done.
- Tests: by_bundle grouping + wizard construction smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:13:51 +02:00
jessey bfbad9cbc6 Merge pull request 'feat(share): render colors in the shared terminal — 0.24.0' (#20) from feat/share-terminal into main
release / release (push) Successful in 14s
Reviewed-on: #20
2026-05-22 08:05:12 +00:00
jessey 2e545ff718 feat(share): terminal-only sharing, bigger + full-screen — 0.25.0
Scope M12 down to a single shared-terminal mode (D23, amends D16):
- Share page rewritten terminal-only: host shares their PTY/shell; guest watches
  and may type only if the host ticks "Allow the guest to type" (read-only
  otherwise — the D9 consent exception). Terminal is larger; either side can pop
  it full-screen (Esc to exit).
- Removed the read-only stats view + HTTP server (core/share.py) and the
  `rigdoctor share serve` CLI; deleted their tests.
- Docs: D23 added; SPEC/MODULES/ROADMAP updated (M12 → done, terminal-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:04:52 +02:00
jessey 5e5dc2d54a feat(share): render colors in the shared terminal — 0.24.0
The terminal view rendered monochrome (QPlainTextEdit.setPlainText), dropping
pyte's per-cell attributes. Rewritten as a QTextEdit that renders fg/bg/bold/
reverse per cell (block cursor = inverted cell), preserving scrollback. The
session already runs the host's $SHELL + config with TERM=xterm-256color, so
fish/ls/git/prompts now look the same as locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:00:23 +02:00
21 changed files with 558 additions and 443 deletions
+27
View File
@@ -5,6 +5,33 @@ 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.26.0] - 2026-05-22
### Added
- **Graphical setup wizard (M9).** A first-run GUI wizard (`gui/setup_wizard.py`) walks through:
environment summary → pick **dependency bundles** (Diagnostics / Monitoring / Gaming / Updates,
from the component catalog) → install the missing apt packages → choose the **recording
trigger** → a readiness summary. It shows automatically on first launch (until done), is
re-runnable from **Settings → Run setup wizard** or `rigdoctor-gui --setup`, and `install.sh`
launches it after a fresh install when a desktop session is present.
## [0.25.0] - 2026-05-22
### Changed
- **Share is now terminal-only (D23, amends D16).** The Share page is a single shared-terminal
experience: the host shares their shell, the guest watches and may type **only if the host
ticks "Allow the guest to type"** (otherwise read-only). The terminal is larger and either
side can pop it **full-screen** (Esc to exit).
### Removed
- The read-only **stats view** (live sensors/health/inventory over the relay) and the
`rigdoctor share serve` HTTP server — the shared terminal replaces them. (`core/share.py`
removed; the `share` CLI command is gone.)
## [0.24.0] - 2026-05-22
### Added
- **Shared terminal is now in color.** The terminal view renders pyte's per-cell foreground/
background, bold, and reverse, so the host's real shell keeps its theming — fish, `ls`,
`git`, prompts, etc. look the same as locally (the session already runs the host's `$SHELL`
with its config and `TERM=xterm-256color`; only the rendering was monochrome).
## [0.23.0] - 2026-05-22 ## [0.23.0] - 2026-05-22
### Added ### Added
- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**, - **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**,
+11 -1
View File
@@ -239,9 +239,19 @@ consent." That milestone lands here, **scoped tightly to stay safe**:
the apply UI is an additive convenience in the GUI, not the only path. Installing optional the apply UI is an additive convenience in the GUI, not the only path. Installing optional
tools (GameMode/MangoHud/cpupower) reuses the M9 installer and is likewise one-click. tools (GameMode/MangoHud/cpupower) reuses the M9 installer and is likewise one-click.
### D23 — Session sharing scoped to a shared terminal only — *DECIDED 2026-05-22; amends D16*
D16's escalating ladder (export → read-only stats view → terminal) is **cut down to just the
shared terminal.** Rationale: the terminal is the only mode the owner wants; the stats view
duplicated what the GUI already shows and added surface area. Concretely:
- **Removed:** the read-only stats view + its HTTP server (`core/share.py`, `rigdoctor share
serve`) and the (never-built) bundle export. The `share` CLI command is gone.
- **Kept & finished:** the relay **shared terminal** (host PTY of `$SHELL`) — now color-rendered
(preserves fish/ls/git theming), full-screen-able, with the guest read-only unless the host
ticks "Allow the guest to type" (the D9 consent exception). Account-gated by the Gitea token.
## Open ## Open
None currently — all tracked decisions (D1D22) are resolved. New questions will be added None currently — all tracked decisions (D1D23) 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`).
+8 -7
View File
@@ -18,7 +18,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ | | M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ |
| M9 | Installer | (meta) | none | all | P1 | 🟨 | | M9 | Installer | (meta) | none | all | P1 | 🟨 |
| M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 | | M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | |
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ | | M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ |
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
@@ -96,12 +96,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
**`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:* **`.run`** (pure-Python self-extractor, `packaging/make_run.py`, built by CI). *Pending:*
config/module selection + `systemd --user` 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, scoped to terminal-only by **D23**) — a single
an escalating ladder: (1) **diagnostic bundle export** (inventory + recent log + report, mode: a **host-consented shared terminal** over the relay. The host shares a real PTY running
one-way), (2) **live read-only view** over a user-chosen tunnel (Tailscale/cloudflared/SSH, their `$SHELL` (colors/theming preserved — fish etc.); the guest watches live and can type
no hosted relay), (3) **gated interactive terminal** wrapping tmate/sshx (read-only by **only if the host allows it** (otherwise read-only) — a deliberate, consent-gated exception
default; read-write only on explicit consent — a deliberate exception to D9). Per-session to D9. The host reads along and can type too (e.g. a sudo password, which stays local). Either
consent, ephemeral revocable tokens, audit log. side can pop the terminal **full-screen**. Account-gated by the Gitea token. *The earlier
read-only stats view and `share serve` (Tier 1/2) were removed.*
- **M13 Auto-update** (D18) — *check + auth implemented:* updates are **gated to Gitea account - **M13 Auto-update** (D18) — *check + auth implemented:* updates are **gated to Gitea account
holders** via a Personal Access Token, stored **encrypted in the OS keyring** (`secret-tool`) holders** via a Personal Access Token, stored **encrypted in the OS keyring** (`secret-tool`)
with a 0600-file fallback (`config.load_token`/`save_token`/`token_backend`). `core/updates` with a 0600-file fallback (`config.load_token`/`save_token`/`token_backend`). `core/updates`
+10 -9
View File
@@ -65,7 +65,10 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
(no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger (no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger
modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI
Settings "Recording trigger") incl. the zero-config **game-launch watcher** Settings "Recording trigger") incl. the zero-config **game-launch watcher**
(`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install. (`core/watcher.py`, `rigdoctor watch`); and a **graphical first-run setup wizard**
(`gui/setup_wizard.py`): environment → dependency-bundle selection → install → recording
trigger → readiness, auto-launched by install.sh and re-runnable from Settings.
*Pending:* `.deb` packaging (next bullet).
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI - [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later) ## Phase 5 — Breadth (later)
@@ -79,14 +82,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot. NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot.
GRUB-based fixes + CPU mitigations remain suggestion-only. GRUB-based fixes + CPU mitigations remain suggestion-only.
## Phase 6 — Session sharing / remote assist (M12, D16) ## Phase 6 — Session sharing / remote assist (M12, D16 → scoped to terminal-only by D23)
Escalating ladder, built in order: - [x] **Shared terminal** — a real PTY (host's `$SHELL`) shared over the relay, color-rendered
- [ ] Tier 1: `share export` — diagnostic bundle (inventory + recent log + report); B opens (pyte), full-screen-able; the guest watches and may type only on host consent (D9
it in RigDoctor. One-way, safest. exception); host reads along + can type (sudo). The single share mode.
- [x] Tier 2: live read-only view — `rigdoctor share serve` (stdlib HTTP, token-gated: - [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the
sensors + health + inventory). Remote = user-chosen tunnel; GUI controls still to add. shared terminal is the only sharing mode.
- [x] Tier 3: host-consented interactive terminal — a real PTY shell shared over the relay
(own `pty`, pyte-rendered guest), off by default; host reads along + can type (sudo).
> **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond > **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond
> Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out. > Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out.
+7 -9
View File
@@ -144,15 +144,13 @@ bundles with descriptions and the exact packages each needs → resolve & instal
mode. Delivered with the user-local install (and the optional `.deb`) (D8). Module mode. Delivered with the user-local install (and the optional `.deb`) (D8). Module
list/bundling is final per D14. list/bundling is final per D14.
### M12 — Session sharing / remote assist (D16) ### M12 — Session sharing / remote assist (D16, scoped to terminal-only by D23)
Lets a user (A) grant a helper (B) inspection access, as an escalating, consent-driven Lets a user (A) grant a helper (B) a **shared terminal** over the relay: A shares a real PTY
ladder: (1) **diagnostic bundle export** (inventory + recent capture log + report, one-way); running their shell; B watches live and may type **only if A allows it** (otherwise read-only)
(2) **live read-only view** of the dashboard + logs over a user-chosen tunnel — a deliberate, consent-gated exception to the read-only stance (D9). A reads along and can
(Tailscale/cloudflared/SSH — no RigDoctor-hosted relay); (3) **gated interactive terminal** type too (e.g. a sudo password, which stays local and is never sent to B). Account-gated by the
wrapping an existing tool (tmate/sshx), read-only by default, read-write only on explicit Gitea token; per-session share code. The shared terminal preserves colors/theming and can be
consent. Per-session consent, ephemeral revocable tokens, permission escalation (view ≠ viewed full-screen. *(The earlier read-only stats view / bundle export were dropped — D23.)*
shell), and a session audit log. Tier 3 is a deliberate, consent-gated exception to the
read-only stance (D9). Built in Phase 6.
## 5. Non-functional requirements ## 5. Non-functional requirements
- **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt - **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt
+8
View File
@@ -115,3 +115,11 @@ case ":$PATH:" in
*":$BIN_DIR:"*) ;; *":$BIN_DIR:"*) ;;
*) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";; *) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";;
esac esac
# Launch the graphical setup wizard if a desktop session is available (first run shows it).
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && [ -x "$VENV/bin/rigdoctor-gui" ]; then
echo " Opening the setup wizard…"
("$VENV/bin/rigdoctor-gui" --setup >/dev/null 2>&1 &)
else
echo " Run 'rigdoctor-gui' to finish setup."
fi
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.23.0" version = "0.26.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.23.0" __version__ = "0.26.0"
-13
View File
@@ -289,12 +289,6 @@ def cmd_uninstall(args) -> int:
return 0 return 0
def cmd_share_serve(args) -> int:
from .core import share
return share.serve(host=args.host, port=args.port)
def cmd_collect_priv(args) -> int: def cmd_collect_priv(args) -> int:
"""Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch.""" """Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch."""
from dataclasses import asdict from dataclasses import asdict
@@ -600,13 +594,6 @@ def build_parser() -> argparse.ArgumentParser:
cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec
cp.set_defaults(func=cmd_collect_priv) cp.set_defaults(func=cmd_collect_priv)
share_p = sub.add_parser("share", help="session sharing (M12)")
share_sub = share_p.add_subparsers(dest="share_cmd", required=True)
serve_p = share_sub.add_parser("serve", help="serve a read-only live view (token-gated)")
serve_p.add_argument("--host", default="127.0.0.1", help="bind address (use 0.0.0.0 + a tunnel for remote)")
serve_p.add_argument("--port", type=int, default=8765, help="port")
serve_p.set_defaults(func=cmd_share_serve)
inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details") inv = sub.add_parser("inventory", help="system inventory (M5): export hardware/OS details")
inv.add_argument("--json", action="store_true", help="output JSON") inv.add_argument("--json", action="store_true", help="output JSON")
inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)") inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)")
+1
View File
@@ -155,6 +155,7 @@ DEFAULTS: dict = {
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12) "relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet "steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet
"trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch "trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
"setup_done": False, # first-run GUI setup wizard completed (M9)
} }
+8
View File
@@ -65,3 +65,11 @@ COMPONENTS: tuple[Component, ...] = (
def by_id(component_id: str) -> Component | None: def by_id(component_id: str) -> Component | None:
"""Look up a catalog component by its id (None if unknown).""" """Look up a catalog component by its id (None if unknown)."""
return next((c for c in COMPONENTS if c.id == component_id), None) return next((c for c in COMPONENTS if c.id == component_id), None)
def by_bundle() -> dict[str, list[Component]]:
"""Components grouped by bundle, preserving catalog order (for the setup wizard)."""
groups: dict[str, list[Component]] = {}
for c in COMPONENTS:
groups.setdefault(c.bundle, []).append(c)
return groups
-194
View File
@@ -1,194 +0,0 @@
"""Session sharing (M12, Tier 2): a read-only live view over a local HTTP server.
Serves the live sensor snapshot + health report + inventory, **read-only**, gated by a
random share token. Bind to localhost for local testing, or to all interfaces behind a
user-chosen tunnel (Tailscale / cloudflared / SSH) for remote help. No actions, no terminal.
"""
from __future__ import annotations
import json
import secrets
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from .sampler import Sampler
from .sources import available_sources
_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>RigDoctor shared</title>
<style>
body{background:#101216;color:#e6e8eb;font-family:system-ui,sans-serif;margin:0;padding:24px}
h1{font-size:20px;margin:0 0 4px} h2{font-size:14px;color:#8b929c;margin:18px 0 6px}
.card{background:#1b1f26;border:1px solid #2a2f39;border-radius:12px;padding:16px;margin:14px 0}
table{width:100%;border-collapse:collapse} td{padding:3px 0;font-size:14px}
td.v{text-align:right;font-weight:600} .muted{color:#8b929c}
.critical{color:#f87171} .warning{color:#fb923c} .ok{color:#4ade80} .info{color:#8b929c}
.badge{display:inline-block;background:#38bdf8;color:#06222e;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:700}
</style></head><body>
<h1>RigDoctor <span class="badge">read-only share</span></h1>
<p class="muted">A live view shared by the machine's owner. You can look, not change anything.</p>
<div class="card"><div id="live">loading</div></div>
<div class="card"><h2 style="margin-top:0">Health</h2><div id="health">loading</div></div>
<div class="card"><h2 style="margin-top:0">Inventory</h2><div id="inv">loading</div></div>
<script>
const T=new URLSearchParams(location.search).get('t');
const j=async p=>(await fetch(p+'?t='+encodeURIComponent(T))).json();
const fmt=(v,u)=>v==null?'N/A':(u==='\\u00b0C'?(+v).toFixed(1)+' °C':(u?v+' '+u:v));
async function live(){try{const d=await j('/api/snapshot');let h='';
for(const[g,items]of Object.entries(d.groups)){h+='<h2>'+g.toUpperCase()+'</h2><table>';
for(const it of items)h+='<tr><td class="muted">'+it.name+'</td><td class="v">'+fmt(it.value,it.unit)+'</td></tr>';
h+='</table>';}document.getElementById('live').innerHTML=h;}catch(e){}}
async function once(){try{const r=await j('/api/report');
document.getElementById('health').innerHTML=r.map(f=>'<div><span class="'+f.severity+'">['+f.severity.toUpperCase()+']</span> '+f.category+': '+f.title+'</div>').join('')||'no findings';}catch(e){}
try{const inv=await j('/api/inventory');let h='';
for(const[s,kv]of Object.entries(inv)){h+='<h2>'+s+'</h2><table>';
for(const[k,v]of Object.entries(kv))h+='<tr><td class="muted">'+k+'</td><td class="v">'+v+'</td></tr>';
h+='</table>';}document.getElementById('inv').innerHTML=h;}catch(e){}}
live();once();setInterval(live,2000);
</script></body></html>"""
def _snapshot(sampler: Sampler) -> dict:
sample = sampler.sample()
groups: dict[str, list] = {}
for r in sample.readings:
if r.metric == "name":
item = {"name": "device", "value": r.label, "unit": ""}
else:
item = {"name": (r.label + " " + r.metric).strip() if r.label else r.metric,
"value": r.value, "unit": r.unit}
groups.setdefault(r.source, []).append(item)
return {"ts": sample.ts, "groups": groups}
def _report() -> list:
from .health import run_health_checks
return [asdict(f) for f in run_health_checks()]
def _inventory() -> dict:
from .inventory import collect, to_dict
return to_dict(collect())
# --- Relay (M12) frames: a host streams these; a guest renders them. -----------------
def host_full_frame(sampler: Sampler) -> str:
"""Initial frame: live snapshot + health report + inventory."""
return json.dumps({"type": "full", "snapshot": _snapshot(sampler),
"report": _report(), "inventory": _inventory()})
def host_snapshot_frame(sampler: Sampler) -> str:
"""Recurring frame: just the live snapshot."""
return json.dumps({"type": "snapshot", "snapshot": _snapshot(sampler)})
def _fmt(value, unit: str) -> str:
if value is None:
return "N/A"
if unit == "°C":
try:
return f"{float(value):.1f} °C"
except (TypeError, ValueError):
return str(value)
return f"{value} {unit}".strip()
def guest_html(snapshot: dict | None, report: list | None, inventory: dict | None) -> str:
"""Render a received frame as read-only dark HTML for the guest's view."""
import html as _html
def esc(x) -> str:
return _html.escape(str(x))
out = ['<div style="font-family:sans-serif;color:#e6e8eb">']
if snapshot:
for group, items in snapshot.get("groups", {}).items():
out.append(f'<h3 style="color:#8b929c">{esc(group).upper()}</h3><table width="100%">')
for it in items:
out.append(f'<tr><td style="color:#8b929c">{esc(it.get("name"))}</td>'
f'<td align="right"><b>{esc(_fmt(it.get("value"), it.get("unit", "")))}</b></td></tr>')
out.append("</table>")
if report:
out.append('<h3 style="color:#8b929c">HEALTH</h3>')
colors = {"critical": "#f87171", "warning": "#fb923c", "ok": "#4ade80"}
for f in report:
sev = f.get("severity", "info")
out.append(f'<div><span style="color:{colors.get(sev, "#8b929c")}">[{esc(sev).upper()}]</span> '
f'{esc(f.get("category"))}: {esc(f.get("title"))}</div>')
if inventory:
out.append('<h3 style="color:#8b929c">INVENTORY</h3>')
for section, kv in inventory.items():
out.append(f'<h4 style="margin:6px 0;color:#8b929c">{esc(section)}</h4><table width="100%">')
for k, v in kv.items():
out.append(f'<tr><td style="color:#8b929c">{esc(k)}</td><td align="right"><b>{esc(v)}</b></td></tr>')
out.append("</table>")
out.append("</div>")
return "".join(out)
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # quiet
pass
def _authed(self, query: dict) -> bool:
return secrets.compare_digest(query.get("t", [""])[0], self.server.token)
def _send(self, code: int, ctype: str, body: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if not self._authed(parse_qs(parsed.query)):
self._send(403, "text/plain", b"Forbidden: missing or invalid share token")
return
if parsed.path == "/":
self._send(200, "text/html; charset=utf-8", _PAGE.encode())
elif parsed.path == "/api/snapshot":
self._send(200, "application/json", json.dumps(_snapshot(self.server.sampler)).encode())
elif parsed.path == "/api/report":
self._send(200, "application/json", json.dumps(_report()).encode())
elif parsed.path == "/api/inventory":
self._send(200, "application/json", json.dumps(_inventory()).encode())
else:
self._send(404, "text/plain", b"Not found")
class _Server(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, addr, token: str):
super().__init__(addr, _Handler)
self.token = token
self.sampler = Sampler(available_sources())
def make_server(host: str = "127.0.0.1", port: int = 0, token: str | None = None) -> tuple[_Server, str]:
token = token or secrets.token_urlsafe(16)
return _Server((host, port), token), token
def serve(host: str = "127.0.0.1", port: int = 8765) -> int:
srv, token = make_server(host, port)
url = f"http://{host}:{srv.server_address[1]}/?t={token}"
print(
f"Sharing a read-only live view at:\n {url}\n\n"
"Anyone with this URL (and network access to this host) can VIEW your sensors,\n"
"health report, and inventory — read-only. For remote help, expose it via a tunnel\n"
"(Tailscale / cloudflared / `ssh -R`). Press Ctrl-C to stop sharing.",
flush=True,
)
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\nStopped sharing.")
finally:
srv.shutdown()
return 0
+7 -1
View File
@@ -28,7 +28,8 @@ def main(argv: list[str] | None = None) -> int:
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
interval = float(load_config().get("interval", 1.0)) cfg = load_config()
interval = float(cfg.get("interval", 1.0))
window = MainWindow(interval=interval) window = MainWindow(interval=interval)
# `--tray` starts hidden to the system tray (for autostart); if no tray is available, # `--tray` starts hidden to the system tray (for autostart); if no tray is available,
# fall back to showing the window so the app is never invisible-and-unreachable. # fall back to showing the window so the app is never invisible-and-unreachable.
@@ -37,6 +38,11 @@ def main(argv: list[str] | None = None) -> int:
window.start_minimized_note() window.start_minimized_note()
else: else:
window.show() window.show()
# First run (or `--setup`): the graphical setup wizard (M9).
if "--setup" in args or not cfg.get("setup_done", False):
from .setup_wizard import SetupWizard
SetupWizard(window).exec()
return app.exec() return app.exec()
+10
View File
@@ -87,8 +87,11 @@ class SetupPage(QWidget):
self._install_btn.clicked.connect(self._install) self._install_btn.clicked.connect(self._install)
self._refresh_btn = QPushButton("Re-check") self._refresh_btn = QPushButton("Re-check")
self._refresh_btn.clicked.connect(self._refresh) self._refresh_btn.clicked.connect(self._refresh)
wizard_btn = QPushButton("Run setup wizard")
wizard_btn.clicked.connect(self._run_wizard)
controls.addWidget(self._install_btn) controls.addWidget(self._install_btn)
controls.addWidget(self._refresh_btn) controls.addWidget(self._refresh_btn)
controls.addWidget(wizard_btn)
controls.addStretch(1) controls.addStretch(1)
comp_layout.addLayout(controls) comp_layout.addLayout(controls)
root.addWidget(comp_card) root.addWidget(comp_card)
@@ -202,6 +205,13 @@ class SetupPage(QWidget):
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual")) self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._refresh_update_status() self._refresh_update_status()
def _run_wizard(self) -> None:
from .setup_wizard import SetupWizard
SetupWizard(self).exec()
self._refresh()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
# --- recording trigger (M9) ----------------------------------------------- # --- recording trigger (M9) -----------------------------------------------
def _apply_trigger(self) -> None: def _apply_trigger(self) -> None:
mode = self._trigger.currentText() mode = self._trigger.currentText()
+259
View File
@@ -0,0 +1,259 @@
"""First-run GUI setup wizard (M9): the full graphical installer/setup.
Bootstrap (Python venv + PySide6) is done by install.sh/.run; this wizard handles the rest
graphically environment summary pick dependency bundles install the missing apt packages
choose the recording trigger readiness summary. Shown automatically on first launch (until
`setup_done`), re-runnable from Settings, and launched by install.sh after a fresh install.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from .. import config
from ..core import catalog, installer, service, sysenv
class SetupWizard(QDialog):
_installed = Signal(int, str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("RigDoctor Setup")
self.resize(620, 560)
self.setObjectName("Page")
self._installed.connect(self._on_installed)
self._bundle_checks: dict[str, QCheckBox] = {}
self._installing = False
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 16)
root.setSpacing(14)
self._stack = QStackedWidget()
self._stack.addWidget(self._page_welcome()) # 0
self._stack.addWidget(self._page_bundles()) # 1
self._stack.addWidget(self._page_install()) # 2
self._stack.addWidget(self._page_trigger()) # 3
self._stack.addWidget(self._page_finish()) # 4
root.addWidget(self._stack, 1)
nav = QHBoxLayout()
self._skip_btn = QPushButton("Skip")
self._skip_btn.clicked.connect(self._skip)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(lambda: self._go(-1))
self._next_btn = QPushButton("Next")
self._next_btn.setObjectName("PrimaryButton")
self._next_btn.clicked.connect(lambda: self._go(1))
nav.addWidget(self._skip_btn)
nav.addStretch(1)
nav.addWidget(self._back_btn)
nav.addWidget(self._next_btn)
root.addLayout(nav)
self._index = 0
self._update_nav()
# --- pages -----------------------------------------------------------------
def _page(self, title: str, subtitle: str = "") -> tuple[QWidget, QVBoxLayout]:
page = QWidget()
v = QVBoxLayout(page)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(10)
head = QLabel(title)
head.setObjectName("PageTitle")
v.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Muted")
sub.setWordWrap(True)
v.addWidget(sub)
return page, v
def _page_welcome(self) -> QWidget:
page, v = self._page(
"Welcome to RigDoctor",
"Let's set up monitoring and diagnostics for your machine. This takes a minute and "
"needs no root for the app itself — only installing optional tools may ask for your "
"password.",
)
env = QLabel(
f"Detected:\n"
f" • Distro: {sysenv.distro_name()}\n"
f" • Package manager: {sysenv.package_manager() or 'none (apt required for extras)'}\n"
f" • GPU: {', '.join(sysenv.gpu_vendors()) or 'unknown'}"
)
env.setObjectName("Muted")
v.addWidget(env)
v.addStretch(1)
return page
def _page_bundles(self) -> QWidget:
page, v = self._page(
"Choose what to set up",
"Pick the optional tool bundles to install. Core monitoring, crash capture, and the "
"health report work without any of these — they just add capability.",
)
present = {c.id: ok for c, ok in installer.component_status()}
for bundle, comps in catalog.by_bundle().items():
missing = [c for c in comps if not present.get(c.id)]
names = ", ".join(c.name for c in comps)
tag = " — all installed ✓" if not missing else f"{len(missing)} to install"
cb = QCheckBox(f"{bundle}: {names}{tag}")
cb.setChecked(bool(missing)) # default-check bundles with something to add
cb.setEnabled(bool(missing) and sysenv.package_manager() == "apt")
self._bundle_checks[bundle] = cb
v.addWidget(cb)
if sysenv.package_manager() != "apt":
note = QLabel("Only apt is supported for installing tools, so these are read-only here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_install(self) -> QWidget:
page, v = self._page("Install tools", "Installing the selected packages…")
self._install_status = QLabel("")
self._install_status.setObjectName("Muted")
self._install_status.setWordWrap(True)
v.addWidget(self._install_status)
self._install_output = QTextEdit()
self._install_output.setObjectName("Report")
self._install_output.setReadOnly(True)
v.addWidget(self._install_output, 1)
return page
def _page_trigger(self) -> QWidget:
page, v = self._page(
"Recording trigger",
"When the crash logger runs. You can change this any time in Settings.",
)
self._trigger_group = QButtonGroup(self)
labels = {
"manual": "Manual — start/stop recording yourself.",
"always-on": "Always-on — a background service records continuously.",
"game-launch": "Game-launch — auto-record while a Steam game runs.",
}
for i, (mode, text) in enumerate(labels.items()):
rb = QRadioButton(text)
rb.setProperty("mode", mode)
rb.setChecked(mode == config.load_config().get("trigger_mode", "manual"))
self._trigger_group.addButton(rb, i)
v.addWidget(rb)
if not service.available():
note = QLabel("systemd --user isn't available, so always-on / game-launch can't be enabled here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_finish(self) -> QWidget:
page, v = self._page("You're all set", "")
self._finish_summary = QLabel("")
self._finish_summary.setObjectName("Muted")
self._finish_summary.setWordWrap(True)
v.addWidget(self._finish_summary)
v.addStretch(1)
return page
# --- navigation ------------------------------------------------------------
def _go(self, delta: int) -> None:
if self._installing:
return
new = self._index + delta
if new < 0:
return
if new >= self._stack.count(): # past the last page → finish
self._finish()
return
self._index = new
self._stack.setCurrentIndex(new)
self._update_nav()
if new == 2: # entering the install page
self._run_install()
elif new == 4: # entering the finish page
self._fill_summary()
def _update_nav(self) -> None:
self._back_btn.setEnabled(self._index > 0 and not self._installing)
last = self._index == self._stack.count() - 1
self._next_btn.setText("Finish" if last else "Next")
self._skip_btn.setVisible(not last)
def _selected_components(self):
present = {c.id: ok for c, ok in installer.component_status()}
chosen = []
for bundle, comps in catalog.by_bundle().items():
if self._bundle_checks.get(bundle) and self._bundle_checks[bundle].isChecked():
chosen += [c for c in comps if not present.get(c.id)]
return chosen
def _run_install(self) -> None:
packages = installer.missing_packages(self._selected_components())
if not packages:
self._install_status.setText("Nothing to install — your selected tools are already present.")
self._install_output.setVisible(False)
return
self._installing = True
self._update_nav()
self._next_btn.setEnabled(False)
self._install_status.setText("Installing… you may be asked for your password.")
self._install_output.setVisible(True)
self._install_output.setPlainText(f"Installing: {' '.join(packages)}\n")
threading.Thread(target=lambda: self._installed.emit(*installer.install_packages(packages)), daemon=True).start()
def _on_installed(self, rc: int, out: str) -> None:
self._installing = False
self._install_output.setPlainText(out[-4000:])
self._install_status.setText("Done." if rc == 0 else "Some packages may not have installed — see the log.")
self._next_btn.setEnabled(True)
self._update_nav()
def _fill_summary(self) -> None:
from ..core.sources import available_sources
status = installer.component_status()
present = sum(1 for _c, ok in status if ok)
sources = len(available_sources())
mode = self._chosen_mode()
self._finish_summary.setText(
f"• Optional tools present: {present}/{len(status)}\n"
f"• Sensor sources detected: {sources}\n"
f"• Recording trigger: {mode}\n\n"
"You can re-run this wizard or change anything from Settings."
)
def _chosen_mode(self) -> str:
btn = self._trigger_group.checkedButton()
return btn.property("mode") if btn else "manual"
def _finish(self) -> None:
mode = self._chosen_mode()
if service.available():
service.apply_mode(mode)
else:
config.update_config(trigger_mode=mode)
config.update_config(setup_done=True)
self.accept()
def _skip(self) -> None:
config.update_config(setup_done=True)
self.reject()
+96 -96
View File
@@ -1,9 +1,10 @@
"""Share page (M12): host or join a shared session over the relay. """Share page (M12): a shared **terminal** session over the relay.
Guest sees the host's live sensors + health + inventory (read-only). If the host enables it, The host shares a real PTY running their shell; the guest watches it live and only if the
a full **PTY terminal** is shared: the guest types and the commands run on the host (as the host ticks "Allow the guest to type" can run commands (as the host's user). The host reads
host's user), the host reads along, and the host can type too — e.g. a sudo password, which along and can type too, e.g. a sudo password, which stays local and is never sent to the guest.
stays local and is never sent to the guest. This is the only share mode (the old read-only stats view was removed). Either terminal can be
popped full-screen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,7 +12,8 @@ from __future__ import annotations
import base64 import base64
import json import json
from PySide6.QtCore import Qt, QSocketNotifier, QTimer, QUrl from PySide6.QtCore import Qt, QSocketNotifier, QUrl
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWebSockets import QWebSocket from PySide6.QtWebSockets import QWebSocket
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
@@ -20,16 +22,12 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..config import load_config, load_token from ..config import load_config, load_token
from ..core import share
from ..core.pty_session import PtySession from ..core.pty_session import PtySession
from ..core.sampler import Sampler
from ..core.sources import available_sources
from .terminal_widget import TerminalView from .terminal_widget import TerminalView
@@ -57,16 +55,13 @@ class SharePage(QWidget):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setObjectName("Page") self.setObjectName("Page")
self._sampler = Sampler(available_sources())
self._host_ws: QWebSocket | None = None self._host_ws: QWebSocket | None = None
self._guest_ws: QWebSocket | None = None self._guest_ws: QWebSocket | None = None
self._pty: PtySession | None = None self._pty: PtySession | None = None
self._pty_notifier: QSocketNotifier | None = None self._pty_notifier: QSocketNotifier | None = None
self._last_report = None self._guest_can_type = False
self._last_inv = None self._fs: QWidget | None = None
self._timer = QTimer(self) self._fs_state = None
self._timer.setInterval(2000)
self._timer.timeout.connect(self._stream)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -74,19 +69,19 @@ class SharePage(QWidget):
title = QLabel("Share") title = QLabel("Share")
title.setObjectName("PageTitle") title.setObjectName("PageTitle")
root.addWidget(title) root.addWidget(title)
root.addWidget(self._build_host()) root.addWidget(self._build_host(), 1)
root.addWidget(self._build_guest(), 1) root.addWidget(self._build_guest(), 1)
# ------------------------------------------------------------------ host # ------------------------------------------------------------------ host
def _build_host(self) -> QFrame: def _build_host(self) -> QFrame:
card, v = _card("Start a shared session") card, v = _card("Host a terminal session")
self._host_status = QLabel("Let someone with an account view your machine, read-only.") self._host_status = QLabel("Share a live terminal with someone who has an account.")
self._host_status.setObjectName("Muted") self._host_status.setObjectName("Muted")
self._host_status.setWordWrap(True) self._host_status.setWordWrap(True)
v.addWidget(self._host_status) v.addWidget(self._host_status)
row = QHBoxLayout() row = QHBoxLayout()
self._start_btn = QPushButton("Start shared session") self._start_btn = QPushButton("Start session")
self._start_btn.setObjectName("PrimaryButton") self._start_btn.setObjectName("PrimaryButton")
self._start_btn.clicked.connect(self._start_host) self._start_btn.clicked.connect(self._start_host)
self._stop_btn = QPushButton("Stop") self._stop_btn = QPushButton("Stop")
@@ -95,28 +90,33 @@ class SharePage(QWidget):
self._code_label = QLabel("") self._code_label = QLabel("")
self._code_label.setStyleSheet("font-weight:700; font-size:18px; color:#38bdf8; background:transparent;") self._code_label.setStyleSheet("font-weight:700; font-size:18px; color:#38bdf8; background:transparent;")
self._code_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._code_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._host_fs_btn = QPushButton("Full screen")
self._host_fs_btn.setEnabled(False)
self._host_fs_btn.clicked.connect(lambda: self._enter_fullscreen(self._host_term))
row.addWidget(self._start_btn) row.addWidget(self._start_btn)
row.addWidget(self._stop_btn) row.addWidget(self._stop_btn)
row.addSpacing(12) row.addSpacing(12)
row.addWidget(self._code_label) row.addWidget(self._code_label)
row.addStretch(1) row.addStretch(1)
row.addWidget(self._host_fs_btn)
v.addLayout(row) v.addLayout(row)
self._allow_term = QCheckBox("Allow remote terminal — the guest runs commands as your user (you read along; you can type too, e.g. a sudo password)") self._allow_input = QCheckBox(
self._allow_term.setStyleSheet("color:#fb923c; background:transparent;") "Allow the guest to type — they run commands as your user (off = they only watch)")
self._allow_term.toggled.connect(self._toggle_terminal) self._allow_input.setStyleSheet("color:#fb923c; background:transparent;")
v.addWidget(self._allow_term) self._allow_input.toggled.connect(self._send_terminal_state)
v.addWidget(self._allow_input)
self._host_term = TerminalView() self._host_term = TerminalView()
self._host_term.keys.connect(lambda b: self._pty.write(b) if self._pty else None) self._host_term.keys.connect(lambda b: self._pty.write(b) if self._pty else None)
self._host_term.resized.connect(lambda r, c: self._pty.set_size(r, c) if self._pty else None) self._host_term.resized.connect(lambda r, c: self._pty.set_size(r, c) if self._pty else None)
self._host_term.setVisible(False) self._host_term.setVisible(False)
v.addWidget(self._host_term) v.addWidget(self._host_term, 1)
return card return card
def _start_host(self) -> None: def _start_host(self) -> None:
if not load_token(): if not load_token():
self._host_status.setText("Set a Gitea access token in Setup → Account access first.") self._host_status.setText("Set a Gitea access token in Settings → Account access first.")
return return
self._host_status.setText("Connecting to the relay…") self._host_status.setText("Connecting to the relay…")
self._start_btn.setEnabled(False) self._start_btn.setEnabled(False)
@@ -135,37 +135,25 @@ class SharePage(QWidget):
if data.get("error"): if data.get("error"):
self._host_status.setText(f"Rejected: {data['error']}") self._host_status.setText(f"Rejected: {data['error']}")
return return
if "code" in data: # relay handshake if "code" in data: # relay handshake → start the terminal immediately
self._code_label.setText(data["code"]) self._code_label.setText(data["code"])
self._host_status.setText(f"Sharing as {data.get('user', '?')} — give this code to whoever should view your machine.") self._host_status.setText(
f"Sharing as {data.get('user', '?')} — give this code to whoever should connect.")
self._stop_btn.setEnabled(True) self._stop_btn.setEnabled(True)
self._host_ws.sendTextMessage(share.host_full_frame(self._sampler))
self._send_terminal_state()
if self._allow_term.isChecked():
self._start_pty() self._start_pty()
self._timer.start() self._send_terminal_state()
return return
kind = data.get("type") # frames forwarded from a guest kind = data.get("type")
if kind == "req_full": if kind == "req_full": # a guest joined — tell them their typing permission
# A guest just joined — send a full frame AND the current terminal state, so a
# guest that joins *after* the host enabled the terminal still gets access.
self._host_ws.sendTextMessage(share.host_full_frame(self._sampler))
self._send_terminal_state() self._send_terminal_state()
elif kind == "pty_in" and self._pty: elif kind == "pty_in" and self._pty and self._allow_input.isChecked():
self._pty.write(base64.b64decode(data["data"])) self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty: elif kind == "pty_resize" and self._pty and self._allow_input.isChecked():
self._pty.set_size(int(data["rows"]), int(data["cols"])) self._pty.set_size(int(data["rows"]), int(data["cols"]))
def _toggle_terminal(self, on: bool) -> None:
if on and self._host_ws and self._code_label.text():
self._start_pty()
elif not on:
self._stop_pty()
self._send_terminal_state()
def _send_terminal_state(self) -> None: def _send_terminal_state(self) -> None:
if self._host_ws and self._code_label.text(): if self._host_ws and self._code_label.text():
self._host_ws.sendTextMessage(json.dumps({"type": "terminal", "enabled": self._allow_term.isChecked()})) self._host_ws.sendTextMessage(json.dumps({"type": "terminal", "enabled": self._allow_input.isChecked()}))
def _start_pty(self) -> None: def _start_pty(self) -> None:
if self._pty: if self._pty:
@@ -176,15 +164,15 @@ class SharePage(QWidget):
self._pty_notifier.activated.connect(self._on_pty_output) self._pty_notifier.activated.connect(self._on_pty_output)
self._host_term.reset() self._host_term.reset()
self._host_term.setVisible(True) self._host_term.setVisible(True)
self._host_fs_btn.setEnabled(True)
self._host_term.setFocus()
def _on_pty_output(self) -> None: def _on_pty_output(self) -> None:
if not self._pty: if not self._pty:
return return
data = self._pty.read() data = self._pty.read()
if not data: # shell exited / EOF if not data: # shell exited
self._stop_pty() self._stop_host()
self._send_terminal_state()
self._allow_term.setChecked(False)
return return
self._host_term.feed(data) self._host_term.feed(data)
if self._host_ws: if self._host_ws:
@@ -198,13 +186,9 @@ class SharePage(QWidget):
self._pty.close() self._pty.close()
self._pty = None self._pty = None
self._host_term.setVisible(False) self._host_term.setVisible(False)
self._host_fs_btn.setEnabled(False)
def _stream(self) -> None:
if self._host_ws:
self._host_ws.sendTextMessage(share.host_snapshot_frame(self._sampler))
def _stop_host(self) -> None: def _stop_host(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
if self._host_ws: if self._host_ws:
self._host_ws.close() self._host_ws.close()
@@ -215,7 +199,6 @@ class SharePage(QWidget):
self._host_status.setText("Stopped sharing.") self._host_status.setText("Stopped sharing.")
def _host_closed(self) -> None: def _host_closed(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
self._start_btn.setEnabled(True) self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False) self._stop_btn.setEnabled(False)
@@ -225,7 +208,7 @@ class SharePage(QWidget):
# ----------------------------------------------------------------- guest # ----------------------------------------------------------------- guest
def _build_guest(self) -> QFrame: def _build_guest(self) -> QFrame:
card, v = _card("Join a shared session") card, v = _card("Join a terminal session")
row = QHBoxLayout() row = QHBoxLayout()
self._code_input = QLineEdit() self._code_input = QLineEdit()
self._code_input.setPlaceholderText("Enter share code") self._code_input.setPlaceholderText("Enter share code")
@@ -237,37 +220,31 @@ class SharePage(QWidget):
self._leave_btn = QPushButton("Leave") self._leave_btn = QPushButton("Leave")
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
self._leave_btn.clicked.connect(self._leave) self._leave_btn.clicked.connect(self._leave)
self._guest_fs_btn = QPushButton("Full screen")
self._guest_fs_btn.setEnabled(False)
self._guest_fs_btn.clicked.connect(lambda: self._enter_fullscreen(self._guest_term))
row.addWidget(self._code_input) row.addWidget(self._code_input)
row.addWidget(self._join_btn) row.addWidget(self._join_btn)
row.addWidget(self._leave_btn) row.addWidget(self._leave_btn)
row.addStretch(1) row.addStretch(1)
row.addWidget(self._guest_fs_btn)
v.addLayout(row) v.addLayout(row)
self._guest_status = QLabel("") self._guest_status = QLabel("")
self._guest_status.setObjectName("Muted") self._guest_status.setObjectName("Muted")
self._guest_status.setWordWrap(True)
v.addWidget(self._guest_status) v.addWidget(self._guest_status)
self._view = QTextEdit()
self._view.setObjectName("Report")
self._view.setReadOnly(True)
self._view.setVisible(False)
self._view.setMinimumHeight(200)
v.addWidget(self._view)
self._term_label = QLabel("")
self._term_label.setObjectName("Muted")
self._term_label.setVisible(False)
v.addWidget(self._term_label)
self._guest_term = TerminalView() self._guest_term = TerminalView()
self._guest_term.keys.connect(self._guest_key) self._guest_term.keys.connect(self._guest_key)
self._guest_term.resized.connect(self._guest_resize) self._guest_term.resized.connect(self._guest_resize)
self._guest_term.setVisible(False) self._guest_term.setVisible(False)
v.addWidget(self._guest_term) v.addWidget(self._guest_term, 1)
return card return card
def _join(self) -> None: def _join(self) -> None:
code = self._code_input.text().strip().upper() code = self._code_input.text().strip().upper()
if not load_token(): if not load_token():
self._guest_status.setText("Set a Gitea access token in Setup → Account access first.") self._guest_status.setText("Set a Gitea access token in Settings → Account access first.")
return return
if not code: if not code:
self._guest_status.setText("Enter a share code.") self._guest_status.setText("Enter a share code.")
@@ -290,46 +267,40 @@ class SharePage(QWidget):
self._guest_status.setText(data["error"]) self._guest_status.setText(data["error"])
return return
if "joined" in data: if "joined" in data:
self._guest_status.setText(f"Viewing {data.get('host', '?')}'s machine — read-only.") self._guest_status.setText(f"Connected to {data.get('host', '?')}'s terminal — watching.")
self._leave_btn.setEnabled(True) self._leave_btn.setEnabled(True)
self._view.setVisible(True) self._guest_fs_btn.setEnabled(True)
self._guest_term.reset()
self._guest_term.setVisible(True)
self._guest_ws.sendTextMessage(json.dumps({"type": "req_full"})) self._guest_ws.sendTextMessage(json.dumps({"type": "req_full"}))
return return
kind = data.get("type") kind = data.get("type")
if kind in ("full", "snapshot"): if kind == "terminal":
if kind == "full": self._guest_can_type = bool(data.get("enabled"))
self._last_report = data.get("report") self._guest_status.setText(
self._last_inv = data.get("inventory") "You can type — your keystrokes run on the host's machine."
self._view.setHtml(share.guest_html(data.get("snapshot"), self._last_report, self._last_inv)) if self._guest_can_type else "Read-only — watching the host's terminal.")
elif kind == "terminal": if self._guest_can_type:
self._set_terminal_visible(bool(data.get("enabled"))) self._guest_term.setFocus()
self._guest_resize(*self._guest_term.grid())
elif kind == "pty": elif kind == "pty":
self._guest_term.feed(base64.b64decode(data["data"])) self._guest_term.feed(base64.b64decode(data["data"]))
def _set_terminal_visible(self, enabled: bool) -> None:
self._term_label.setVisible(True)
self._term_label.setText("Terminal enabled by host — your keystrokes run on their machine. Click here and type."
if enabled else "Terminal not enabled by the host.")
self._guest_term.setVisible(enabled)
if enabled:
self._guest_term.reset()
self._guest_resize(*self._guest_term.grid())
self._guest_term.setFocus()
def _guest_key(self, data: bytes) -> None: def _guest_key(self, data: bytes) -> None:
if self._guest_ws: if self._guest_ws and self._guest_can_type:
self._guest_ws.sendTextMessage(json.dumps({"type": "pty_in", "data": _b64(data)})) self._guest_ws.sendTextMessage(json.dumps({"type": "pty_in", "data": _b64(data)}))
def _guest_resize(self, rows: int, cols: int) -> None: def _guest_resize(self, rows: int, cols: int) -> None:
if self._guest_ws: if self._guest_ws and self._guest_can_type:
self._guest_ws.sendTextMessage(json.dumps({"type": "pty_resize", "rows": rows, "cols": cols})) self._guest_ws.sendTextMessage(json.dumps({"type": "pty_resize", "rows": rows, "cols": cols}))
def _leave(self) -> None: def _leave(self) -> None:
if self._guest_ws: if self._guest_ws:
self._guest_ws.close() self._guest_ws.close()
self._guest_ws = None self._guest_ws = None
for w in (self._view, self._term_label, self._guest_term): self._guest_term.setVisible(False)
w.setVisible(False) self._guest_fs_btn.setEnabled(False)
self._guest_can_type = False
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
self._join_btn.setEnabled(True) self._join_btn.setEnabled(True)
self._guest_status.setText("Left the session.") self._guest_status.setText("Left the session.")
@@ -337,11 +308,40 @@ class SharePage(QWidget):
def _guest_closed(self) -> None: def _guest_closed(self) -> None:
self._join_btn.setEnabled(True) self._join_btn.setEnabled(True)
self._leave_btn.setEnabled(False) self._leave_btn.setEnabled(False)
if self._view.isVisible(): if self._guest_term.isVisible():
self._guest_status.setText("Session ended (host disconnected).") self._guest_status.setText("Session ended (host disconnected).")
# --------------------------------------------------------------- full screen
def _enter_fullscreen(self, term: TerminalView) -> None:
if self._fs is not None:
return
parent_layout = term.parentWidget().layout()
self._fs_state = (parent_layout, parent_layout.indexOf(term), term)
self._fs = QWidget()
self._fs.setStyleSheet("background:#0d0f13;")
lay = QVBoxLayout(self._fs)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
hint = QLabel("Esc to exit full screen")
hint.setObjectName("Muted")
hint.setStyleSheet("padding:4px 10px; background:#15181e;")
lay.addWidget(hint)
lay.addWidget(term, 1)
QShortcut(QKeySequence(Qt.Key.Key_Escape), self._fs, activated=self._leave_fullscreen)
self._fs.showFullScreen()
term.setFocus()
def _leave_fullscreen(self) -> None:
if self._fs is None:
return
parent_layout, index, term = self._fs_state
parent_layout.insertWidget(index, term)
self._fs.close()
self._fs = None
self._fs_state = None
term.setFocus()
def shutdown(self) -> None: def shutdown(self) -> None:
self._timer.stop()
self._stop_pty() self._stop_pty()
for ws in (self._host_ws, self._guest_ws): for ws in (self._host_ws, self._guest_ws):
if ws: if ws:
+85 -22
View File
@@ -1,30 +1,66 @@
"""A minimal terminal view: renders PTY output via pyte and emits keystrokes (M12, Tier 3). """A terminal view: renders PTY output via pyte (with colors) and emits keystrokes (M12).
Used by both sides of a shared session the host (mirrors its local PTY, can also type, e.g. Used by both sides of a shared session the host (mirrors its local PTY, can also type, e.g.
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Monochrome for a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Renders pyte's
now; cursor addressing / layout (vim, top) work via pyte. per-cell foreground/background/bold/reverse so the host's real shell (e.g. fish) keeps its
colors and theming; cursor addressing (vim, top) works via pyte. Scrollback is preserved.
""" """
from __future__ import annotations from __future__ import annotations
import html as _html
import pyte import pyte
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor from PySide6.QtGui import QFontDatabase, QFontMetrics
from PySide6.QtWidgets import QPlainTextEdit from PySide6.QtWidgets import QTextEdit
# ANSI named colors → RGB (a dark, modern palette). pyte also yields 6-hex strings for
# 256-color / truecolor, which we pass through, and "default" which maps to the theme.
_FG_DEFAULT = "#d6dae0"
_BG_DEFAULT = "#0d0f13"
_NAMED = {
"black": "#2a2f39", "red": "#f87171", "green": "#4ade80", "brown": "#e5c07b",
"yellow": "#e5c07b", "blue": "#60a5fa", "magenta": "#c084fc", "cyan": "#38bdf8",
"white": "#d6dae0",
}
_BRIGHT = { # bold brightens the standard 8
"black": "#5b626c", "red": "#fca5a5", "green": "#86efac", "brown": "#fde68a",
"yellow": "#fde68a", "blue": "#93c5fd", "magenta": "#d8b4fe", "cyan": "#7dd3fc",
"white": "#ffffff",
}
_HISTORY_RENDER = 400 # cap scrollback rows rendered per frame (perf)
class TerminalView(QPlainTextEdit): def _color(name: str, default: str, bright: bool) -> str:
if name == "default":
return default
table = _BRIGHT if bright else _NAMED
if name in table:
return table[name]
if len(name) == 6: # pyte 256/truecolor as a hex string
try:
int(name, 16)
return "#" + name
except ValueError:
pass
return default
class TerminalView(QTextEdit):
keys = Signal(bytes) # user keystrokes -> bytes for the PTY keys = Signal(bytes) # user keystrokes -> bytes for the PTY
resized = Signal(int, int) # rows, cols resized = Signal(int, int) # rows, cols
def __init__(self, rows: int = 24, cols: int = 80): def __init__(self, rows: int = 24, cols: int = 80):
super().__init__() super().__init__()
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.setUndoRedoEnabled(False) self.setUndoRedoEnabled(False)
self.setMinimumHeight(260) self.setReadOnly(False) # we capture keys ourselves; no local editing
self.setStyleSheet(f"QTextEdit {{ background: {_BG_DEFAULT}; border: none; }}")
self.setMinimumHeight(320)
self._rows, self._cols = rows, cols self._rows, self._cols = rows, cols
self._screen = pyte.HistoryScreen(cols, rows, history=1000, ratio=0.5) self._screen = pyte.HistoryScreen(cols, rows, history=2000, ratio=0.5)
self._stream = pyte.ByteStream(self._screen) self._stream = pyte.ByteStream(self._screen)
def grid(self) -> tuple[int, int]: def grid(self) -> tuple[int, int]:
@@ -38,24 +74,51 @@ class TerminalView(QPlainTextEdit):
self._screen.reset() self._screen.reset()
self._render() self._render()
def _row_text(self, row) -> str: # --- rendering ---------------------------------------------------------------------
return "".join(row[x].data for x in range(self._cols)).rstrip() def _span(self, style, text: str) -> str:
fg_name, bg_name, bold, reverse = style
fg = _color(fg_name, _FG_DEFAULT, bold)
bg = _color(bg_name, _BG_DEFAULT, False)
if reverse:
fg, bg = bg, fg
esc = _html.escape(text, quote=False).replace(" ", "&nbsp;")
weight = "font-weight:bold;" if bold else ""
return f'<span style="color:{fg};background:{bg};{weight}">{esc}</span>'
def _row_html(self, row, cursor_x) -> str:
out: list[str] = []
buf: list[str] = []
cur_style = None
for x in range(self._cols):
ch = row[x]
reverse = ch.reverse
if cursor_x is not None and x == cursor_x and self.hasFocus():
reverse = not reverse # block cursor = inverted cell
style = (ch.fg, ch.bg, ch.bold, reverse)
if style != cur_style:
if buf:
out.append(self._span(cur_style, "".join(buf)))
buf = []
cur_style = style
buf.append(ch.data or " ")
if buf:
out.append(self._span(cur_style, "".join(buf)))
return "".join(out)
def _render(self) -> None: def _render(self) -> None:
bar = self.verticalScrollBar() bar = self.verticalScrollBar()
at_bottom = bar.value() >= bar.maximum() - 2 at_bottom = bar.value() >= bar.maximum() - 2
prev = bar.value() prev = bar.value()
history = [self._row_text(r) for r in self._screen.history.top] # scrollback
self.setPlainText("\n".join(history + list(self._screen.display))) history = list(self._screen.history.top)[-_HISTORY_RENDER:]
if at_bottom: # follow output; place caret at the real (row, col) lines = [self._row_html(r, None) for r in history]
cursor = self.textCursor() cur_y = self._screen.cursor.y
cursor.movePosition(QTextCursor.MoveOperation.Start) for y in range(self._rows):
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y) cursor_x = self._screen.cursor.x if y == cur_y else None
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x) lines.append(self._row_html(self._screen.buffer[y], cursor_x))
self.setTextCursor(cursor) self.setHtml('<div style="white-space:pre;line-height:100%;">' + "<br>".join(lines) + "</div>")
self.ensureCursorVisible()
else: # user scrolled up to read — keep their place bar.setValue(bar.maximum() if at_bottom else prev)
bar.setValue(prev)
def resizeEvent(self, event): # noqa: N802 (Qt override) def resizeEvent(self, event): # noqa: N802 (Qt override)
super().resizeEvent(event) super().resizeEvent(event)
+7
View File
@@ -64,6 +64,13 @@ class GuiSmokeTests(unittest.TestCase):
self.assertIn("14.2 / 31.0 GB", tray._mem_act.text()) self.assertIn("14.2 / 31.0 GB", tray._mem_act.text())
self.assertEqual(tray._status_act.text(), "● Normal") self.assertEqual(tray._status_act.text(), "● Normal")
def test_setup_wizard_constructs(self):
from rigdoctor.gui.setup_wizard import SetupWizard
wizard = SetupWizard()
self.assertEqual(wizard._stack.count(), 5) # welcome/bundles/install/trigger/finish
self.assertTrue(wizard._bundle_checks)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -1
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from rigdoctor.core import installer from rigdoctor.core import catalog, installer
from rigdoctor.core.catalog import Component from rigdoctor.core.catalog import Component
from rigdoctor.core.updates import is_newer from rigdoctor.core.updates import is_newer
@@ -31,6 +31,13 @@ class InstallerTests(unittest.TestCase):
rc, _ = installer.install_packages([]) rc, _ = installer.install_packages([])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
def test_by_bundle_groups_all_components(self):
groups = catalog.by_bundle()
flat = [c for comps in groups.values() for c in comps]
self.assertEqual(len(flat), len(catalog.COMPONENTS))
self.assertIn("Gaming", groups)
self.assertIn("Diagnostics", groups)
class UpdateTests(unittest.TestCase): class UpdateTests(unittest.TestCase):
def test_is_newer(self): def test_is_newer(self):
-38
View File
@@ -1,38 +0,0 @@
"""Tests for M12 relay frames + guest HTML rendering (host/guest data shapes)."""
import json
import unittest
from rigdoctor.core import share
from rigdoctor.core.sampler import Sampler
from rigdoctor.core.sources import available_sources
class RelayFrameTests(unittest.TestCase):
def setUp(self):
self.sampler = Sampler(available_sources())
def test_full_frame_shape(self):
frame = json.loads(share.host_full_frame(self.sampler))
self.assertEqual(frame["type"], "full")
self.assertIn("groups", frame["snapshot"])
self.assertIsInstance(frame["report"], list)
self.assertIsInstance(frame["inventory"], dict)
def test_snapshot_frame_shape(self):
frame = json.loads(share.host_snapshot_frame(self.sampler))
self.assertEqual(frame["type"], "snapshot")
self.assertIn("groups", frame["snapshot"])
def test_guest_html_renders(self):
snap = {"groups": {"gpu": [{"name": "temp", "value": 51.0, "unit": "°C"}]}}
report = [{"severity": "ok", "category": "Logs", "title": "No errors"}]
inv = {"System": {"Kernel": "7.0.0"}}
html = share.guest_html(snap, report, inv)
self.assertIn("51.0 °C", html)
self.assertIn("No errors", html)
self.assertIn("Kernel", html)
if __name__ == "__main__":
unittest.main()
-46
View File
@@ -1,46 +0,0 @@
"""Tests for M12 Tier 2 share server: token gating + endpoints."""
import json
import threading
import unittest
import urllib.error
import urllib.request
from rigdoctor.core import share
class ShareServerTests(unittest.TestCase):
def setUp(self):
self.srv, self.token = share.make_server("127.0.0.1", 0)
self.port = self.srv.server_address[1]
self.thread = threading.Thread(target=self.srv.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.srv.shutdown()
def _url(self, path, token=None):
q = f"?t={token}" if token else ""
return f"http://127.0.0.1:{self.port}{path}{q}"
def test_requires_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(self._url("/api/snapshot"), timeout=10)
self.assertEqual(cm.exception.code, 403)
def test_bad_token_rejected(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(self._url("/api/snapshot", "wrong"), timeout=10)
self.assertEqual(cm.exception.code, 403)
def test_snapshot_with_token(self):
data = json.load(urllib.request.urlopen(self._url("/api/snapshot", self.token), timeout=10))
self.assertIn("groups", data)
def test_page_served(self):
body = urllib.request.urlopen(self._url("/", self.token), timeout=10).read()
self.assertIn(b"read-only share", body)
if __name__ == "__main__":
unittest.main()