Compare commits

..

5 Commits

Author SHA1 Message Date
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
jessey 7804893054 Merge pull request 'feat(m9): systemd --user trigger modes + game-launch watcher — 0.23.0' (#19) from feat/m9-installer into main
release / release (push) Successful in 14s
Reviewed-on: #19
2026-05-22 07:55:47 +00:00
jessey bf3ac4af1a feat(m9): systemd --user trigger modes + game-launch watcher — 0.23.0
D6 trigger modes, no root:
- core/service.py: write/enable `systemd --user` units; apply_mode(manual/
  always-on/game-launch) reconciles the recorder + watcher services; status().
- core/watcher.py + `rigdoctor watch`: poll Steam RunningAppID, auto-bracket a
  focused capture (D12 zero-config fallback; wrapper stays primary).
- CLI `rigdoctor service status|mode`; config `trigger_mode`.
- GUI Settings: "Recording trigger" dropdown (Apply runs apply_mode off-thread).
- Tests for unit generation, mode reconciliation, watcher transitions/parse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:55:36 +02:00
19 changed files with 697 additions and 444 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions).
## [0.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
### Added
- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**,
**always-on** (a background service records continuously), and **game-launch** (auto-records
while a Steam game runs). Set it from **Settings → Recording trigger** or
`rigdoctor service mode <manual|always-on|game-launch>`; `rigdoctor service status` shows it.
`core/service.py` writes/enables the user units.
- **Zero-config game-launch watcher** (`core/watcher.py`, `rigdoctor watch`) — polls Steam's
RunningAppID and brackets a focused capture around the running game (the D12 fallback for users
who don't add the `wrap` launch option; the wrapper stays the precise primary path).
## [0.22.0] - 2026-05-22
### Added
- **M6 breadth.** Environment checks now also report **GPU PowerMizer** mode (NVIDIA, X — flags
+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
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
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
set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's
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 | ✅ |
| M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ✅ |
| 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 | ✅ |
| ~~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:*
config/module selection + `systemd --user`
service enable.
- **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,
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
default; read-write only on explicit consent — a deliberate exception to D9). Per-session
consent, ephemeral revocable tokens, audit log.
- **M12 Session sharing / remote assist** (D16, scoped to terminal-only by **D23**) — a single
mode: a **host-consented shared terminal** over the relay. The host shares a real PTY running
their `$SHELL` (colors/theming preserved — fish etc.); the guest watches live and can type
**only if the host allows it** (otherwise read-only) — a deliberate, consent-gated exception
to D9. The host reads along and can type too (e.g. a sudo password, which stays local). Either
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
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`
+11 -11
View File
@@ -61,9 +61,11 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user`
service.
- [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install
(`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`**
(no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection
config + `systemd --user` service enable + trigger-mode pick.
(`rigdoctor install`, GUI Settings); **user-local `install.sh` + self-extracting `.run`**
(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
Settings "Recording trigger") incl. the zero-config **game-launch watcher**
(`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install.
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later)
@@ -77,14 +79,12 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
NVIDIA persistence, PCIe ASPM, swappiness, THP) via a single pkexec prompt, no reboot.
GRUB-based fixes + CPU mitigations remain suggestion-only.
## Phase 6 — Session sharing / remote assist (M12, D16)
Escalating ladder, built in order:
- [ ] Tier 1: `share export` — diagnostic bundle (inventory + recent log + report); B opens
it in RigDoctor. One-way, safest.
- [x] Tier 2: live read-only view — `rigdoctor share serve` (stdlib HTTP, token-gated:
sensors + health + inventory). Remote = user-chosen tunnel; GUI controls still to add.
- [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).
## Phase 6 — Session sharing / remote assist (M12, D16 → scoped to terminal-only by D23)
- [x] **Shared terminal** — a real PTY (host's `$SHELL`) shared over the relay, color-rendered
(pyte), full-screen-able; the guest watches and may type only on host consent (D9
exception); host reads along + can type (sudo). The single share mode.
- [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the
shared terminal is the only sharing mode.
> **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.
+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
list/bundling is final per D14.
### M12 — Session sharing / remote assist (D16)
Lets a user (A) grant a helper (B) inspection access, as an escalating, consent-driven
ladder: (1) **diagnostic bundle export** (inventory + recent capture log + report, one-way);
(2) **live read-only view** of the dashboard + logs over a user-chosen tunnel
(Tailscale/cloudflared/SSH — no RigDoctor-hosted relay); (3) **gated interactive terminal**
wrapping an existing tool (tmate/sshx), read-only by default, read-write only on explicit
consent. Per-session consent, ephemeral revocable tokens, permission escalation (view ≠
shell), and a session audit log. Tier 3 is a deliberate, consent-gated exception to the
read-only stance (D9). Built in Phase 6.
### M12 — Session sharing / remote assist (D16, scoped to terminal-only by D23)
Lets a user (A) grant a helper (B) a **shared terminal** over the relay: A shares a real PTY
running their shell; B watches live and may type **only if A allows it** (otherwise read-only)
— a deliberate, consent-gated exception to the read-only stance (D9). A reads along and can
type too (e.g. a sudo password, which stays local and is never sent to B). Account-gated by the
Gitea token; per-session share code. The shared terminal preserves colors/theming and can be
viewed full-screen. *(The earlier read-only stats view / bundle export were dropped — D23.)*
## 5. Non-functional requirements
- **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.22.0"
version = "0.25.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.22.0"
__version__ = "0.25.0"
+40 -13
View File
@@ -289,12 +289,6 @@ def cmd_uninstall(args) -> int:
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:
"""Internal: emit root-only data (SMART + dmidecode) as JSON, run via pkexec at launch."""
from dataclasses import asdict
@@ -416,6 +410,34 @@ def cmd_wrap(args) -> int:
return wrap.run(args.command)
def cmd_watch(args) -> int:
from .core import watcher
interval = args.interval or load_config().get("interval", 1.0)
print("Watching for a running Steam game (Ctrl-C to stop)…")
return watcher.watch(interval=max(2.0, interval))
def cmd_service(args) -> int:
from .core import service
sub = args.service_cmd or "status"
if sub == "mode":
ok, msg = service.apply_mode(args.mode)
print(f"Trigger mode set to '{args.mode}'.")
if not ok and msg:
print(f" note: {msg}")
return 0 if ok or not service.available() else 1
info = service.status()
print(f"Trigger mode: {info['mode']}")
print(f"systemd --user: {'available' if info['available'] else 'not available'}")
if info["available"]:
print(f" recorder service: {'active' if info.get('recorder_active') else 'inactive'}")
print(f" watcher service: {'active' if info.get('watch_active') else 'inactive'}")
return 0
def cmd_gameenv(args) -> int:
from dataclasses import asdict
@@ -572,13 +594,6 @@ def build_parser() -> argparse.ArgumentParser:
cp = sub.add_parser("collect-priv", help=argparse.SUPPRESS) # internal: run via pkexec
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.add_argument("--json", action="store_true", help="output JSON")
inv.add_argument("--markdown", action="store_true", help="output Markdown (for forum/bug reports)")
@@ -618,6 +633,18 @@ def build_parser() -> argparse.ArgumentParser:
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
wrap_p.set_defaults(func=cmd_wrap)
watch_p = sub.add_parser("watch", help="auto-capture while a Steam game runs (game-launch trigger)")
watch_p.add_argument("-n", "--interval", type=float, default=None, help="poll interval (s)")
watch_p.set_defaults(func=cmd_watch)
svc_p = sub.add_parser("service", help="crash-logger trigger mode + systemd --user service (M9/D6)")
svc_sub = svc_p.add_subparsers(dest="service_cmd")
svc_sub.add_parser("status", help="show the trigger mode and service state").set_defaults(func=cmd_service)
mode_p = svc_sub.add_parser("mode", help="set the trigger mode")
mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch"))
mode_p.set_defaults(func=cmd_service)
svc_p.set_defaults(func=cmd_service, service_cmd=None)
return p
+1
View File
@@ -154,6 +154,7 @@ DEFAULTS: dict = {
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"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
}
+118
View File
@@ -0,0 +1,118 @@
"""`systemd --user` services for the crash logger + game watcher (M9 / D6 trigger modes).
Three trigger modes (D6): **manual** (no service — start/stop by hand), **always-on** (a user
service samples continuously, bounded by log rotation), and **game-launch** (a watcher service
auto-brackets a capture around each game). No root: everything is a `systemd --user` unit in
``~/.config/systemd/user``. Degrades gracefully when systemd isn't available.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
from .. import config
UNIT_DIR = Path(os.path.expanduser("~/.config/systemd/user"))
RECORDER_UNIT = "rigdoctor-recorder.service"
WATCH_UNIT = "rigdoctor-watch.service"
MODES = ("manual", "always-on", "game-launch")
_UNITS = {
RECORDER_UNIT: ("RigDoctor crash-capture recorder (always-on)", ["record", "run"]),
WATCH_UNIT: ("RigDoctor game-launch watcher", ["watch"]),
}
def available() -> bool:
return shutil.which("systemctl") is not None
def _rigdoctor_bin() -> str:
exe = Path(sys.executable).with_name("rigdoctor") # next to the venv python
if exe.exists():
return str(exe)
return shutil.which("rigdoctor") or "rigdoctor"
def _systemctl(*args: str) -> tuple[int, str]:
try:
proc = subprocess.run(["systemctl", "--user", *args],
capture_output=True, text=True, timeout=20)
return proc.returncode, (proc.stdout + proc.stderr).strip()
except (OSError, subprocess.SubprocessError) as exc:
return 1, str(exc)
def unit_text(description: str, args: list[str]) -> str:
exec_cmd = " ".join([_rigdoctor_bin(), *args])
return (
"[Unit]\n"
f"Description={description}\n\n"
"[Service]\n"
"Type=simple\n"
f"ExecStart={exec_cmd}\n"
"Restart=on-failure\n"
"RestartSec=5\n\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def install_units() -> None:
"""Write/refresh both unit files and reload systemd (idempotent)."""
UNIT_DIR.mkdir(parents=True, exist_ok=True)
for name, (desc, args) in _UNITS.items():
(UNIT_DIR / name).write_text(unit_text(desc, args))
_systemctl("daemon-reload")
def is_active(name: str) -> bool:
return _systemctl("is-active", name)[0] == 0
def is_enabled(name: str) -> bool:
return _systemctl("is-enabled", name)[0] == 0
def _enable(name: str) -> tuple[int, str]:
return _systemctl("enable", "--now", name)
def _disable(name: str) -> tuple[int, str]:
return _systemctl("disable", "--now", name)
def apply_mode(mode: str) -> tuple[bool, str]:
"""Reconcile the user services to `mode` and persist it. Returns (ok, message)."""
if mode not in MODES:
return False, f"Unknown trigger mode: {mode}"
if not available():
config.update_config(trigger_mode=mode)
return False, "systemd --user isn't available — mode saved, but no service was changed."
install_units()
if mode == "always-on":
_disable(WATCH_UNIT)
rc, out = _enable(RECORDER_UNIT)
elif mode == "game-launch":
_disable(RECORDER_UNIT)
rc, out = _enable(WATCH_UNIT)
else: # manual
_disable(RECORDER_UNIT)
_disable(WATCH_UNIT)
rc, out = 0, ""
config.update_config(trigger_mode=mode)
return rc == 0, out
def status() -> dict:
"""Current trigger mode (config) + live service states (best-effort)."""
cfg = config.load_config()
info = {"available": available(), "mode": cfg.get("trigger_mode", "manual")}
if info["available"]:
info["recorder_active"] = is_active(RECORDER_UNIT)
info["watch_active"] = is_active(WATCH_UNIT)
return info
-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
+107
View File
@@ -0,0 +1,107 @@
"""Zero-config game-launch watcher (D12 fallback): poll Steam's RunningAppID and
auto-bracket a focused capture around the running game.
For users who won't add the `rigdoctor wrap %command%` launch option. Less precise than the
wrapper (it depends on Steam writing RunningAppID to registry.vdf, and only covers Steam), so
the wrapper stays the primary mechanism. Stdlib only; safe to run as a `systemd --user` service
(the game-launch trigger mode).
"""
from __future__ import annotations
import os
import signal
import time
from pathlib import Path
from . import reccontrol, steam
from .steam import _parse_vdf
_REGISTRY_CANDIDATES = ("~/.steam/registry.vdf", "~/.steam/steam/registry.vdf")
def _registry_path() -> Path | None:
for cand in _REGISTRY_CANDIDATES:
p = Path(os.path.expanduser(cand))
if p.exists():
return p
return None
def _find_key(data: dict, key: str):
"""Recursively find a (case-insensitive) scalar key in nested VDF dicts."""
target = key.lower()
for k, v in data.items():
if isinstance(v, dict):
found = _find_key(v, key)
if found is not None:
return found
elif k.lower() == target:
return v
return None
def running_appid() -> int:
"""The Steam appid currently running (0 if none / unknown)."""
path = _registry_path()
if path is None:
return 0
try:
data = _parse_vdf(path.read_text(encoding="utf-8", errors="replace"))
except OSError:
return 0
raw = _find_key(data, "RunningAppID")
try:
return int(raw)
except (TypeError, ValueError):
return 0
def transition(prev: int, current: int) -> str | None:
"""'start' when a game begins, 'stop' when it ends, else None."""
if current and not prev:
return "start"
if prev and not current:
return "stop"
return None
def _name_for(appid: int) -> str:
target = str(appid)
for g in steam.cached_games() or steam.scan_games(steam.selected_library_paths()):
if g.appid == target:
return g.name
return f"Steam app {appid}"
def watch(interval: float = 5.0) -> int:
"""Poll for a running Steam game and bracket a capture around it. Blocks until signalled."""
from . import diagnostic
stop = {"flag": False}
def _on_signal(_sig, _frame):
stop["flag"] = True
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
prev = 0
started = False
while not stop["flag"]:
current = running_appid()
action = transition(prev, current)
if action == "start" and not reccontrol.running_pid():
started = diagnostic.start(game=_name_for(current)) is not None
elif action == "stop" and started:
reccontrol.stop_background()
started = False
prev = current
# Sleep in small slices so a stop signal is handled promptly.
slept = 0.0
while slept < interval and not stop["flag"]:
time.sleep(min(0.25, interval - slept))
slept += 0.25
if started:
reccontrol.stop_background()
return 0
+51 -1
View File
@@ -9,6 +9,7 @@ from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
@@ -24,7 +25,7 @@ from PySide6.QtWidgets import (
)
from .. import config
from ..core import alerts, installer, sysenv, uninstall, updates
from ..core import alerts, installer, service, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN
@@ -52,6 +53,7 @@ _BACKEND_DESC = {
class SetupPage(QWidget):
_installed = Signal(int, str)
_upd_state = Signal(object)
_mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change
changed = Signal() # alert settings saved — main window re-applies them live
def __init__(self) -> None:
@@ -59,6 +61,7 @@ class SetupPage(QWidget):
self.setObjectName("Page")
self._installed.connect(self._on_installed)
self._upd_state.connect(self._on_upd_state)
self._mode_applied.connect(self._on_mode_applied)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
@@ -123,6 +126,35 @@ class SetupPage(QWidget):
alerts_layout.addLayout(alerts_buttons)
root.addWidget(alerts_card)
# Recording trigger (M9 / D6): when the crash logger runs.
trig_card, trig_layout = _panel("Recording trigger")
trig_desc = QLabel(
"When the crash logger runs (uses a systemd --user service):\n"
"• Manual — you start/stop it yourself.\n"
"• Always-on — a background service records continuously.\n"
"• Game-launch — auto-records while a Steam game is running."
)
trig_desc.setObjectName("Muted")
trig_desc.setWordWrap(True)
trig_layout.addWidget(trig_desc)
trig_row = QHBoxLayout()
self._trigger = QComboBox()
self._trigger.addItems(list(service.MODES))
apply_trigger = QPushButton("Apply")
apply_trigger.setObjectName("PrimaryButton")
apply_trigger.clicked.connect(self._apply_trigger)
trig_row.addWidget(self._trigger, 1)
trig_row.addWidget(apply_trigger)
trig_layout.addLayout(trig_row)
self._trigger_status = QLabel("")
self._trigger_status.setObjectName("Muted")
self._trigger_status.setWordWrap(True)
trig_layout.addWidget(self._trigger_status)
if not service.available():
apply_trigger.setEnabled(False)
self._trigger_status.setText("systemd --user isn't available on this system.")
root.addWidget(trig_card)
# Account access (M13/M12): one Gitea token gates updates and session sharing.
upd_card, upd_layout = _panel("Account access")
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
@@ -167,8 +199,26 @@ class SetupPage(QWidget):
self._refresh()
self._load_alerts()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._refresh_update_status()
# --- recording trigger (M9) -----------------------------------------------
def _apply_trigger(self) -> None:
mode = self._trigger.currentText()
self._trigger_status.setText(f"Applying “{mode}”… (may take a moment)")
threading.Thread(target=self._work_trigger, args=(mode,), daemon=True).start()
def _work_trigger(self, mode: str) -> None:
ok, msg = service.apply_mode(mode)
self._mode_applied.emit((mode, ok, msg))
def _on_mode_applied(self, result) -> None:
mode, ok, msg = result
if ok:
self._trigger_status.setText(f"Recording trigger set to “{mode}”.")
else:
self._trigger_status.setText(f"{mode}” saved. {msg}")
# --- alerts (M8) ----------------------------------------------------------
@staticmethod
def _spin() -> QDoubleSpinBox:
+100 -100
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,
a full **PTY terminal** is shared: the guest types and the commands run on the host (as the
host's user), the host reads along, and the host can type too e.g. a sudo password, which
stays local and is never sent to the guest.
The host shares a real PTY running their shell; the guest watches it live and — only if the
host ticks "Allow the guest to type" — can run commands (as the host's user). The host reads
along and can type too, e.g. a sudo password, which 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
@@ -11,7 +12,8 @@ from __future__ import annotations
import base64
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.QtWidgets import (
QCheckBox,
@@ -20,16 +22,12 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ..config import load_config, load_token
from ..core import share
from ..core.pty_session import PtySession
from ..core.sampler import Sampler
from ..core.sources import available_sources
from .terminal_widget import TerminalView
@@ -57,16 +55,13 @@ class SharePage(QWidget):
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._sampler = Sampler(available_sources())
self._host_ws: QWebSocket | None = None
self._guest_ws: QWebSocket | None = None
self._pty: PtySession | None = None
self._pty_notifier: QSocketNotifier | None = None
self._last_report = None
self._last_inv = None
self._timer = QTimer(self)
self._timer.setInterval(2000)
self._timer.timeout.connect(self._stream)
self._guest_can_type = False
self._fs: QWidget | None = None
self._fs_state = None
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
@@ -74,19 +69,19 @@ class SharePage(QWidget):
title = QLabel("Share")
title.setObjectName("PageTitle")
root.addWidget(title)
root.addWidget(self._build_host())
root.addWidget(self._build_host(), 1)
root.addWidget(self._build_guest(), 1)
# ------------------------------------------------------------------ host
def _build_host(self) -> QFrame:
card, v = _card("Start a shared session")
self._host_status = QLabel("Let someone with an account view your machine, read-only.")
card, v = _card("Host a terminal session")
self._host_status = QLabel("Share a live terminal with someone who has an account.")
self._host_status.setObjectName("Muted")
self._host_status.setWordWrap(True)
v.addWidget(self._host_status)
row = QHBoxLayout()
self._start_btn = QPushButton("Start shared session")
self._start_btn = QPushButton("Start session")
self._start_btn.setObjectName("PrimaryButton")
self._start_btn.clicked.connect(self._start_host)
self._stop_btn = QPushButton("Stop")
@@ -95,28 +90,33 @@ class SharePage(QWidget):
self._code_label = QLabel("")
self._code_label.setStyleSheet("font-weight:700; font-size:18px; color:#38bdf8; background:transparent;")
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._stop_btn)
row.addSpacing(12)
row.addWidget(self._code_label)
row.addStretch(1)
row.addWidget(self._host_fs_btn)
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_term.setStyleSheet("color:#fb923c; background:transparent;")
self._allow_term.toggled.connect(self._toggle_terminal)
v.addWidget(self._allow_term)
self._allow_input = QCheckBox(
"Allow the guest to type — they run commands as your user (off = they only watch)")
self._allow_input.setStyleSheet("color:#fb923c; background:transparent;")
self._allow_input.toggled.connect(self._send_terminal_state)
v.addWidget(self._allow_input)
self._host_term = TerminalView()
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.setVisible(False)
v.addWidget(self._host_term)
v.addWidget(self._host_term, 1)
return card
def _start_host(self) -> None:
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
self._host_status.setText("Connecting to the relay…")
self._start_btn.setEnabled(False)
@@ -135,37 +135,25 @@ class SharePage(QWidget):
if data.get("error"):
self._host_status.setText(f"Rejected: {data['error']}")
return
if "code" in data: # relay handshake
if "code" in data: # relay handshake → start the terminal immediately
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._host_ws.sendTextMessage(share.host_full_frame(self._sampler))
self._send_terminal_state()
if self._allow_term.isChecked():
self._start_pty()
self._timer.start()
return
kind = data.get("type") # frames forwarded from a guest
if kind == "req_full":
# 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()
elif kind == "pty_in" and self._pty:
self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty:
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()
self._send_terminal_state()
return
kind = data.get("type")
if kind == "req_full": # a guest joined — tell them their typing permission
self._send_terminal_state()
elif kind == "pty_in" and self._pty and self._allow_input.isChecked():
self._pty.write(base64.b64decode(data["data"]))
elif kind == "pty_resize" and self._pty and self._allow_input.isChecked():
self._pty.set_size(int(data["rows"]), int(data["cols"]))
def _send_terminal_state(self) -> None:
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:
if self._pty:
@@ -176,15 +164,15 @@ class SharePage(QWidget):
self._pty_notifier.activated.connect(self._on_pty_output)
self._host_term.reset()
self._host_term.setVisible(True)
self._host_fs_btn.setEnabled(True)
self._host_term.setFocus()
def _on_pty_output(self) -> None:
if not self._pty:
return
data = self._pty.read()
if not data: # shell exited / EOF
self._stop_pty()
self._send_terminal_state()
self._allow_term.setChecked(False)
if not data: # shell exited
self._stop_host()
return
self._host_term.feed(data)
if self._host_ws:
@@ -198,13 +186,9 @@ class SharePage(QWidget):
self._pty.close()
self._pty = None
self._host_term.setVisible(False)
def _stream(self) -> None:
if self._host_ws:
self._host_ws.sendTextMessage(share.host_snapshot_frame(self._sampler))
self._host_fs_btn.setEnabled(False)
def _stop_host(self) -> None:
self._timer.stop()
self._stop_pty()
if self._host_ws:
self._host_ws.close()
@@ -215,7 +199,6 @@ class SharePage(QWidget):
self._host_status.setText("Stopped sharing.")
def _host_closed(self) -> None:
self._timer.stop()
self._stop_pty()
self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
@@ -225,7 +208,7 @@ class SharePage(QWidget):
# ----------------------------------------------------------------- guest
def _build_guest(self) -> QFrame:
card, v = _card("Join a shared session")
card, v = _card("Join a terminal session")
row = QHBoxLayout()
self._code_input = QLineEdit()
self._code_input.setPlaceholderText("Enter share code")
@@ -237,37 +220,31 @@ class SharePage(QWidget):
self._leave_btn = QPushButton("Leave")
self._leave_btn.setEnabled(False)
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._join_btn)
row.addWidget(self._leave_btn)
row.addStretch(1)
row.addWidget(self._guest_fs_btn)
v.addLayout(row)
self._guest_status = QLabel("")
self._guest_status.setObjectName("Muted")
self._guest_status.setWordWrap(True)
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.keys.connect(self._guest_key)
self._guest_term.resized.connect(self._guest_resize)
self._guest_term.setVisible(False)
v.addWidget(self._guest_term)
v.addWidget(self._guest_term, 1)
return card
def _join(self) -> None:
code = self._code_input.text().strip().upper()
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
if not code:
self._guest_status.setText("Enter a share code.")
@@ -290,46 +267,40 @@ class SharePage(QWidget):
self._guest_status.setText(data["error"])
return
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._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"}))
return
kind = data.get("type")
if kind in ("full", "snapshot"):
if kind == "full":
self._last_report = data.get("report")
self._last_inv = data.get("inventory")
self._view.setHtml(share.guest_html(data.get("snapshot"), self._last_report, self._last_inv))
elif kind == "terminal":
self._set_terminal_visible(bool(data.get("enabled")))
if kind == "terminal":
self._guest_can_type = bool(data.get("enabled"))
self._guest_status.setText(
"You can type — your keystrokes run on the host's machine."
if self._guest_can_type else "Read-only — watching the host's terminal.")
if self._guest_can_type:
self._guest_term.setFocus()
self._guest_resize(*self._guest_term.grid())
elif kind == "pty":
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:
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)}))
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}))
def _leave(self) -> None:
if self._guest_ws:
self._guest_ws.close()
self._guest_ws = None
for w in (self._view, self._term_label, self._guest_term):
w.setVisible(False)
self._guest_term.setVisible(False)
self._guest_fs_btn.setEnabled(False)
self._guest_can_type = False
self._leave_btn.setEnabled(False)
self._join_btn.setEnabled(True)
self._guest_status.setText("Left the session.")
@@ -337,11 +308,40 @@ class SharePage(QWidget):
def _guest_closed(self) -> None:
self._join_btn.setEnabled(True)
self._leave_btn.setEnabled(False)
if self._view.isVisible():
if self._guest_term.isVisible():
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:
self._timer.stop()
self._stop_pty()
for ws in (self._host_ws, self._guest_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.
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Monochrome for
now; cursor addressing / layout (vim, top) work via pyte.
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Renders pyte's
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
import html as _html
import pyte
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor
from PySide6.QtWidgets import QPlainTextEdit
from PySide6.QtGui import QFontDatabase, QFontMetrics
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
resized = Signal(int, int) # rows, cols
def __init__(self, rows: int = 24, cols: int = 80):
super().__init__()
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
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._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)
def grid(self) -> tuple[int, int]:
@@ -38,24 +74,51 @@ class TerminalView(QPlainTextEdit):
self._screen.reset()
self._render()
def _row_text(self, row) -> str:
return "".join(row[x].data for x in range(self._cols)).rstrip()
# --- rendering ---------------------------------------------------------------------
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:
bar = self.verticalScrollBar()
at_bottom = bar.value() >= bar.maximum() - 2
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)))
if at_bottom: # follow output; place caret at the real (row, col)
cursor = self.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.Start)
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y)
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x)
self.setTextCursor(cursor)
self.ensureCursorVisible()
else: # user scrolled up to read — keep their place
bar.setValue(prev)
history = list(self._screen.history.top)[-_HISTORY_RENDER:]
lines = [self._row_html(r, None) for r in history]
cur_y = self._screen.cursor.y
for y in range(self._rows):
cursor_x = self._screen.cursor.x if y == cur_y else None
lines.append(self._row_html(self._screen.buffer[y], cursor_x))
self.setHtml('<div style="white-space:pre;line-height:100%;">' + "<br>".join(lines) + "</div>")
bar.setValue(bar.maximum() if at_bottom else prev)
def resizeEvent(self, event): # noqa: N802 (Qt override)
super().resizeEvent(event)
-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()
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the M9 systemd --user trigger-mode service manager."""
import unittest
from unittest import mock
from rigdoctor.core import service
class UnitTextTests(unittest.TestCase):
def test_unit_text_has_required_sections(self):
txt = service.unit_text("RigDoctor recorder", ["record", "run"])
self.assertIn("[Unit]", txt)
self.assertIn("[Service]", txt)
self.assertIn("ExecStart=", txt)
self.assertIn("record run", txt)
self.assertIn("WantedBy=default.target", txt)
class ApplyModeTests(unittest.TestCase):
def test_unknown_mode_rejected(self):
ok, msg = service.apply_mode("turbo")
self.assertFalse(ok)
self.assertIn("Unknown", msg)
def test_no_systemd_saves_mode_but_reports(self):
with mock.patch.object(service, "available", return_value=False), \
mock.patch.object(service.config, "update_config") as update:
ok, msg = service.apply_mode("always-on")
self.assertFalse(ok)
self.assertIn("available", msg.lower())
update.assert_called_once_with(trigger_mode="always-on")
def test_always_on_enables_recorder_disables_watch(self):
calls = []
with mock.patch.object(service, "available", return_value=True), \
mock.patch.object(service, "install_units"), \
mock.patch.object(service, "_enable", side_effect=lambda n: calls.append(("enable", n)) or (0, "")), \
mock.patch.object(service, "_disable", side_effect=lambda n: calls.append(("disable", n)) or (0, "")), \
mock.patch.object(service.config, "update_config"):
ok, _ = service.apply_mode("always-on")
self.assertTrue(ok)
self.assertIn(("enable", service.RECORDER_UNIT), calls)
self.assertIn(("disable", service.WATCH_UNIT), calls)
def test_manual_disables_both(self):
disabled = []
with mock.patch.object(service, "available", return_value=True), \
mock.patch.object(service, "install_units"), \
mock.patch.object(service, "_enable", return_value=(0, "")), \
mock.patch.object(service, "_disable", side_effect=lambda n: disabled.append(n) or (0, "")), \
mock.patch.object(service.config, "update_config"):
ok, _ = service.apply_mode("manual")
self.assertTrue(ok)
self.assertEqual(set(disabled), {service.RECORDER_UNIT, service.WATCH_UNIT})
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()
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the M9/D12 game-launch watcher (RunningAppID parse + transitions)."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import watcher
_REGISTRY = """"Registry"
{
\t"HKCU"
\t{
\t\t"Software"
\t\t{
\t\t\t"Valve"
\t\t\t{
\t\t\t\t"Steam"
\t\t\t\t{
\t\t\t\t\t"RunningAppID"\t\t"%s"
\t\t\t\t}
\t\t\t}
\t\t}
\t}
}
"""
class TransitionTests(unittest.TestCase):
def test_transitions(self):
self.assertEqual(watcher.transition(0, 570), "start")
self.assertEqual(watcher.transition(570, 0), "stop")
self.assertIsNone(watcher.transition(570, 570))
self.assertIsNone(watcher.transition(0, 0))
class FindKeyTests(unittest.TestCase):
def test_case_insensitive_nested(self):
data = {"Registry": {"HKCU": {"steam": {"runningappid": "42"}}}}
self.assertEqual(watcher._find_key(data, "RunningAppID"), "42")
def test_missing(self):
self.assertIsNone(watcher._find_key({"a": {"b": "c"}}, "RunningAppID"))
class RunningAppIdTests(unittest.TestCase):
def _with_registry(self, content):
d = tempfile.mkdtemp()
path = Path(d) / "registry.vdf"
path.write_text(content)
return path
def test_reads_running_appid(self):
path = self._with_registry(_REGISTRY % "570")
with mock.patch.object(watcher, "_registry_path", return_value=path):
self.assertEqual(watcher.running_appid(), 570)
def test_zero_when_idle(self):
path = self._with_registry(_REGISTRY % "0")
with mock.patch.object(watcher, "_registry_path", return_value=path):
self.assertEqual(watcher.running_appid(), 0)
def test_zero_when_no_registry(self):
with mock.patch.object(watcher, "_registry_path", return_value=None):
self.assertEqual(watcher.running_appid(), 0)
if __name__ == "__main__":
unittest.main()