From 7308ac09b17bd3403bf74a831f2c311647374333 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:11:57 +1000 Subject: [PATCH 1/6] Remote desktop from Windows: sign in as steamos, and say when xrdp isn't there A Windows user reported "RDP not working". Frame Control ran `mstsc /v:HOST`, which offers the Windows account; the Frame's xrdp (TLS, no NLA) only accepts steamos with the Developer Mode password. The app also said "Opened Remote Desktop" without checking that anything answered on port 3389. - open_rdp checks port 3389 first and explains how to turn xrdp on - On Windows, launch mstsc with a .rdp file naming user steamos (CRLF) - Every platform's message says to sign in as steamos with the Developer Mode password - The server no longer logs a page closing mid-reply (WinError 10053 on Windows) as a 500 with an error diagnostic Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/frame-control.md | 8 ++- tests/test_rdp.py | 130 ++++++++++++++++++++++++++++++++++++++++++ ui/frame_host.py | 45 ++++++++++++--- ui/server.py | 20 ++++++- 4 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 tests/test_rdp.py diff --git a/docs/frame-control.md b/docs/frame-control.md index 3ec3b4c..8b9838a 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -88,8 +88,12 @@ 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. 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/tests/test_rdp.py b/tests/test_rdp.py new file mode 100644 index 0000000..4c2f806 --- /dev/null +++ b/tests/test_rdp.py @@ -0,0 +1,130 @@ +"""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) + + 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.HostError) 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}", str(cm.exception)) + self.assertEqual(self.spawned, []) + + 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 e5c1914..202d600 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -8,6 +8,7 @@ CLI (used by the Electron app, so terminal handling lives in one place): import os import shlex import shutil +import socket import ssl import subprocess import sys @@ -264,24 +265,54 @@ 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 +RDP_LOGIN = f"sign in as {RDP_USER} with your Developer Mode password" + + +def rdp_reachable(host, timeout=3): + """Whether anything answers on the Frame's RDP port.""" + try: + with socket.create_connection((host, RDP_PORT), timeout=timeout): + return True + except OSError: + return False + + +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") + path = cache_dir("frame.rdp") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"full address:s:{host}\nusername:s:{RDP_USER}\n", encoding="utf-8", newline="\r\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. + if not rdp_reachable(host): + raise HostError(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " + f"{RDP_PORT}). Turn on Developer Mode in Steam Settings → System on the headset, " + "then restart it and try again.") 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))]) + return f"Opened Remote Desktop to {host}: {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 7771b72..52bc1e5 100755 --- a/ui/server.py +++ b/ui/server.py @@ -2254,6 +2254,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 @@ -2286,8 +2291,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) @@ -2405,6 +2413,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: @@ -2439,6 +2449,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) @@ -2614,6 +2626,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 From 72ffec45c228b7e7acda23653fbb3f520b619fb8 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:53:01 +1000 Subject: [PATCH 2/6] Remote desktop: mention the Frame's certificate warning Seen on Windows 11 against a real Frame: mstsc warns about xrdp's own certificate before xrdp's login box appears. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/frame-control.md | 3 ++- tests/test_rdp.py | 1 + ui/frame_host.py | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/frame-control.md b/docs/frame-control.md index 8b9838a..d60af80 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -91,7 +91,8 @@ counts them while they run. 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. Sign in with the Developer Mode password. Sleep, restart and + 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. diff --git a/tests/test_rdp.py b/tests/test_rdp.py index 4c2f806..eed2f85 100644 --- a/tests/test_rdp.py +++ b/tests/test_rdp.py @@ -65,6 +65,7 @@ class OpenRdp(unittest.TestCase): self.assertIn("username:s:steamos", lines) self.assertIn("steamos", message) self.assertIn("Developer Mode password", message) + self.assertIn("certificate", message) def test_nothing_listening_says_why_and_opens_nothing(self): self.xrdp.close() diff --git a/ui/frame_host.py b/ui/frame_host.py index 202d600..c5c028c 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -267,7 +267,9 @@ def open_steam_link(): RDP_PORT = 3389 RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's -RDP_LOGIN = f"sign in as {RDP_USER} with your Developer Mode password" +# 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 rdp_reachable(host, timeout=3): From 3f273ca37ac9d8be9b333834a39cab3c9be0ba95 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:07:39 +1000 Subject: [PATCH 3/6] Remote desktop on Windows: say to choose Connect on mstsc's file prompt Seen on Windows 11: an unsigned .rdp file makes mstsc ask about the publisher before the certificate warning. Plain '>' in the message, which a cp1252 console can print. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_rdp.py | 1 + ui/frame_host.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_rdp.py b/tests/test_rdp.py index eed2f85..814517e 100644 --- a/tests/test_rdp.py +++ b/tests/test_rdp.py @@ -66,6 +66,7 @@ class OpenRdp(unittest.TestCase): 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() diff --git a/ui/frame_host.py b/ui/frame_host.py index c5c028c..9407de9 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -298,7 +298,7 @@ def open_rdp(alias, host=None): # The client would open either way and then fail on its own, with nothing said here. if not rdp_reachable(host): raise HostError(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " - f"{RDP_PORT}). Turn on Developer Mode in Steam Settings → System on the headset, " + f"{RDP_PORT}). Turn on Developer Mode in Steam Settings > System on the headset, " "then restart it and try again.") if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: @@ -307,7 +307,8 @@ def open_rdp(alias, host=None): return "Windows App isn't installed; opened its App Store page" if WINDOWS: _spawn(["mstsc.exe", str(rdp_file(host))]) - return f"Opened Remote Desktop to {host}: {RDP_LOGIN}" + # 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://{RDP_USER}@{host}"]) return f"Opened Remmina to {host}: {RDP_LOGIN}" From d9cd40c035985537a714774ec730e99338769403 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:06:14 +1000 Subject: [PATCH 4/6] Remote desktop: review fixes - Write the .rdp file through open(newline=), since Path.write_text(newline=) needs Python 3.10 and CI's checks job runs 3.9 - One .rdp file per address, so overlapping launches can't swap headsets - xrdp not answering is NotListening, a 400 with its message rather than a 500 filed as an error diagnostic - /source-image/ lets ClientGone through instead of answering 404 mid-reply Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_rdp.py | 18 +++++++++++++++++- ui/frame_host.py | 17 ++++++++++++----- ui/server.py | 4 ++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/test_rdp.py b/tests/test_rdp.py index 814517e..1f5ee4a 100644 --- a/tests/test_rdp.py +++ b/tests/test_rdp.py @@ -71,12 +71,28 @@ class OpenRdp(unittest.TestCase): 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.HostError) as cm: + with self.subTest(name), platform(name), self.assertRaises(frame_host.NotListening) 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}", 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") + self.assertNotEqual(a, b) + 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") diff --git a/ui/frame_host.py b/ui/frame_host.py index 9407de9..c618ebb 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -6,6 +6,7 @@ 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 os +import re import shlex import shutil import socket @@ -33,6 +34,10 @@ class HostError(RuntimeError): pass +class NotListening(HostError): + """The Frame answers, but a service on it doesn't: something the person can turn on.""" + + def data_dir(*parts): """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME (or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory).""" @@ -286,9 +291,11 @@ def rdp_file(host): 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") - path = cache_dir("frame.rdp") + # One file per address, so two launches close together can't swap headsets. + path = cache_dir(f"frame-{re.sub(r'[^A-Za-z0-9.-]', '_', host)}.rdp") path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"full address:s:{host}\nusername:s:{RDP_USER}\n", encoding="utf-8", newline="\r\n") + 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 @@ -297,9 +304,9 @@ def open_rdp(alias, host=None): host = host or ssh_hostname(alias) # The client would open either way and then fail on its own, with nothing said here. if not rdp_reachable(host): - raise HostError(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " - f"{RDP_PORT}). Turn on Developer Mode in Steam Settings > System on the headset, " - "then restart it and try again.") + raise NotListening(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " + f"{RDP_PORT}). Turn on Developer Mode in Steam Settings > System on the headset, " + "then restart it and try again.") if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: return f"Opened Windows App: connect to {host} and {RDP_LOGIN}" diff --git a/ui/server.py b/ui/server.py index 52bc1e5..f1c798d 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1192,6 +1192,8 @@ def open_thing(body): SHOTS_DIR.mkdir(parents=True, exist_ok=True) frame_host.open_path(SHOTS_DIR) return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"} + except frame_host.NotListening 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) @@ -2338,6 +2340,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": From e8db571a2c016516c6071db0a87591331d5d64e7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:10:45 +1000 Subject: [PATCH 5/6] Remote desktop: say which way the Frame didn't answer Second review: only a refused port 3389 means xrdp is off. A name that doesn't resolve, a timeout or no route now say so, rather than telling the person to turn on Developer Mode. All are Unreachable (a 400, no error diagnostic). The .rdp file name is a digest of the address, since fe80::1%2 and fe80::1:2 sanitised to the same name. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_rdp.py | 19 ++++++++++++++++--- ui/frame_host.py | 32 +++++++++++++++++++------------- ui/server.py | 2 +- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/tests/test_rdp.py b/tests/test_rdp.py index 1f5ee4a..272bef1 100644 --- a/tests/test_rdp.py +++ b/tests/test_rdp.py @@ -71,10 +71,22 @@ class OpenRdp(unittest.TestCase): 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.NotListening) as cm: + 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}", 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): @@ -89,7 +101,8 @@ class OpenRdp(unittest.TestCase): 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") - self.assertNotEqual(a, b) + 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()) diff --git a/ui/frame_host.py b/ui/frame_host.py index c618ebb..682ddd3 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -5,8 +5,8 @@ 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 os -import re import shlex import shutil import socket @@ -34,8 +34,8 @@ class HostError(RuntimeError): pass -class NotListening(HostError): - """The Frame answers, but a service on it doesn't: something the person can turn on.""" +class Unreachable(HostError): + """The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here.""" def data_dir(*parts): @@ -277,13 +277,22 @@ RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as "with your Developer Mode password") -def rdp_reachable(host, timeout=3): - """Whether anything answers on the Frame's RDP port.""" +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 True - except OSError: - return False + 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): @@ -292,7 +301,7 @@ def rdp_file(host): 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-{re.sub(r'[^A-Za-z0-9.-]', '_', host)}.rdp") + 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") @@ -303,10 +312,7 @@ 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. - if not rdp_reachable(host): - raise NotListening(f"The Frame isn't accepting remote desktop at {host} (nothing answered on port " - f"{RDP_PORT}). Turn on Developer Mode in Steam Settings > System on the headset, " - "then restart it and try again.") + check_rdp(host) if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: return f"Opened Windows App: connect to {host} and {RDP_LOGIN}" diff --git a/ui/server.py b/ui/server.py index f1c798d..f966575 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1192,7 +1192,7 @@ def open_thing(body): SHOTS_DIR.mkdir(parents=True, exist_ok=True) frame_host.open_path(SHOTS_DIR) return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"} - except frame_host.NotListening as e: + 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) From 63c1a9ff55dc4177ad7bd113965b26f1c5a9af98 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:25:49 +1000 Subject: [PATCH 6/6] docs: xrdp sign-in from Windows verified on a real Frame Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/streaming.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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