diff --git a/docs/frame-control.md b/docs/frame-control.md index 3ec3b4c..d60af80 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -88,8 +88,13 @@ counts them while they run. name your networks, and switch headsets. See [devices.md](devices.md). - **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on - Linux). Sleep, restart and shut down open a terminal window because SteamOS - asks for the sudo password over SSH. + Linux). Remote desktop first checks that the Frame's xrdp answers on port + 3389 (Developer Mode turns it on). On Windows it opens a connection file for + user `steamos`, because `mstsc /v:` alone offers your Windows account, which + xrdp turns away. Accept the warning about the Frame's own certificate, then + sign in with the Developer Mode password. Sleep, restart and + shut down open a terminal window because SteamOS asks for the sudo password + over SSH. ## How it works diff --git a/docs/streaming.md b/docs/streaming.md index 773e698..904105e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md). documents it. Use Windows App (RDP) when you want a proper Linux desktop on the Mac with keyboard, mouse, and clipboard. +**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2, +Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer +Mode password opens a Plasma (X11) desktop within about 6 seconds. + +- xrdp has no NLA, so the client shows a certificate warning (xrdp's own + `www.xrdp.org` certificate) and then xrdp's own login box. Frame Control + fills in `steamos` there on Windows, Remmina and FreeRDP. +- The desktop is a separate login session (Xorg on display `:10`), not the + headset's view. It uses about 1.3 GB of the Frame's memory. +- Closing the client leaves the session running, and the next login + reconnects to it. To end it over SSH, find it with `loginctl list-sessions` + and run `loginctl terminate-session `. That doesn't touch the headset's + gamescope or SteamVR session. + ## B. Show the Mac's desktop inside the Frame The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had diff --git a/tests/test_rdp.py b/tests/test_rdp.py new file mode 100644 index 0000000..272bef1 --- /dev/null +++ b/tests/test_rdp.py @@ -0,0 +1,161 @@ +"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client +launch stubbed and a real socket standing in for the Frame's xrdp. Also the server +staying quiet when the page goes away mid-reply, which on Windows is +ConnectionAbortedError (WinError 10053). + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import email.message +import io +import socket +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_host # noqa: E402 +import server # noqa: E402 + + +def platform(name): + """Patches frame_host to behave as on `name` ("mac", "windows" or "linux").""" + return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows", + LINUX=name == "linux") + + +class OpenRdp(unittest.TestCase): + def setUp(self): + self.xrdp = socket.socket() + self.xrdp.bind(("127.0.0.1", 0)) + self.xrdp.listen(4) + self.addCleanup(self.xrdp.close) + port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1]) + port.start() + self.addCleanup(port.stop) + self.spawned = [] + spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append) + spawn.start() + self.addCleanup(spawn.stop) + cache = tempfile.TemporaryDirectory() + self.addCleanup(cache.cleanup) + self.cache = Path(cache.name) + where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p)) + where.start() + self.addCleanup(where.stop) + + def test_windows_signs_in_as_steamos(self): + # The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects. + with platform("windows"): + message = frame_host.open_rdp("frame", "127.0.0.1") + self.assertEqual(len(self.spawned), 1) + argv = self.spawned[0] + self.assertEqual(argv[0], "mstsc.exe") + self.assertNotIn("/v:127.0.0.1", argv) + rdp = Path(argv[1]) + self.assertEqual(rdp.suffix, ".rdp") + data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines + self.assertNotIn(b"\r\r", data) + lines = data.decode("utf-8").split("\r\n") + self.assertIn("full address:s:127.0.0.1", lines) + self.assertIn("username:s:steamos", lines) + self.assertIn("steamos", message) + self.assertIn("Developer Mode password", message) + self.assertIn("certificate", message) + self.assertIn("Connect", message) + + def test_nothing_listening_says_why_and_opens_nothing(self): + self.xrdp.close() + for name in ("windows", "mac", "linux"): + with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm: + frame_host.open_rdp("frame", "127.0.0.1") + self.assertIn("Developer Mode", str(cm.exception)) + self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception)) + self.assertEqual(self.spawned, []) + + def test_says_which_way_it_failed(self): + # Only a refused port says xrdp is off; a wrong address or a silent network say so instead. + for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"), + (socket.timeout("timed out"), "didn't answer"), + (OSError(65, "No route to host"), "didn't answer")): + with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \ + platform("windows"), self.assertRaises(frame_host.Unreachable) as cm: + frame_host.open_rdp("frame", "frame.local") + self.assertIn(says, str(cm.exception)) + self.assertNotIn("refused", str(cm.exception)) + self.assertEqual(self.spawned, []) + + def test_server_says_it_as_the_persons_to_fix(self): + # A 400 with the message, not a 500 filed as an error diagnostic. + self.xrdp.close() + with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \ + self.assertRaises(server.Failure) as cm: + server.open_thing({"what": "rdp"}) + self.assertEqual(cm.exception.status, 400) + self.assertIn("Developer Mode", str(cm.exception)) + + def test_one_file_per_address(self): + with platform("windows"): + a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0") + c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2") + self.assertEqual(len({a, b, c, d}), 4) + self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes()) + self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes()) + + def test_address_cant_add_lines_to_the_file(self): + with platform("windows"), self.assertRaises(frame_host.HostError): + frame_host.rdp_file("frame\r\nusername:s:root") + self.assertEqual(list(self.cache.iterdir()), []) + + def test_linux_clients_get_the_user(self): + with platform("linux"), mock.patch.object(frame_host, "which", + lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None): + message = frame_host.open_rdp("frame", "127.0.0.1") + self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]]) + self.assertIn("steamos", message) + + +class PageGoneAway(unittest.TestCase): + """The report's server log: the page closed while index.html was being sent, and the + server logged it as a 500, tried to answer anyway, and filed an error diagnostic.""" + + def handler(self, path="/"): + h = server.Handler.__new__(server.Handler) + h.command, h.path, h.request_version = "GET", path, "HTTP/1.1" + h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1) + h.headers = email.message.Message() + h.headers["Host"] = "127.0.0.1:1" + h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted"))) + h.close_connection = True + return h + + def test_not_a_server_error(self): + h = self.handler() + with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \ + mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone): + h.do_GET() + diagnostic.assert_not_called() + self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply + + def test_server_logs_nothing(self): + srv = server.LoopbackServer.__new__(server.LoopbackServer) + err = io.StringIO() + with mock.patch.object(sys, "stderr", err): + try: + raise server.ClientGone() + except server.ClientGone: + srv.handle_error(None, ("127.0.0.1", 1)) + self.assertEqual(err.getvalue(), "") + try: + raise RuntimeError("real") + except RuntimeError: + srv.handle_error(None, ("127.0.0.1", 1)) + self.assertIn("RuntimeError: real", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/frame_host.py b/ui/frame_host.py index fac720e..359e58b 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -5,10 +5,12 @@ Everything here runs on your computer, not the Frame. Python stdlib only. CLI (used by the Electron app, so terminal handling lives in one place): python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window """ +import hashlib import io import os import shlex import shutil +import socket import ssl import subprocess import sys @@ -34,6 +36,10 @@ class HostError(RuntimeError): pass +class Unreachable(HostError): + """The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here.""" + + def run_ssh(argv, **kwargs): """Run an OpenSSH tool without Windows' redirected-stderr pipe hang. @@ -350,24 +356,65 @@ def open_steam_link(): return "Steam Link isn't installed; opened its download page" +RDP_PORT = 3389 +RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's +# xrdp's certificate is its own, so every client warns about it first. +RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} " + "with your Developer Mode password") + + +def check_rdp(host, timeout=3): + """Raise Unreachable, saying why, unless the Frame's RDP port takes a connection.""" + try: + with socket.create_connection((host, RDP_PORT), timeout=timeout): + return + except ConnectionRefusedError: + raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} " + "refused). Turn on Developer Mode in Steam Settings > System on the headset, " + "then restart it and try again.") from None + except socket.gaierror: + raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's " + "address on the Devices tab.") from None + except OSError as e: + raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, " + "switched off or on another network; if it's on, check Developer Mode is on " + "in Steam Settings > System.") from None + + +def rdp_file(host): + """A Remote Desktop connection file for the Frame. mstsc /v: alone offers this + computer's Windows account, which xrdp turns away; the file names steamos instead.""" + if any(c in host for c in "\r\n"): + raise HostError("That headset address can't be used for remote desktop") + # One file per address, so two launches close together can't swap headsets. + path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp") + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+ + f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n") + return path + + def open_rdp(alias, host=None): """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points.""" host = host or ssh_hostname(alias) + # The client would open either way and then fail on its own, with nothing said here. + check_rdp(host) if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: - return "Opened Windows App" + return f"Opened Windows App: connect to {host} and {RDP_LOGIN}" open_url("https://apps.apple.com/app/windows-app/id1295203466") return "Windows App isn't installed; opened its App Store page" if WINDOWS: - _spawn(["mstsc.exe", f"/v:{host}"]) - return f"Opened Remote Desktop to {host}" + _spawn(["mstsc.exe", str(rdp_file(host))]) + # Windows asks about the unsigned connection file first. + return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}" if which("remmina"): - _spawn(["remmina", "-c", f"rdp://steamos@{host}"]) - return f"Opened Remmina to {host}" + _spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"]) + return f"Opened Remmina to {host}: {RDP_LOGIN}" for name in ("xfreerdp3", "xfreerdp"): if which(name): - _spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"]) - return f"Opened FreeRDP to {host}" + _spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"]) + return f"Opened FreeRDP to {host}: {RDP_LOGIN}" raise HostError("No RDP client found: install Remmina or FreeRDP") diff --git a/ui/server.py b/ui/server.py index 129835b..a04acea 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1225,6 +1225,8 @@ def open_thing(body): raise Failure("That screenshot isn't saved on this computer yet", 404) frame_host.reveal_path(saved) return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"} + except frame_host.Unreachable as e: + raise Failure(str(e), 400) # theirs to turn on; nothing failed here except frame_host.HostError as e: raise Failure(str(e), 500) raise Failure("unknown target", 400) @@ -2288,6 +2290,11 @@ def push_file(path, dest="Downloads/"): return f"Sent {name} to ~/{dest}" +class ClientGone(Exception): + """The page went away (a reload, the app quitting) before its reply was written: + nobody to answer, and nothing went wrong here.""" + + class Handler(BaseHTTPRequestHandler): server_version = "FrameControl/1" timeout = 60 # per socket operation, so a stalled client can't hold a thread @@ -2320,8 +2327,11 @@ class Handler(BaseHTTPRequestHandler): # Nobody may frame the UI (clickjacking). self.send_header("X-Frame-Options", "DENY") self.send_header("Content-Security-Policy", "frame-ancestors 'none'") - self.end_headers() - self.wfile.write(data) + try: + self.end_headers() + self.wfile.write(data) + except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError + raise ClientGone() from e def send_json(self, obj, status=200): self.send_bytes(json.dumps(obj).encode(), "application/json", status) @@ -2364,6 +2374,8 @@ class Handler(BaseHTTPRequestHandler): from apk_sources import _images try: self.send_bytes(*_images.image(path.rsplit("/", 1)[-1])) + except ClientGone: + raise except Exception: self.send_json({"error": "Artwork unavailable"}, 404) elif path == "/api/sources/details": @@ -2441,6 +2453,8 @@ class Handler(BaseHTTPRequestHandler): headers=[("X-Capture-Source", "gamescope")]) else: self.send_json({"error": "not found"}, 404) + except ClientGone: + raise except Failure as e: self.send_error_json(str(e), e.status, e.apk) except ValueError as e: @@ -2475,6 +2489,8 @@ class Handler(BaseHTTPRequestHandler): with (contextlib.nullcontext() if path == "/api/devices" else working(meant)): result = handler(body) self.send_json(result) + except ClientGone: + raise except Failure as e: if e.status >= 500: frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e) @@ -2650,6 +2666,10 @@ class LoopbackServer(ThreadingHTTPServer): socketserver.TCPServer.server_bind(self) self.server_name, self.server_port = "127.0.0.1", self.server_address[1] + def handle_error(self, request, client_address): + if not isinstance(sys.exc_info()[1], ClientGone): + super().handle_error(request, client_address) + _ONE_SERVER = None