diff --git a/docs/devices.md b/docs/devices.md index 847d8b7..ac8e9e5 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -76,8 +76,12 @@ 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 an address +that would be tried before the one in use passes the test, it gets **Use now**, +which reconnects; the reconnect picks the first address that answers, which is +that one. 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/tests/test_link.py b/tests/test_link.py index 782306e..85d1802 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -304,6 +304,19 @@ class Connecting(unittest.TestCase): self.assertEqual(self.link.active_device()["alias"], "frame-bare") self.assertEqual(self.routes[-1][0], "frame-bare") + def test_devices_view_ranks_addresses_for_the_current_network(self): + # The page offers Use now only on the address a reconnect would pick: rank says which. + d = self.device("frame.tail1234.ts.net") + self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True) + self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0) + self.link.state["network"] = {"id": "n-home", "name": "Home", "tailscale": {"up": True}} + view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"]) + self.assertEqual({a["host"]: a["rank"] for a in view["addresses"]}, + {"192.168.1.40": 1, "frame.tail1234.ts.net": 0}) # only Tailscale worked here so far + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) + view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"]) + self.assertEqual([a["rank"] for a in view["addresses"]], [0, 1]) # now the user's order decides + def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self): d = self.device("localhost") self.link.override = self.link.session_alias = "frame-t" 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..46a01b5 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -1017,6 +1017,8 @@ def devices_view(link): snap = link.reg.snapshot() active = link.active_device() names = {nid: link.reg.network_name(dict(n, id=nid)) for nid, n in snap["networks"].items()} + net = link.state["network"] or {} + tailscale_up = bool((net.get("tailscale") or {}).get("up")) devices = [] bare = link.bare(link.session_alias) if link.session_alias and not link.reg.by_alias(link.session_alias) else None for extra in ([active] if active.get("transient") and not active.get("none") else []) + \ @@ -1027,8 +1029,12 @@ def devices_view(link): view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")} view["active"] = d["id"] == active["id"] view["pinned"] = frame_devices.pinned(d["id"]) - view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]]) - for a in d["addresses"]] + # rank: where the next connection on this network tries it (0 first), so the page can + # tell which address a reconnect would pick. + ranks = {a["host"]: i for i, (a, _) in + enumerate(frame_devices.order_addresses(d["addresses"], net.get("id"), tailscale_up))} + view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]], + rank=ranks[a["host"]]) for a in d["addresses"]] devices.append(view) return {"devices": devices, "active": active["id"], "network": link.state["network"], "networks": [dict(n, id=nid, display=names[nid]) for nid, n in snap["networks"].items()], @@ -1111,7 +1117,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 169c061..9ac7a9b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -219,7 +219,7 @@ /* Every drop-down dark, like the fields; the panels' own rules below still size them. */ select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; } - select:focus { outline: none; border-color: var(--blue); } + select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; } option { background: #1e2329; color: var(--text); } .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } @@ -294,6 +294,7 @@ padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text); box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag; } .pop .shelf-head { margin-bottom: 10px; } + .pop .shelf-head h2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .pop .stats { margin-top: 12px; } .pop .chips { margin-top: 12px; } .batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; } @@ -699,7 +700,7 @@ .detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; } .sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; } } - @media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } } + @media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } } @@ -1787,9 +1788,8 @@ function battery(b, power) { $("battChip").innerHTML = pct == null ? "โ" : `${bolt}${pct}%`; $("battChip").title = `${label}. Click for the headset's details.`; - $("battChip").setAttribute("aria-label", `Battery ${pct}%, ${label}. Headset details`); - $("battChip").hidden = pct == null; - if (pct == null) closeDevPop(); + $("battChip").setAttribute("aria-label", pct == null ? "Headset details" : `Battery ${pct}%, ${label}. Headset details`); + $("battChip").hidden = false; // even without a battery reading: it's the way to the headset's details } // hidePopover throws where the menu isn't open (and doesn't exist in older browsers). function closeDevPop() { try { $("devPop").hidePopover(); } catch {} } @@ -1798,13 +1798,14 @@ $("devPop").addEventListener("beforetoggle", e => { if (e.newState !== "open") return; const r = $("battChip").getBoundingClientRect(); $("devPop").style.top = `${Math.round(r.bottom + 8)}px`; - $("devPop").style.right = `${Math.max(12, Math.round(innerWidth - r.right))}px`; + const width = Math.min(390, innerWidth - 24); // as .pop's CSS width + $("devPop").style.right = `${Math.max(12, Math.min(Math.round(innerWidth - r.right), innerWidth - width - 12))}px`; }); function render(s) { renderPerformance(s); battery(s.battery, s.power); - $("devPopTitle").textContent = window.connName || "Headset"; + $("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset"; $("devName").textContent = `${s.hostname} ยท ${s.ip ?? "no IP"}`; $("updated").textContent = new Date(s.time * 1000).toLocaleTimeString(); const h = s.disk.home; @@ -4394,7 +4395,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, session: 0, sig: "", phase: null }; function renderConnDlg() { const s = link.s; if (!s || !$("connDlg").open) return; @@ -4432,7 +4433,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 || "" }; @@ -4443,14 +4444,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 = `