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_devices.py b/tests/test_devices.py index 31f3c61..522e879 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "ui")) import frame_devices as fd # noqa: E402 +import frame_host # noqa: E402 CONFIG = """Host lxso1 HostName 192.168.1.109 @@ -274,7 +275,8 @@ class Pins(Base): def test_hashed_and_non_default_port_entries(self): kh = self.ssh / "known_hosts" kh.write_text(f"[frame.local]:2222 {KEY}\n") - subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) + frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, + stdin=subprocess.DEVNULL, check=True, timeout=10) self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) @@ -283,7 +285,8 @@ class Pins(Base): target = fd.known_hosts("d4") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(f"frame-control-d4 {KEY}\n") - subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True) + frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, + stdin=subprocess.DEVNULL, check=True, timeout=10) self.assertNotIn("frame-control-d4", target.read_text()) self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.forget_pin("d4")) diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py index 175762c..6b9d3e8 100644 --- a/tests/test_frame_android_data.py +++ b/tests/test_frame_android_data.py @@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase): with self.assertRaisesRegex(android.FrameError, 'start this app'): data.install_obb(PKG, [path]) stream.assert_not_called() - with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): + with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): with self.assertRaisesRegex(android.FrameError, 'bad hash'): data._stream('command') diff --git a/tests/test_frame_host.py b/tests/test_frame_host.py new file mode 100644 index 0000000..39c8aa9 --- /dev/null +++ b/tests/test_frame_host.py @@ -0,0 +1,87 @@ +"""Captured OpenSSH output keeps working on Windows and POSIX hosts.""" +import sandbox # noqa: F401 +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui")) +import frame_host + + +class CapturedSSH(unittest.TestCase): + def run_command(self, source, **kwargs): + with mock.patch.object(frame_host, "WINDOWS", True): + return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs) + + def test_binary_output_and_input(self): + result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); " + "sys.stderr.buffer.write(b'error\\r\\n')", + capture_output=True, input=b"data\x00\xff") + self.assertEqual(result.stdout, b"data\x00\xff") + self.assertEqual(result.stderr, b"error\r\n") + + def test_text_output_normalizes_newlines(self): + result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); " + "sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')", + capture_output=True, input="hello\n", text=True) + self.assertEqual(result.stdout, "hello\n") + self.assertEqual(result.stderr, "first\nsecond\nthird\n") + + def test_explicit_encoding_and_errors(self): + result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')", + capture_output=True, encoding="ascii", errors="replace") + self.assertEqual(result.stderr, "\ufffd\ufffd") + + def test_check_preserves_error_output(self): + with self.assertRaises(subprocess.CalledProcessError) as caught: + self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)", + capture_output=True, text=True, check=True) + self.assertEqual(caught.exception.returncode, 7) + self.assertEqual(caught.exception.stdout, "out\n") + self.assertEqual(caught.exception.stderr, "err\n") + + def test_timeout_preserves_partial_stderr(self): + with self.assertRaises(subprocess.TimeoutExpired) as caught: + with mock.patch.object(frame_host, "WINDOWS", True): + frame_host.run_ssh([sys.executable, "-c", "import sys, time; " + "sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"], + capture_output=True, text=True, timeout=1) + self.assertEqual(caught.exception.stderr, b"waiting") + + def test_streamed_stdout_is_kept_separate(self): + with tempfile.TemporaryFile() as output: + result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); " + "sys.stderr.buffer.write(b'error')", + stdout=output, stderr=subprocess.PIPE) + output.seek(0) + self.assertEqual(output.read(), b"file") + self.assertIsNone(result.stdout) + self.assertEqual(result.stderr, b"error") + + def test_uncaptured_windows_call_is_unchanged(self): + with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run: + frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5) + run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5) + + def test_posix_call_is_unchanged(self): + with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run: + frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5) + run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5) + + def test_capture_rejects_explicit_streams(self): + for stream in ("stdout", "stderr"): + with self.subTest(stream=stream), self.assertRaises(ValueError): + self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL}) + + @unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH") + def test_real_ssh_failure_returns_stderr_without_hanging(self): + result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes", + "-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"], + capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5) + self.assertEqual(result.returncode, 255) + self.assertIn("Could not resolve hostname", result.stderr) 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_android.py b/ui/frame_android.py index 4345971..eff7c19 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120): try: # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL} - p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, - timeout=timeout, text=isinstance(input, str) or input is None) + p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, + timeout=timeout, text=isinstance(input, str) or input is None) except subprocess.TimeoutExpired: raise FrameError(f'timed out talking to {FRAME}') if p.returncode != 0: @@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600): else: cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}'] try: - subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) + frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) except subprocess.TimeoutExpired: raise FrameError(f'copying {name} to the Frame timed out') except subprocess.CalledProcessError as e: diff --git a/ui/frame_android_data.py b/ui/frame_android_data.py index 5a2e31a..c1a5dfb 100644 --- a/ui/frame_android_data.py +++ b/ui/frame_android_data.py @@ -11,16 +11,17 @@ import tempfile import uuid import frame_android as android +import frame_host REMOTE = Path(android.ROOT) / 'frame/android/app-data.py' def _stream(command, src=None, dst=None): try: - result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command], - stdin=src if src else subprocess.DEVNULL, - stdout=dst if dst else subprocess.PIPE, - stderr=subprocess.PIPE, timeout=1800) + result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], + stdin=src if src else subprocess.DEVNULL, + stdout=dst if dst else subprocess.PIPE, + stderr=subprocess.PIPE, timeout=1800) except subprocess.TimeoutExpired: raise android.FrameError('app-data transfer timed out') except OSError as error: diff --git a/ui/frame_connect.py b/ui/frame_connect.py index a459a32..c0cefa4 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -24,6 +24,8 @@ import urllib.error import urllib.request from pathlib import Path +import frame_host + FRAME_USER = os.environ.get("FRAME_USER", "steamos") USER_FROM_ENV = "FRAME_USER" in os.environ FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") @@ -349,9 +351,9 @@ def _write_config(host, port, user): def key_login_works(): # accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. - return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], - capture_output=True).returncode == 0 + return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], + capture_output=True).returncode == 0 def configured_user(): diff --git a/ui/frame_devices.py b/ui/frame_devices.py index 8345302..fb2f29a 100644 --- a/ui/frame_devices.py +++ b/ui/frame_devices.py @@ -333,8 +333,8 @@ def remove_block(alias, path=None): def effective_port(alias, config): """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" try: - out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, - stdin=subprocess.DEVNULL, timeout=10).stdout + out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return 22 m = re.search(r"^port (\d+)$", out, re.M) @@ -346,8 +346,8 @@ def effective_port(alias, config): def _keygen(*args): try: - return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, - timeout=10) + return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, + timeout=10) except (OSError, subprocess.TimeoutExpired): return None diff --git a/ui/frame_host.py b/ui/frame_host.py index 7562d42..f9163c4 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -5,12 +5,16 @@ 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 +import tempfile from pathlib import Path MAC = sys.platform == "darwin" @@ -32,6 +36,45 @@ 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. + + A real temporary file avoids OpenSSH's blocked asynchronous stderr writes, + while keeping subprocess.run's captured output, text, check and timeout API. + """ + if not WINDOWS: + return subprocess.run(argv, **kwargs) + if kwargs.pop("capture_output", False): + if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None: + raise ValueError("stdout and stderr arguments may not be used with capture_output") + kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if kwargs.get("stderr") != subprocess.PIPE: + return subprocess.run(argv, **kwargs) + check = kwargs.pop("check", False) + text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors")) + with tempfile.TemporaryFile() as stderr: + kwargs["stderr"] = stderr + try: + result = subprocess.run(argv, **kwargs) + except subprocess.TimeoutExpired as error: + stderr.seek(0) + error.stderr = stderr.read() + raise + stderr.seek(0) + if text: + with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader: + result.stderr = reader.read() + else: + result.stderr = stderr.read() + if check: + result.check_returncode() + return result + + 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).""" @@ -244,7 +287,7 @@ def clipboard_text(): def ssh_hostname(alias): """The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP.""" try: - out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout + out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return alias for line in out.splitlines(): @@ -277,24 +320,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/frame_link.py b/ui/frame_link.py index 51979c6..d9fd551 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -74,8 +74,8 @@ def ssh_g(alias): """(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it.""" try: - out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, - timeout=10).stdout + out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, + timeout=10).stdout except (OSError, subprocess.TimeoutExpired): out = "" got = {} @@ -765,8 +765,8 @@ class Link: if not self.control: return False try: - return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, - stdin=subprocess.DEVNULL, timeout=5).returncode == 0 + return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, + stdin=subprocess.DEVNULL, timeout=5).returncode == 0 except (OSError, subprocess.TimeoutExpired): return False @@ -777,8 +777,8 @@ class Link: pending.kill() if self.control and self.alias: try: - subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, - stdin=subprocess.DEVNULL, timeout=5) + frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, + stdin=subprocess.DEVNULL, timeout=5) except (OSError, subprocess.TimeoutExpired): pass if proc and proc.poll() is None: @@ -983,8 +983,8 @@ class Link: *self.host_opts(device, ssh_target(a["host"], res.get("ip"))), "-o", "StrictHostKeyChecking=yes", device["alias"], "true"] try: - r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, - errors="replace", timeout=20) + r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, + errors="replace", timeout=20) err = r.stderr.strip() if r.returncode == 0: rows[i].update(ssh="ok", detail=f"{lead} ยท SSH works") diff --git a/ui/server.py b/ui/server.py index c459396..f37d3c5 100755 --- a/ui/server.py +++ b/ui/server.py @@ -334,8 +334,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True): # Never let ssh inherit our stdin: under the app it's the pipe held open for # --exit-on-eof, and Windows' ssh.exe waits on it forever. feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} - r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed, - text=text, errors="replace" if text else None, timeout=timeout) + r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed, + text=text, errors="replace" if text else None, timeout=timeout) except subprocess.TimeoutExpired: raise Failure(f"Timed out talking to {FRAME}") if r.returncode != 0: @@ -505,8 +505,8 @@ def save_shots(body): incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR)) try: try: - r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], - capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) + r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], + capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) except subprocess.TimeoutExpired: raise Failure("Copying screenshots timed out") if r.returncode != 0: @@ -1200,6 +1200,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) @@ -2255,7 +2257,7 @@ def push_file(path, dest="Downloads/"): else: # Modern scp uses SFTP, so the remote path isn't parsed by a shell. cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"] - r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) + r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) except subprocess.TimeoutExpired: raise Failure(f"Copying {name} timed out") if r.returncode != 0: @@ -2263,6 +2265,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 @@ -2295,8 +2302,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) @@ -2339,6 +2349,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": @@ -2416,6 +2428,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: @@ -2451,6 +2465,8 @@ class Handler(BaseHTTPRequestHandler): with (contextlib.nullcontext() if path in NOT_HEADSET_WORK 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) @@ -2626,6 +2642,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