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/<id>):
  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) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 21:48:39 +10:00
1 parent 18334b5989
commit cb182dbce5
6 files changed
+187 -122

No files matched your search

+12 -7
View File
@@ -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/<id>`, 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=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control_known_hosts -o User=… -o Port=…`. The
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -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
+28 -8
View File
@@ -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):
+25 -2
View File
@@ -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")
+56 -76
View File
@@ -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-<id>` and a known_hosts file of our own, so a
different device answering at a remembered IP is caught.
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
(~/.ssh/frame-control-hosts/<id>), 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 --------------------------------------------------------------------
+60 -29
View File
@@ -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 "")
+6
View File
@@ -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;
}