From cb0a90a9c39f5fa0f5f89ab30540e612309bf429 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:36:13 +1000 Subject: [PATCH] Change the headset's addresses from the connection pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill says plainly whether the headset is connected and how ("Connected · Tailscale", or the network's name), and never shows a truncated address. Its dialog now leads with that, then lists the headset's addresses with what each answered; they can be added, edited, reordered and removed right there, and a new one is tested straight away. When the Frame reports a LAN IP on this computer's network that isn't saved, it offers to add it, so at home the app connects directly rather than over Tailscale. The connection steps and this computer's network fold away while it's connected and open when it isn't. Retry now and Set Up Connection only show when they'd help; the buttons stay in reach when the dialog scrolls. On the Devices tab the SSH alias, user, port and Forget identity move under Advanced, and the address kind is worked out from the address. Report a problem is a speech bubble rather than a warning sign. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/devices.md | 16 ++- docs/frame-control.md | 6 +- docs/privacy.md | 2 +- ui/index.html | 271 ++++++++++++++++++++++++++++++++---------- 5 files changed, 227 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index fb40d32..1fbfcd5 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and This is a first public test, so reports are really useful, especially from Windows and Linux. The quickest way is **Report a problem** in the app (the -warning-sign button at the top, or **Help → Report a Problem…**). It adds +speech-bubble button at the top, or **Help → Report a Problem…**). It adds diagnostics with personal details removed, shows you exactly what's included, and sends it privately to the maintainer; nothing is published. Without the app, use the [feedback form](https://frame-control.pages.dev/feedback/). Please include: diff --git a/docs/devices.md b/docs/devices.md index 1ed3200..847d8b7 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -3,8 +3,9 @@ Frame Control can manage more than one Steam Frame, and each headset can be reached at more than one address: a LAN IP at home, another at the office, its mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices** -tab (key 5) lists them, and the connection pill in the header shows what the -app is doing to reach the one in use, step by step, as it happens. +tab (key 5) lists them. The connection pill in the header says whether the one in +use is connected and how (Tailscale, or the network's name); click it to add, +edit or reorder that headset's addresses, or to see each step of connecting. The code is in three modules, all stdlib-only Python on your computer: @@ -57,7 +58,7 @@ and ranks them: 5. addresses that only ever worked on other networks; 6. Tailscale addresses while Tailscale is off. -Your order on the Devices tab breaks ties. The best-ranked address that answers +Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers wins; one that answers first waits up to 0.35 s for a better-ranked one that is still trying. If SSH to the winner fails in a way another address could fix (a different device answered there, or the link dropped), the next one that @@ -72,6 +73,12 @@ status --json`, including the Mac app's own CLI) with buttons to add their MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit 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. + ## Networks A network is told apart by its default gateway: the router's IP address plus its @@ -122,7 +129,8 @@ file per headset instead, so saving or forgetting one headset's key never touche 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 +headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the +SSH alias, user and port) lets the next connection save the new one. ## One server at a time diff --git a/docs/frame-control.md b/docs/frame-control.md index 3ec3b4c..eedc055 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -22,9 +22,9 @@ The window has five tabs: **Home** (headset view, status, screenshots), the catalogue, display settings, reports), **Tools** (sending files and text, Flatpaks, remote and power) and **Devices** (your headsets and their addresses). Keys 1–5 switch between them. Files can be dropped anywhere in the window. A -connection pill in the header always shows which headset, which network this -computer is on, the address in use or being tried, and each step of connecting -as it happens; click it for the whole timeline. When the Frame can't be +connection pill in the header always shows which headset and whether it's +connected, and how (Tailscale, or the network's name). Click it to add, edit or +reorder the headset's addresses, or to see each step of connecting. When the Frame can't be reached, a banner says why in plain words, what was tried, and counts down to the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar counts them while they run. diff --git a/docs/privacy.md b/docs/privacy.md index db7004f..51b7d9a 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -97,7 +97,7 @@ The same error is sent at most once every 10 minutes. ## Report a problem -**Report a problem** is the warning-sign button in the header, also in the +**Report a problem** is the speech-bubble button in the header, also in the Privacy panel and under **Help → Report a Problem…**. It sends the report privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and diff --git a/ui/index.html b/ui/index.html index 09870da..1e15f1b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -71,7 +71,8 @@ @media (max-width: 1250px) { .brand b { display: none; } } @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } @media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } - .brand, #refreshAll { display: none; } .pill .pt span { display: none; } } + .brand, #refreshAll { display: none; } + .pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -103,7 +104,27 @@ @keyframes pulse { 50% { opacity: .35; } } .pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; } .pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .conn-now { display: flex; gap: 10px; align-items: baseline; } + .conn-now .dot { position: relative; top: -1px; } + .conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; } + .conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px; + background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; } + .conn-offer .grow { flex: 1 1 260px; } + .h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; } + #connAddrs .item { gap: 6px; } + #connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; } + .conn-add { grid-template-columns: 2fr 1.2fr auto; } + .conn-edit { grid-template-columns: 2fr 1.2fr auto auto; } + .addr-add button.small, .dev-name button.small { height: 40px; } + .dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; } + .adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; } + .adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; } + .conn-more { margin-top: 16px; } + .conn-more summary { margin-top: 0; } + /* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */ + #connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px; + background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); } .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; } .dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } @@ -144,8 +165,8 @@ .addr .t { overflow-wrap: anywhere; } .addr .s { white-space: normal; } .addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; } - .addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; } - @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } } + .addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; } + @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } } .found { margin-top: 10px; } /* ---- drop anywhere ---- */ @@ -460,7 +481,8 @@ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } - .brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */ + .brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */ + .pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); } header .chip { height: 30px; padding: 0 10px; } nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0; height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); @@ -672,8 +694,8 @@ - +
Connection steps +
    +

    This computer

    +
    +
    - - - -
    + + + +
    @@ -4233,28 +4262,36 @@ function netLabel(s) { } function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; } -// The pill in the header: always there, and says what's happening right now. +// How the headset is reached, in a word: Tailscale, the network's own name, or this network. +function routeLabel(s) { + if (s.via && s.via.kind === "tailscale") return "Tailscale"; + const named = s.network && dv.data && dv.data.networks.find(x => x.id === s.network.id && x.name); + return named ? named.name : "this network"; +} +// The pill in the header: always there, and says plainly what's happening. Addresses are in its +// dialog, where there's room for them; the tooltip has the detail. function renderPill() { const s = link.s; if (!link.live || !s) return false; const dev = s.device ? s.device.name : "Frame"; - let dot = "wait", line; + let dot = "wait", word, via, detail; if (s.phase === "connected") { - dot = "on"; - line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : ""); + dot = "on"; word = "Connected"; via = routeLabel(s); + detail = `connected via ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""); } else if (s.phase === "failed") { - dot = "off"; + dot = "off"; word = "Offline"; const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null; - const st = (s.stages || []).find(x => x.id === (s.error || {}).stage); - line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : ""); + via = left != null ? `retrying in ${left} s` : ""; + detail = s.error ? s.error.message : "not connected"; } else { + word = "Connecting…"; const st = activeStage(s); - const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving"); - line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…") - : "Connecting…"; + via = st ? st.label.toLowerCase() : ""; + detail = via || "connecting"; } - $("conn").innerHTML = `${esc(dev)}${esc(line)}`; - $("conn").title = `${dev}: ${line}. Click for details.`; + $("conn").innerHTML = `${esc(dev)}${esc(word)}` + + `${via ? ` · ${esc(via)}` : ""}`; + $("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`; return true; } window.connPill = renderPill; @@ -4278,17 +4315,27 @@ function renderBanner() { } 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 }; function renderConnDlg() { const s = link.s; if (!s || !$("connDlg").open) return; - $("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection"; - $("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}` - : s.phase === "failed" ? (s.error ? s.error.message : "Not connected") + $("connTitle").textContent = s.device ? s.device.name : "Connection"; + const connected = s.phase === "connected", failed = s.phase === "failed"; + $("connDot").className = "dot " + (connected ? "on" : failed ? "off" : "wait"); + $("connState").textContent = connected ? `Connected via ${routeLabel(s)}` : failed ? "Offline" : "Connecting…"; + $("connWhy").textContent = connected ? viaText(s.via) + (s.via && s.via.rtt_ms != null ? ` · answered in ${s.via.rtt_ms} ms` : "") + : failed ? (s.error ? s.error.message : "Not connected") : `${s.reason || "Connecting"}…`; + // Folded while connected; opened when something's wrong. Left as the user set it otherwise. + if (s.phase !== cd.phase) { $("connMore").open = !connected; cd.phase = s.phase; } + $("connRetryBtn").hidden = !failed; + $("connRetryBtn").classList.toggle("action", failed); + $("connSetup").hidden = connected; const n = s.network || {}, ts = n.tailscale || {}; - const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")], - ["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"], - ["This computer", n.local_ip || "—"], + const facts = [["Network", n.ssid || n.name || "—"], + ["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"], ["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]]; $("connNet").innerHTML = facts.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(""); $("connStages").innerHTML = (s.stages || []).map(st => `
  1. ${STAGE_ICON[st.state] || ""} @@ -4297,14 +4344,108 @@ function renderConnDlg() { ${st.detail ? `${esc(st.detail)}` : ""}
  2. `).join("") + (s.phase === "connected" ? `
  3. ✓Connected ${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}
  4. ` : ""); - $("connProbes").innerHTML = (s.probes || []).map(p => `${esc(p.host)}${p.label ? `
    ${esc(p.label)}
    ` : ""} - ${esc(KIND_TAG[p.kind] || p.kind)}${esc(p.why || "")} - ${esc(p.detail)}${p.ip && p.ip !== p.host ? ` ${esc(p.ip)}` : ""}`).join("") - || `No addresses tried yet.`; + renderConnAddrs(); const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null; $("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : ""; } +const PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d{1,3}\.\d{1,3}(\.\d{1,3})?$/; +const sameSubnet = (a, b) => !!a && !!b && a.split(".").slice(0, 3).join(".") === b.split(".").slice(0, 3).join("."); +// The Frame's own LAN address, when this computer is on the same network and it isn't saved yet: +// 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.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 || "" }; +} +function activeDevice() { const s = link.s; return s && s.device ? dv.list.find(x => x.id === s.device.id) : null; } +function renderConnAddrs() { + const s = link.s, d = activeDevice(), list = $("connAddrs"); + const offer = s && lanOffer(s, d); + $("connOffer").hidden = !offer; + if (offer) { + const key = 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.
    + `; + $("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip, + kind: "lan", label: offer.label }, e.currentTarget) + .then(res => res && testQuietly(d.id)); + } + } else $("connOffer").dataset.key = ""; + $("connAdd").hidden = !d || !!d.transient; + if (!d) { list.innerHTML = `
    Loading…
    `; cd.sig = ""; return; } + if (d.transient) { + list.innerHTML = `
    This headset isn't set up in Frame Control yet, so ssh's own settings decide where it goes. + Set Up Connection adds its addresses here.
    `; + cd.sig = ""; return; + } + const probes = new Map((s.probes || []).map(p => [p.host, p])); + 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; + cd.sig = sig; + const refocus = cd.refocus; // after an edit, back to that row's Edit button + cd.refocus = null; + list.innerHTML = d.addresses.map((a, i) => { + if (cd.editing === a.host) return `
    + + +
    `; + const p = probes.get(a.host), t = tested.get(a.host); + const result = a.host === inUse ? `In use${p && p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}` + : t ? `${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}` + : p ? `${esc(p.detail)}` + : a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network"; + return `
    +
    ${esc(a.host)} ${esc(KIND_TAG[a.kind] || a.kind)}${a.label ? ` ${esc(a.label)}` : ""}
    +
    ${result}
    + + +
    `; + }).join("") || `
    No addresses yet. Add the Frame's IP address below.
    `; + if (refocus) list.querySelector(`[data-host="${CSS.escape(refocus)}"] [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-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); + }; + }); + if ($("ceSave")) { + const host = cd.editing, a = d.addresses.find(x => x.host === host); + const save = e => { + e.preventDefault(); + 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)); + }; + $("ceSave").onclick = save; + [$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } }); + $("ceCancel").onclick = () => { cd.editing = null; cd.sig = ""; cd.refocus = host; renderConnAddrs(); }; + } +} +$("connAdd").onsubmit = async e => { + e.preventDefault(); + const d = activeDevice(), host = $("caHost").value.trim(); + if (!d) return; + if (!host) return $("caHost").focus(); + const res = await devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("caLabel").value }); + if (res) { $("caHost").value = ""; $("caLabel").value = ""; testQuietly(d.id); } +}; +// Try the addresses (without reconnecting), so a new one shows whether it answers. +function testQuietly(id) { api("/api/devices", { action: "test", id }).catch(() => {}); } + function onConnection(s) { const prev = link.s; link.s = s; link.live = !s.local; link.lastData = Date.now(); @@ -4401,7 +4542,9 @@ setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { rend $("conn").onclick = () => { if (!link.live) { if (online === false) refresh(); return; } + cd.editing = null; cd.sig = ""; cd.phase = null; $("connDlg").showModal(); renderConnDlg(); + if (!dv.data) loadDevices(); }; $("offDetails").onclick = () => $("conn").click(); $("connClose").onclick = () => $("connDlg").close(); @@ -4460,7 +4603,7 @@ async function devAction(label, body, btn) { function deviceStatus(d) { const s = link.s; if (d.active && s) { - return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`] + return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s)}`] : s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"]; } const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0)); @@ -4474,7 +4617,7 @@ function renderDevices() { const [dot, text] = deviceStatus(d); return `
    ${esc(d.name)} ${d.active ? 'In use' : ""}
    -
    ssh ${esc(d.alias)} · ${esc(text)}
    +
    ${esc(text)}
    ${d.active ? "" : ``}`; }).join("") || `
    No headsets yet. Add one to get started.
    `; $("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => { @@ -4489,6 +4632,7 @@ function renderDevices() { $("netName").disabled = $("netSave").disabled = !(n && n.id); } renderDetail(); + renderConnDlg(); // the pill's dialog lists the same addresses } $("netSave").onclick = e => { const n = dv.data && dv.data.network; @@ -4526,19 +4670,13 @@ function renderDetail() { `).join(""); el.innerHTML = `

    ${esc(d.name)}

    ${d.active ? 'In use' : ""} ${d.active ? "" : ``}
    - + - - - -
    -
    - User and port are written to its block in ~/.ssh/config too.
    -

    Addresses tried all at once; this order breaks ties

    + +

    Addresses tried all at once; the first in the list wins a tie

    ${rows || '
    No addresses yet: add one, or find it below.
    '}
    - ${kindSel("adKind", "")}
    @@ -4546,22 +4684,33 @@ function renderDetail() { - -
    +
    -
    Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused." - : "not saved yet; the next connection saves it."} Kind "auto" is picked from the address: - .local names are mDNS, 100.x and .ts.net are Tailscale.
    `; - $("adKind").insertAdjacentHTML("afterbegin", ``); - $("adKind").value = ""; +
    Advanced +
    + + + +
    +
    + In Terminal it's ssh ${esc(d.alias)}. User and port are written to its block in ~/.ssh/config too.
    +
    + Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused." + : "not saved yet; the next connection saves it."}
    +
    An address's kind is worked out from it: .local names are mDNS, 100.x and + .ts.net are Tailscale, and other IPs are this network. Edit an address to set it yourself.
    +
    `; + el.querySelector(".adv").ontoggle = e => { dv.adv = e.target.open; }; if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id); - $("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value, - user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget); + const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value, + user: $("dvUser").value, port: $("dvPort").value }, btn); + $("dvSave").onclick = e => saveDev(e.currentTarget); + $("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); }; $("dvAdd").onsubmit = e => { e.preventDefault(); const host = $("adHost").value.trim(); if (!host) return $("adHost").focus(); - devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value }); + devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("adLabel").value }); }; el.querySelectorAll("[data-host]").forEach(row => { const host = row.dataset.host;