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;