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)