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 = `