From cb182dbce58c98d46e6c7ebe1dca61e561ac0b35 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:48:39 +1000 Subject: [PATCH] Devices: fixes from review round 3 - Attempts carry a generation: one overtaken by a switch, a removal or a login change routes nothing back to the old headset and can't report connected. - Removing the active headset or changing its user/port reroutes at once, before anything that can fail. - One known_hosts file per headset (~/.ssh/frame-control-hosts/): forgetting one headset's key can't drop another's, whoever else writes. - learn() checks, under the config lock, that the block is still what the attempt started from before writing to it. - A switch stops live video and drops captures from the previous headset. - A probe shares its time between the addresses a name resolves to. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/devices.md | 19 +++--- tests/test_devices.py | 36 +++++++++--- tests/test_link.py | 27 ++++++++- ui/frame_devices.py | 132 ++++++++++++++++++------------------------ ui/frame_link.py | 89 ++++++++++++++++++---------- ui/index.html | 6 ++ 6 files changed, 187 insertions(+), 122 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index 4f01b69..8e09e7d 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -24,8 +24,8 @@ work for each one. - **Nothing to migrate by hand.** On first start, the app imports every `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with the block's HostName as its first address. It also copies the host key your - `known_hosts` already trusts for that address into the app's own - `~/.ssh/frame-control_known_hosts`, so nobody is asked to trust it again. + `known_hosts` already trusts for that address into the headset's own + known_hosts file, `~/.ssh/frame-control-hosts/`, so nobody is asked to trust it again. - **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS, `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias. When it writes its block, the app picks the headset up by itself. If Set Up @@ -33,7 +33,9 @@ work for each one. at the top of its list. - **Use this headset** (or the switcher in the header, or the app's **Frame → Headset** menu) moves the whole app to another headset; every panel - reloads from it. + reloads from it. From that moment no command goes to the previous headset, even + if the new one never answers. It waits while an install is running, since an + install reads the SSH settings step by step. - **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick the box; either way it isn't imported again unless Set Up Connection changes it. - A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the @@ -106,15 +108,18 @@ headset makes the connector start again. Once connected, every `ssh`, `scp` and `rsync` the app runs gets `-o HostName=
-o HostKeyAlias=frame-control- --o UserKnownHostsFile=~/.ssh/frame-control_known_hosts -o User=… -o Port=…`. The +-o UserKnownHostsFile=~/.ssh/frame-control-hosts/ -o HashKnownHosts=no -o User=… -o Port=…`. The alias's block in `~/.ssh/config` is also updated to the last address that worked (and to the user and port you set), so Terminal's `ssh frame` and the -scripts follow. +scripts follow. Edits to `~/.ssh/config` take a lock file +(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never +write over a change someone else made since the app last read the file. **Host keys are pinned per headset, not per address.** Your own `known_hosts` is keyed by address, so a different device answering at a remembered IP (a DHCP -lease that moved) would look like a new host there. The app keys its own -known_hosts by headset instead: a different device answering at one of its +lease that moved) would look like a new host there. The app keeps one known_hosts +file per headset instead, so saving or forgetting one headset's key never touches +another's: a different device answering at one of its addresses is refused, and the pill says so. A headset's first connection trusts the key it shows, as Set Up Connection does. After reinstalling SteamOS the headset has a new key; **Forget identity** on the Devices tab lets the next diff --git a/tests/test_devices.py b/tests/test_devices.py index abad757..10d77e2 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -181,6 +181,14 @@ class ConfigRewrite(Base): self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"]) self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left + def test_learning_skips_a_block_someone_changed(self): + # The connector learned an address, but Set Up Connection moved the block meanwhile. + self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "old.example", "user": None, "port": None})) + self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text()) + self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22})) + def test_zone_is_escaped_and_read_back(self): fd.rewrite_block("frame", hostname="fe80::1%en0") self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text()) @@ -200,12 +208,21 @@ class Pins(Base): self.assertFalse(fd.pinned("d1")) self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) self.assertTrue(fd.pinned("d1")) - self.assertEqual(fd.known_hosts().read_text(), f"frame-control-d1 {KEY}\n") + self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n") self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent - self.assertEqual(fd.known_hosts().read_text().count("\n"), 1) + self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1) self.assertFalse(fd.seed_pin("d2", ["never-seen.example"])) self.assertTrue(fd.forget_pin("d1")) self.assertFalse(fd.pinned("d1")) + self.assertFalse(fd.forget_pin("d1")) + + def test_each_headset_has_its_own_file(self): + (self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n") + fd.seed_pin("da", ["a.local"]) + fd.seed_pin("db", ["b.local"]) + fd.forget_pin("da") + self.assertTrue(fd.pinned("db")) # forgetting one can't touch another + self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db")) def test_hashed_and_non_default_port_entries(self): kh = self.ssh / "known_hosts" @@ -213,19 +230,22 @@ class Pins(Base): subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) 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().read_text()) + self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) def test_hashed_pins_are_found_and_forgotten(self): - fd.known_hosts().write_text(f"frame-control-d4 {KEY}\n") - subprocess.run(["ssh-keygen", "-H", "-f", str(fd.known_hosts())], capture_output=True, check=True) - self.assertNotIn("frame-control-d4", fd.known_hosts().read_text()) + 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) + self.assertNotIn("frame-control-d4", target.read_text()) self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.forget_pin("d4")) self.assertFalse(fd.pinned("d4")) - self.assertFalse(fd.known_hosts().with_name("frame-control_known_hosts.old").exists()) def test_known_hosts_option_uses_the_override(self): - self.assertEqual(fd.known_hosts_opt(), str(self.ssh / "frame-control_known_hosts")) + self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5")) + os.environ.pop("FRAME_CONTROL_SSH_DIR") + self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on class Registry(Base): diff --git a/tests/test_link.py b/tests/test_link.py index 1f46be2..a5265f3 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -118,6 +118,10 @@ class Connecting(unittest.TestCase): explain=explain) self.addCleanup(self.link.stop) + def pin(self, device_id): + fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True) + fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n") + def hosts(self, mapping): os.environ["FAKESSH_HOSTS"] = json.dumps(mapping) @@ -199,7 +203,7 @@ class Connecting(unittest.TestCase): def test_pinned_identity_is_checked_strictly(self): d = self.device("localhost") - (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") + self.pin(d["id"]) self.hosts({"localhost": "ok"}) self.link.connect(["start"]) master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] @@ -235,7 +239,7 @@ class Connecting(unittest.TestCase): def test_test_now_checks_every_address_without_touching_the_connection(self): d = self.device("127.0.0.1", "localhost", "nothing.invalid") - (self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") + self.pin(d["id"]) self.hosts({"127.0.0.1": "wrong", "localhost": "ok"}) self.link.test(d["id"]) rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]} @@ -262,6 +266,25 @@ class Connecting(unittest.TestCase): self.assertIn("HostName=nothing.invalid", opts) self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts) + def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + other = self.reg.add_device("frame-other", port=self.port) + self.reg.add_address(other["id"], "nothing.invalid") + pick = fl.Link.pick + + def switch_then_pick(*args): + if not getattr(self, "switched", False): + self.switched = True + self.link.use(other["id"]) # the user switches while A is being found + return pick(*args) + with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)): + self.link.connect(["start"]) + self.assertEqual(self.routes[-1][0], "frame-other") + self.assertEqual(self.link.snapshot()["phase"], "connecting") + self.assertFalse(self.link.alive()) + self.assertIsNone(self.link.master) + def test_no_switching_while_something_is_installing(self): d = self.device("localhost") other = self.reg.add_device("frame-other") diff --git a/ui/frame_devices.py b/ui/frame_devices.py index 2fd49f1..911f006 100644 --- a/ui/frame_devices.py +++ b/ui/frame_devices.py @@ -22,8 +22,9 @@ scripts go on working, and the connector rewrites the block's HostName to the last address that worked, so they follow it. Host keys are pinned per headset, not per address: ssh gets -`-o HostKeyAlias=frame-control-` and a known_hosts file of our own, so a -different device answering at a remembered IP is caught. +`-o HostKeyAlias=frame-control-` and a known_hosts file of the headset's own +(~/.ssh/frame-control-hosts/), so a different device answering at a +remembered IP is caught. Python stdlib only. """ @@ -66,14 +67,21 @@ def ssh_config(): return ssh_dir() / "config" -def known_hosts(): - return ssh_dir() / "frame-control_known_hosts" +PIN_DIR = "frame-control-hosts" -def known_hosts_opt(): - """How ssh is told about our known_hosts file. `~` rather than the full path when it's - the usual place, so a home folder with a space in its name can't split the option.""" - return "~/.ssh/frame-control_known_hosts" if not os.environ.get("FRAME_CONTROL_SSH_DIR") else str(known_hosts()) +def known_hosts(device_id): + """The headset's own known_hosts file: one per headset, so saving or forgetting one + headset's key (by ssh or by the app) can never touch another's.""" + return ssh_dir() / PIN_DIR / device_id + + +def known_hosts_opt(device_id): + """How ssh is told about it. `~` rather than the full path when it's the usual + place, so a home folder with a space in its name can't split the option.""" + if os.environ.get("FRAME_CONTROL_SSH_DIR"): + return str(known_hosts(device_id)) + return f"~/.ssh/{PIN_DIR}/{device_id}" def host_key_alias(device_id): @@ -262,11 +270,18 @@ def _edit_config(path, change): raise OSError(f"{path} kept changing while Frame Control tried to update it") -def rewrite_block(alias, path=None, hostname=None, user=None, port=None): +def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None): """Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the - file alone. -> True if the file changed. Does nothing if there's no such block.""" - return _edit_config(Path(path or ssh_config()), - lambda lines: _rewritten(lines, alias, hostname, user, port)) + file alone. -> True if the file changed. Does nothing if there's no such block, or + if `expect` ({"hostname", "user", "port"}; None values match anything) no longer + describes the block, checked under the lock: someone else changed it meanwhile.""" + def change(lines): + if expect: + block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None) + if not block or any(v is not None and block[k] != v for k, v in expect.items()): + return None + return _rewritten(lines, alias, hostname, user, port) + return _edit_config(Path(path or ssh_config()), change) def _rewritten(lines, alias, hostname, user, port): @@ -308,13 +323,6 @@ def remove_block(alias, path=None): # ---- pinned host keys ------------------------------------------------------------- -def _pin_lines(path=None): - try: - return Path(path or known_hosts()).read_text(encoding="utf-8").splitlines() - except (OSError, UnicodeDecodeError): - return [] - - def _keygen(*args): try: return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, @@ -323,29 +331,27 @@ def _keygen(*args): return None -def _pin_lock(target): - return file_lock(target.with_name(target.name + ".lock")) - - -def pinned(device_id, path=None): - """Whether a key is saved for the device. The app's own entries are plain text (it - passes HashKnownHosts=no), but ask ssh-keygen too in case one was hashed.""" +def pinned(device_id): + """Whether a key is saved for the headset. Entries are plain text (ssh gets + HashKnownHosts=no), but ask ssh-keygen too in case one was hashed.""" + target = known_hosts(device_id) + try: + lines = target.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return False name = host_key_alias(device_id) - target = Path(path or known_hosts()) - if any(line.split(None, 1)[0].split(",").count(name) for line in _pin_lines(target) + if any(line.split(None, 1)[0].split(",").count(name) for line in lines if line.strip() and not line.startswith("#")): return True - if not target.is_file(): - return False r = _keygen("-F", name, "-f", str(target)) return bool(r and r.returncode == 0 and r.stdout.strip()) -def seed_pin(device_id, hosts, port=22, sources=None, path=None): - """Copy the host keys ssh already trusts for one of `hosts` into our file under the - device's alias, so moving to per-device pinning asks nobody to trust anything again. - -> True if a key was pinned.""" - if pinned(device_id, path): +def seed_pin(device_id, hosts, port=22, sources=None): + """Copy the host keys ssh already trusts for one of `hosts` into the headset's file + under its alias, so moving to per-headset pinning asks nobody to trust anything again. + -> True if a key is pinned.""" + if pinned(device_id): return True sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"] name = host_key_alias(device_id) @@ -355,56 +361,30 @@ def seed_pin(device_id, hosts, port=22, sources=None, path=None): for src in sources: if not Path(src).is_file(): continue - try: - out = subprocess.run(["ssh-keygen", "-F", wanted, "-f", str(src)], capture_output=True, - stdin=subprocess.DEVNULL, text=True, timeout=10).stdout - except (OSError, subprocess.TimeoutExpired): - continue - for line in out.splitlines(): + r = _keygen("-F", wanted, "-f", str(src)) + for line in (r.stdout if r else "").splitlines(): f = line.split() if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"): keys.append(f"{name} {f[1]} {f[2]}") if keys: - target = Path(path or known_hosts()) - with _pin_lock(target): - with open(target, "a", encoding="utf-8") as fh: - fh.write("\n".join(dict.fromkeys(keys)) + "\n") - if not frame_host.WINDOWS: - target.chmod(0o600) + target = known_hosts(device_id) + target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True) + fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent)) + with os.fdopen(fd_, "w", encoding="utf-8") as fh: + fh.write("\n".join(dict.fromkeys(keys)) + "\n") + os.replace(tmp, target) # whole file at once: ssh never sees half of it return True return False -def forget_pin(device_id, path=None): - """Drop a device's pinned keys, e.g. after SteamOS was reinstalled. The next connection +def forget_pin(device_id): + """Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection trusts whatever key the headset shows, as a first connection does.""" - target = Path(path or known_hosts()) - name = host_key_alias(device_id) - with _pin_lock(target): - # ssh itself may append a first-seen key meanwhile (accept-new): swap the file - # only if it still holds what was read, else read it again. - removed = False - for _ in range(5): - text = "\n".join(_pin_lines(target)) - lines = text.splitlines() - kept = [line for line in lines if not (line.strip() and name in line.split(None, 1)[0].split(","))] - if kept == lines: - break - fd_, tmp = tempfile.mkstemp(prefix=target.name + ".", dir=str(target.parent)) - with os.fdopen(fd_, "w", encoding="utf-8") as fh: - fh.write("".join(line + "\n" for line in kept)) - if "\n".join(_pin_lines(target)) == text: - os.replace(tmp, target) - removed = True - break - os.unlink(tmp) - if target.is_file() and pinned(device_id, target): # a hashed entry: ssh-keygen finds it - r = _keygen("-R", name, "-f", str(target)) - removed = removed or bool(r and r.returncode == 0) - old = target.with_name(target.name + ".old") # ssh-keygen -R leaves a backup - if old.exists(): - old.unlink() - return removed + try: + known_hosts(device_id).unlink() + return True + except FileNotFoundError: + return False # ---- address order -------------------------------------------------------------------- diff --git a/ui/frame_link.py b/ui/frame_link.py index ed88e7a..7359abe 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -98,14 +98,17 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None): except (socket.gaierror, UnicodeError, OSError) as e: return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)} last = None - for family, kind, proto, _, addr in infos[:4]: + infos = infos[:4] + for n, (family, kind, proto, _, addr) in enumerate(infos): ip = addr[0] left = deadline - now() if left <= 0: break update(state="trying", detail=f"Trying {ip}", ip=ip) s = socket.socket(family, kind, proto) - s.settimeout(left) + # Share the time out, so one address that never answers (a dead IPv6 route, + # say) leaves the others their turn. + s.settimeout(left / (len(infos) - n)) t0 = time.monotonic() try: s.connect(addr) @@ -154,7 +157,10 @@ class Link: self.last_attempt = 0 self.config_mtime = None self.thread = None + self.attempt_gen = 0 self.pending = None # an ssh handshake still running + self.gen = 0 # bumped when the headset or its login changes: older attempts are void + self.route_lock = threading.Lock() self.routed = None # the device id every ssh command points at # ---- publishing ---- @@ -227,7 +233,7 @@ class Link: self.close_master() def alive(self): - if self.state["phase"] != "connected": + if self.state["phase"] != "connected" or self.kicks: return False if not self.control: return True @@ -248,12 +254,20 @@ class Link: self.state["phase"] != "connecting"), wait) def use(self, device_id): - """Switch to another headset. Commands go to it from now on (never the last one), - and wait in ensure() for the connector to reach it.""" + """Switch to another headset.""" self.reg.set_active(device_id) self.override = None + self.invalidate() + + def invalidate(self): + """The headset in use, or how to log in to it, changed: from now on commands go to + the one now selected (never the last one), any attempt still running is void, + and ensure() waits for the connector to reach it.""" device = self.active_device() - self.apply(device["alias"], self.first_route(device)) + with self.route_lock: + self.gen += 1 + self.apply(device["alias"], self.first_route(device)) + self.routed = None # the next attempt routes again with self.cond: self.state["phase"] = "connecting" self.kicks.append("switch") @@ -301,7 +315,7 @@ class Link: return [] return ["-o", f"HostName={frame_devices.ssh_host(host)}", "-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}", - "-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt()}", "-o", "HashKnownHosts=no", + "-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no", "-o", f"User={device['user']}", "-o", f"Port={device['port']}"] def public_device(self, d): @@ -361,7 +375,7 @@ class Link: self.devices_changed() after = self.active_device() if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")): - self.kick("switch") # Set Up Connection changed the active headset's login + self.invalidate() # Set Up Connection changed the active headset's login def refresh_network(self): net = frame_network.current_network(self.last_fp) @@ -374,13 +388,15 @@ class Link: why = self.describe(reasons) self.last_attempt = now() self.close_master() - device = self.active_device() - if self.route_key(device) != self.routed: - # Another headset, or a new user or port: nothing may go on using the old - # route, even if this attempt fails. - self.alias, self.opts = device["alias"], self.first_route(device) - self.apply(self.alias, self.opts) - self.routed = self.route_key(device) + with self.route_lock: + gen = self.attempt_gen = self.gen + device = self.active_device() + if self.route_key(device) != self.routed: + # Another headset, or a new user or port: nothing may go on using the old + # route, even if this attempt fails. + self.alias, self.opts = device["alias"], self.first_route(device) + self.apply(self.alias, self.opts) + self.routed = self.route_key(device) with self.cond: self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None, error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(), @@ -393,6 +409,17 @@ class Link: try: ok = self.attempt(device) finally: + with self.cond: + if gen != self.gen: + # The headset changed meanwhile: this attempt's result is about the + # old one. Leave "connecting"; the queued switch starts the next. + self.state["phase"] = "connecting" + self.version += 1 + self.cond.notify_all() + ok = None + if ok is None: + self.close_master() + return with self.cond: self.state["finished"] = now() if ok: @@ -574,16 +601,16 @@ class Link: return if self.route_key(now_dev) != self.route_key(device): return - # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too. + # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too, + # unless the block changed since this attempt began (checked under the file lock). + login = device.get("config_login") or [None, None] + expect = {"hostname": device.get("config_host"), "user": login[0], "port": login[1]} try: - block = next((b for b in frame_devices.parse_blocks(frame_devices.read_config()) - if b["alias"] == device["alias"]), None) - moved = block and block["hostname"] != device.get("config_host") and device.get("config_host") - if not moved and frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"], - port=device["port"]): + if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"], + port=device["port"], expect=expect): self.config_mtime = frame_devices.ssh_config().stat().st_mtime - if block and not moved: self.reg.set_config_host(device["id"], host) + self.reg.sync_from_config() # records the login it now holds except OSError: pass # not fatal: the app itself doesn't need the file self.devices_changed() @@ -622,8 +649,11 @@ class Link: -> "ok", "next" (try another address) or "stop".""" opts = self.host_opts(device, a["host"]) alias = device["alias"] - self.alias, self.opts = alias, opts - self.apply(alias, opts) + with self.route_lock: + if self.attempt_gen != self.gen: + return "stop" # the headset changed: don't route anything back to this one + self.alias, self.opts = alias, opts + self.apply(alias, opts) target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "") self.stage("ssh", "active", f"Opening SSH to {target}") if self.control and self.check(opts, alias): @@ -850,16 +880,20 @@ def devices_action(link, body, open_setup, busy=lambda: 0): link.use(did) msg = f"Switched to {d['name']}" elif action == "update": + before = reg.get(did) d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port")) + if is_active and (d["user"], d["port"]) != (before["user"], before["port"]): + link.invalidate() # before anything else can fail: the old login mustn't stay in use try: frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"]) except OSError as e: raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") - if is_active: - link.kick("switch") msg = f"Saved {d['name']}" elif action == "remove": d = reg.remove_device(did) + if is_active: + link.override = None + link.invalidate() frame_devices.forget_pin(did) removed = False if body.get("config"): @@ -867,9 +901,6 @@ def devices_action(link, body, open_setup, busy=lambda: 0): removed = frame_devices.remove_block(d["alias"]) except OSError as e: raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}") - if is_active: - link.override = None - link.kick("switch") msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "") elif action == "address-add": a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "") diff --git a/ui/index.html b/ui/index.html index 3e7c6ee..dd584c4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2533,6 +2533,12 @@ function onConnection(s) { online = null; devGen++; // answers still on their way are about the other headset refreshing = null; // and the next status check must be a new one + if (live) toggleLive(false); // the other headset's video or captures + viewGen++; // a capture still on its way is the other headset's too + lastImg = null; lastShot = null; + $("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true; + ["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true); + $("saveBtn").disabled = true; link.reload = true; // everything on the page was the other headset's $("battChip").hidden = true; }