diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d4561..186f1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/ diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f125528..b54147d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 (D1–D22) are resolved. New questions will be added +None currently — all tracked decisions (D1–D23) 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`). diff --git a/docs/MODULES.md b/docs/MODULES.md index 365bccc..76ad5b0 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -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` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f4f556f..74a52b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/SPEC.md b/docs/SPEC.md index 6ac6ba1..41b96f3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5280f47..bd7f1da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 8fd561a..cc1470e 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.24.0" +__version__ = "0.25.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index c343cee..0a58fa3 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -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)") diff --git a/src/rigdoctor/core/share.py b/src/rigdoctor/core/share.py deleted file mode 100644 index 9df990a..0000000 --- a/src/rigdoctor/core/share.py +++ /dev/null @@ -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 = """ -RigDoctor — shared - -

RigDoctor read-only share

-

A live view shared by the machine's owner. You can look, not change anything.

-
loading…
-

Health

loading…
-

Inventory

loading…
-""" - - -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 = ['
'] - if snapshot: - for group, items in snapshot.get("groups", {}).items(): - out.append(f'

{esc(group).upper()}

') - for it in items: - out.append(f'' - f'') - out.append("
{esc(it.get("name"))}{esc(_fmt(it.get("value"), it.get("unit", "")))}
") - if report: - out.append('

HEALTH

') - colors = {"critical": "#f87171", "warning": "#fb923c", "ok": "#4ade80"} - for f in report: - sev = f.get("severity", "info") - out.append(f'
[{esc(sev).upper()}] ' - f'{esc(f.get("category"))}: {esc(f.get("title"))}
') - if inventory: - out.append('

INVENTORY

') - for section, kv in inventory.items(): - out.append(f'

{esc(section)}

') - for k, v in kv.items(): - out.append(f'') - out.append("
{esc(k)}{esc(v)}
") - out.append("
") - 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 diff --git a/src/rigdoctor/gui/share_page.py b/src/rigdoctor/gui/share_page.py index de456f9..7a0c114 100644 --- a/src/rigdoctor/gui/share_page.py +++ b/src/rigdoctor/gui/share_page.py @@ -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: diff --git a/tests/test_relay_frames.py b/tests/test_relay_frames.py deleted file mode 100644 index 0e4440e..0000000 --- a/tests/test_relay_frames.py +++ /dev/null @@ -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() diff --git a/tests/test_share.py b/tests/test_share.py deleted file mode 100644 index 3f3e59c..0000000 --- a/tests/test_share.py +++ /dev/null @@ -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()