diff --git a/tests/test_link.py b/tests/test_link.py index 8dbfac4..f512f24 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -241,6 +241,17 @@ class Connecting(unittest.TestCase): self.assertIn("No headset", s["error"]["message"]) self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"])) + def test_a_headset_without_addresses_reaches_nothing(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None) + self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry + self.link.connect(["switch"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["retry_at"]), ("failed", None)) + self.assertIn("no addresses", s["error"]["message"]) + def test_a_bare_alias_lets_ssh_config_decide(self): self.link.override = "frame-bare" self.hosts({"frame-bare": "ok"}) # the stand-in ssh has no config: the alias is the host diff --git a/ui/frame_link.py b/ui/frame_link.py index 2b360cf..5dbcefc 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -338,8 +338,10 @@ class Link: """What every ssh command adds to reach DEVICE at HOST.""" if device.get("none"): return ["-o", "HostName=no-headset.invalid"] # fails at once, with ssh's own "can't resolve" - if device.get("transient") or not host: + if device.get("transient"): return [] + if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says + return ["-o", "HostName=no-address.invalid"] 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(device['id'])}", "-o", "HashKnownHosts=no", @@ -454,7 +456,7 @@ class Link: self.state.update(phase="connected", retry_at=None, error=None) else: self.fails += 1 - self.state.update(phase="failed", retry_at=None if device.get("none") else + self.state.update(phase="failed", retry_at=None if device.get("none") or not (device.get("transient") or device["addresses"]) else now() + RETRY[min(self.fails, len(RETRY)) - 1]) if not self.state["error"]: self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""} @@ -485,6 +487,9 @@ class Link: if device.get("none"): self.fail("find", "No headset is set up. Add one on the Devices tab.") return False + if not device.get("transient") and not device["addresses"]: + self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.") + return False # 1. this computer's network self.stage("network", "active") net = frame_network.current_network(self.last_fp) @@ -506,8 +511,7 @@ class Link: # 2. find the headset self.stage("find", "active") port = device.get("port") or 22 - bare = device.get("transient") or not device["addresses"] - if bare: + if device.get("transient"): host, port, user, proxied = ssh_g(device["alias"]) if user and not device.get("user"): device["user"] = user @@ -951,9 +955,13 @@ def devices_action(link, body, open_setup, busy=lambda: 0): elif action == "address-update": a = reg.update_address(did, body.get("host"), new_host=body.get("newHost"), kind=body.get("kind"), label=body.get("label")) + if is_active and a["host"] != body.get("host"): + link.invalidate() # the address in use may have moved msg = f"Saved {a['host']}" elif action == "address-remove": reg.remove_address(did, body.get("host")) + if is_active: + link.invalidate() # it may be the address in use: stop using it now msg = f"Removed {body.get('host')}" elif action == "address-move": delta = body.get("delta") diff --git a/ui/index.html b/ui/index.html index 754624e..7f77b32 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1537,12 +1537,12 @@ window.addEventListener("drop", e => { const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0)); sendFiles([...e.dataTransfer.files], dirs); }); -function upload(file, mode) { +function upload(file, mode, dev = window.connDevice) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/upload"); xhr.setRequestHeader("X-Frame-UI", UI_KEY); - if (window.connDevice) xhr.setRequestHeader("X-Frame-Device", window.connDevice); + if (dev) xhr.setRequestHeader("X-Frame-Device", dev); xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name)); xhr.setRequestHeader("X-Mode", mode); const bar = $("prog").firstElementChild; @@ -1603,7 +1603,12 @@ $("apkAltClose").onclick = () => $("apkAltDlg").close(); const TITLE_EXT = /\.(zip|exe)$/i; async function sendFiles(files, dirs = new Set()) { + const dev = window.connDevice; // the whole batch is for the headset it was dropped on for (const [i, f] of files.entries()) { + if (window.connDevice !== dev) { + toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true); + break; + } const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : ""; if (dirs.has(i)) { if (path) await sideload(f, path, true); @@ -1613,7 +1618,7 @@ async function sendFiles(files, dirs = new Set()) { if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; } if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; } const apk = f.name.toLowerCase().endsWith(".apk"); - await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push")); + await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev)); } $("fileInput").value = ""; refresh(); @@ -2639,9 +2644,14 @@ $("connSetup").onclick = setUpActive; // ---- Devices tab ---- PAGES.push("devices"); +let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection async function loadDevices() { if (!link.live) return; - try { dv.data = await api("/api/devices"); } catch (e) { return failed($("devList"), e); } + const seq = ++devicesSeq; + let data; + try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; } + if (seq !== devicesSeq) return; + dv.data = data; dv.list = dv.data.devices; if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active; const sel = $("devSel");