From 36dd5a05f2f36c22303a9583b129bb18823a6254 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:28:38 +1000 Subject: [PATCH] Make the offered home address the one that gets used, and keep edits safe An interrupted review pass pointed at four problems in the pill's dialog, each confirmed against the Frame: - The offer to add the Frame's LAN address appended it after the Tailscale name, which then kept winning on that network, so nothing changed. The offer now adds it first in the list (address-add takes first: true); away from home the Tailscale name still leads. A tested address that ranks above the one in use gets a Use now button that reconnects through it. - A half-typed edit was thrown away when the connection state changed after focus left the input, and when the server refused the save. The edit row now stays until it is saved or cancelled. - Pressing Enter twice sent the update twice. - The offer's button could act on the previously selected headset. Keyboard focus also stays on the same button of the same address when the rows are rebuilt. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/devices.md | 7 ++++-- tests/test_devices.py | 14 ++++++++++++ ui/frame_devices.py | 6 +++-- ui/frame_link.py | 3 ++- ui/index.html | 53 ++++++++++++++++++++++++++++++------------- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/docs/devices.md b/docs/devices.md index 847d8b7..1d691b3 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -76,8 +76,11 @@ services and checks `ALIAS.local` and `frame.local`. **The pill's dialog** lists the same addresses, with what each one answered, and can add, edit, reorder and remove them without leaving the page you're on. When the headset reports a LAN IP on the same network as this computer and that IP -isn't saved, it offers to add it, so at home the app connects directly rather -than over Tailscale. A new or edited address is tested straight away. +isn't saved, it offers to add it. The offer puts the address first in the list, +so on that network it wins over the Tailscale name; away from home the Tailscale +name still leads. A new or edited address is tested straight away. When a tested +address ranks above the one in use, **Use now** reconnects through it; otherwise +the change takes effect the next time Frame Control connects. ## Networks diff --git a/tests/test_devices.py b/tests/test_devices.py index 31f3c61..dde3bea 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -296,6 +296,20 @@ class Pins(Base): class Registry(Base): + def test_an_address_added_first_wins_on_its_own_network(self): + # The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has + # worked here before. The LAN address has to go ahead of it to be used at home. + d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"]) + self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0) + self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True) + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it + addrs = self.reg.get(d["id"])["addresses"] + self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"]) + at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)] + self.assertEqual(at_home[0], "192.168.1.40") + away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)] + self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads + def test_address_editing(self): d = self.reg.add_device("frame-3", hosts=["192.168.1.40"]) a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS") diff --git a/ui/frame_devices.py b/ui/frame_devices.py index 8345302..ba6f5ac 100644 --- a/ui/frame_devices.py +++ b/ui/frame_devices.py @@ -623,7 +623,9 @@ class Registry: return a raise DeviceError(f"{host} isn't one of this headset's addresses") - def add_address(self, device_id, host, kind=None, label=""): + def add_address(self, device_id, host, kind=None, label="", first=False): + """Add an address at the end of the list, or at the front (first=True), where the + user's order makes it win over the others that work on the same network.""" with self._changing(): d = self._find(device_id) a = new_address(host, kind, label) @@ -631,7 +633,7 @@ class Registry: raise DeviceError(f"{a['host']} is already on the list") if len(d["addresses"]) >= 32: raise DeviceError("That's enough addresses for one headset") - d["addresses"].append(a) + d["addresses"].insert(0 if first else len(d["addresses"]), a) self.save() return copy.deepcopy(a) diff --git a/ui/frame_link.py b/ui/frame_link.py index 51979c6..b890b52 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -1111,7 +1111,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0): raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}") 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 "") + a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "", + first=body.get("first") is True) if is_active and link.state["phase"] == "failed": link.kick("retry") msg = f"Added {a['host']}" diff --git a/ui/index.html b/ui/index.html index b7f2465..77317d2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4323,7 +4323,7 @@ window.connBanner = renderBanner; // The pill's dialog: how it's connected, and the headset's addresses, which can be added, // edited and reordered right here. The steps it took stay folded away while all is well. -const cd = { editing: null, sig: "", phase: null }; +const cd = { editing: null, editDev: null, sig: "", phase: null }; function renderConnDlg() { const s = link.s; if (!s || !$("connDlg").open) return; @@ -4361,7 +4361,7 @@ const sameSubnet = (a, b) => !!a && !!b && a.split(".").slice(0, 3).join(".") == // saving it lets Frame Control connect directly at home rather than over Tailscale. function lanOffer(s, d) { const ip = state && state.ip, n = s.network || {}; - if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || !sameSubnet(ip, n.local_ip)) return null; + if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || ip === n.local_ip || !sameSubnet(ip, n.local_ip)) return null; if (d.addresses.some(a => a.host === ip) || (s.probes || []).some(p => p.ip === ip && p.kind !== "tailscale")) return null; const named = dv.data && dv.data.networks.find(x => x.id === n.id && x.name); return { ip, label: named ? named.name : n.ssid || "" }; @@ -4372,14 +4372,15 @@ function renderConnAddrs() { const offer = s && lanOffer(s, d); $("connOffer").hidden = !offer; if (offer) { - const key = offer.ip + offer.label; + const key = JSON.stringify([d.id, offer.ip, offer.label]); if ($("connOffer").dataset.key !== key) { $("connOffer").dataset.key = key; - $("connOffer").innerHTML = `
The Frame is at ${esc(offer.ip)} on this network. Add it for this - network? Frame Control will then connect to it directly whenever you're here.
+ $("connOffer").innerHTML = `
The Frame is at ${esc(offer.ip)} on this network. Add it, and + Frame Control tries it first here, reaching the headset directly.
`; + // First in the list: on this network it then wins over the address in use now. $("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip, - kind: "lan", label: offer.label }, e.currentTarget) + kind: "lan", label: offer.label, first: true }, e.currentTarget) .then(res => res && testQuietly(d.id)); } } else $("connOffer").dataset.key = ""; @@ -4394,13 +4395,19 @@ function renderConnAddrs() { const tested = new Map((((s.tests || {})[d.id] || {}).rows || []).map(r => [r.host, r])); const inUse = s.phase === "connected" && s.via ? s.via.host : null; const sig = JSON.stringify([d.id, d.addresses, [...probes.values(), ...tested.values()].map(p => [p.host, p.state, p.ssh, p.detail]), cd.editing, inUse]); - // Leave the rows alone when nothing has changed, and while an address is being edited. - if (sig === cd.sig || cd.editing && list.contains(document.activeElement) && document.activeElement.matches("input")) return; + // Leave the rows alone when nothing has changed, and while an address is being edited: what's + // typed stays put until it's saved or cancelled, wherever the focus is. + if (sig === cd.sig || cd.editing && $("ceHost") && cd.editDev === d.id && d.addresses.some(a => a.host === cd.editing)) return; cd.sig = sig; - const refocus = cd.refocus; // after an edit, back to that row's Edit button + // Rebuilding the rows would drop the keyboard focus: put it back on the same button of the + // same address (after an edit, on that row's Edit button). + const held = list.contains(document.activeElement) && document.activeElement.closest("[data-host]"); + const refocus = cd.refocus ? [cd.refocus, "[data-ed]"] + : held ? [held.dataset.host, ["[data-use]", "[data-mv='-1']", "[data-mv='1']", "[data-ed]", "[data-rm]"].find(q => document.activeElement.matches(q))] : null; cd.refocus = null; + const usedAt = d.addresses.findIndex(a => a.host === inUse); list.innerHTML = d.addresses.map((a, i) => { - if (cd.editing === a.host) return `
+ if (cd.editing === a.host && cd.editDev === d.id) return `
`; @@ -4412,16 +4419,23 @@ function renderConnAddrs() { return `
${esc(a.host)} ${esc(KIND_TAG[a.kind] || a.kind)}${a.label ? ` ${esc(a.label)}` : ""}
${result}
+ ${t && t.ssh === "ok" && usedAt > i ? `` : ""}
`; }).join("") || `
No addresses yet. Add the Frame's IP address below.
`; - if (refocus) list.querySelector(`[data-host="${CSS.escape(refocus)}"] [data-ed]`)?.focus(); + if (refocus) { + const row = list.querySelector(`[data-host="${CSS.escape(refocus[0])}"]`); + const again = row && refocus[1] && row.querySelector(refocus[1]); + if (row) (again && !again.disabled ? again : row.querySelector("[data-ed]")).focus(); + } list.querySelectorAll("[data-host]").forEach(row => { const host = row.dataset.host; row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () => devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b)); - row.querySelector("[data-ed]").onclick = () => { cd.editing = host; renderConnAddrs(); $("ceHost").focus(); }; + row.querySelector("[data-ed]").onclick = () => { cd.editing = host; cd.editDev = d.id; renderConnAddrs(); $("ceHost").focus(); }; + const use = row.querySelector("[data-use]"); + if (use) use.onclick = () => devAction(`Switch to ${host}`, { action: "retry", id: d.id }, use); row.querySelector("[data-rm]").onclick = e => { if (host === inUse && !confirm(`${host} is how Frame Control is reaching the headset now. Remove it?`)) return; devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget); @@ -4429,12 +4443,19 @@ function renderConnAddrs() { }); if ($("ceSave")) { const host = cd.editing, a = d.addresses.find(x => x.host === host); - const save = e => { + let saving = false; + const save = async e => { e.preventDefault(); + if (saving) return; // a second Enter while the first is on its way const newHost = $("ceHost").value.trim(), label = $("ceLabel").value; - cd.editing = null; cd.refocus = newHost || host; - devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave")) - .then(res => res && testQuietly(d.id)); + if (!newHost) return $("ceHost").focus(); + saving = true; + const res = await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave")); + saving = false; + if (!res) return; // refused: what was typed stays, to correct + cd.editing = null; cd.sig = ""; cd.refocus = newHost; + renderConnAddrs(); + testQuietly(d.id); }; $("ceSave").onclick = save; [$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } });