From 34e91988b1e2ba158701f25b5d6b9ce88477f4ee Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:40:10 +1000 Subject: [PATCH] Devices: fixes from review round 14 - Retry now (while connected) and Forget identity wait for running installs. - Terminal windows get the headset's address by name, so a link-local IPv6 zone never has to pass through Windows' console. - Renaming the headset in use shows at once in the header. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_link.py | 27 ++++++++++++++++++++++++++- ui/frame_link.py | 16 ++++++++++++++-- ui/server.py | 2 +- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/test_link.py b/tests/test_link.py index 7fff303..2b77eef 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -392,7 +392,8 @@ class Connecting(unittest.TestCase): for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]}, {"action": "update", "id": d["id"], "port": 2222}, {"action": "address-remove", "id": d["id"], "host": "localhost"}, - {"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"}): + {"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"}, + {"action": "forget-identity", "id": d["id"]}): with self.assertRaises(fd.DeviceError, msg=body): fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1) # Renaming, or changing another headset, is fine. @@ -400,6 +401,30 @@ class Connecting(unittest.TestCase): fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1) self.assertEqual(self.reg.get(d["id"])["name"], "Desk") + def test_no_reconnecting_under_a_running_install(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + with self.assertRaises(fd.DeviceError): + fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1) + fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs + + def test_terminals_get_the_address_by_name(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + alias, opts = self.link.named_route() + self.assertEqual(alias, "frame-t") + self.assertIn("HostName=localhost", opts) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts) + + def test_a_rename_shows_at_once(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertEqual(self.link.snapshot()["device"]["name"], "Desk") + def test_stopping_mid_handshake_leaves_no_ssh_behind(self): self.device("localhost") self.hosts({"localhost": "slow"}) diff --git a/ui/frame_link.py b/ui/frame_link.py index d804fcf..2427ca9 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -304,6 +304,15 @@ class Link: self.cond.notify_all() self.devices_changed() + def named_route(self): + """(alias, options) for a terminal window: like every command's, but with the + address by name, not the IP it answered from. A zone's % can't be passed through + Windows' console, and ssh resolves the name itself.""" + device = self.active_device() + via = self.state.get("via") if self.state.get("phase") == "connected" else None + host = via["host"] if via else (device["addresses"][0]["host"] if device.get("addresses") else None) + return device["alias"], self.host_opts(device, host) + def first_route(self, device): """Where commands go before any address has answered: the first one, with the headset's own pinned identity, so nothing reaches another device meanwhile.""" @@ -946,8 +955,9 @@ def devices_action(link, body, open_setup, busy=lambda: 0): did = body.get("id") active = link.active_device() is_active = did == active["id"] - moves = action == "use" or (is_active and ( - action in ("remove", "address-remove") + moves = action == "use" or (action == "retry" and link.alive()) or (is_active and ( + # (a retry while connected would cut the install's connection) + action in ("remove", "address-remove", "forget-identity") or (action == "update" and (body.get("user") is not None or body.get("port") is not None)) or (action == "address-update" and body.get("newHost") not in (None, body.get("host"))))) if moves and busy(): @@ -965,6 +975,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0): if is_active and login_changed: link.invalidate() # before anything else can fail: the old login mustn't stay in use msg = f"Saved {d['name']}" + if is_active and not login_changed: + link.publish(device=link.public_device(link.active_device())) # a new name shows at once if login_changed: # Only what changed, and only if the block still says what it did: Set Up # Connection may have written a new login meanwhile, which then stands. diff --git a/ui/server.py b/ui/server.py index 841b1d5..10f257c 100755 --- a/ui/server.py +++ b/ui/server.py @@ -582,7 +582,7 @@ def open_thing(body): raise Failure("open that from the app", 400) # The headset and address in use, as every other command gets them (one snapshot). with _route_lock: - alias, opts = FRAME, list(HOST_OPTS) + alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS)) host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None) if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"): raise Failure("No headset address to use: add one on the Devices tab", 400)