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>
This commit is contained in:
2026-05-22 10:04:52 +02:00
parent 5e5dc2d54a
commit 2e545ff718
12 changed files with 145 additions and 418 deletions
+11
View File
@@ -5,6 +5,17 @@ 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/
+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`
+6 -8
View File
@@ -79,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.24.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.24.0"
__version__ = "0.25.0"
-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
@@ -600,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)")
-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
+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,
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()
self._send_terminal_state()
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))
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:
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:
elif kind == "pty_resize" and self._pty and self._allow_input.isChecked():
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:
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:
-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()