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.
-
-
-
-"""
-
-
-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'| {esc(it.get("name"))} | '
- f'{esc(_fmt(it.get("value"), it.get("unit", "")))} |
')
- out.append("
")
- 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'| {esc(k)} | {esc(v)} |
')
- out.append("
")
- 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()