|
|
|
@@ -68,10 +68,11 @@
|
|
|
|
|
the macOS window buttons the app adds on the left. */
|
|
|
|
|
header > button:not(.pill), header > .chip { flex: none; }
|
|
|
|
|
header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
|
|
|
|
|
@media (max-width: 1250px) { .brand b { display: none; } }
|
|
|
|
|
@media (max-width: 1340px) { .brand b { display: none; } header { gap: 22px; } }
|
|
|
|
|
@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; }
|
|
|
|
@@ -97,13 +98,35 @@
|
|
|
|
|
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
|
|
|
|
|
|
|
|
|
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
|
|
|
|
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 0; flex: 0 1 auto; background: var(--btn); }
|
|
|
|
|
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 124px; flex: 0 1 auto; background: var(--btn); }
|
|
|
|
|
.pill .dot { flex: none; }
|
|
|
|
|
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
|
|
|
|
@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; }
|
|
|
|
|
/* "Connected" is never cut short: when the route doesn't fit beside it, the route wraps out of sight. */
|
|
|
|
|
.pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; display: flex; flex-wrap: wrap; height: 1.2em; overflow: hidden; white-space: nowrap; }
|
|
|
|
|
.pill .via { white-space: pre; }
|
|
|
|
|
.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 +167,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 ---- */
|
|
|
|
@@ -345,7 +368,7 @@
|
|
|
|
|
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
|
|
|
|
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
|
|
|
|
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
|
|
|
|
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
|
|
|
|
.toast { position: fixed; inset: auto; margin: 0; border: 0; overflow: visible; width: auto; height: auto; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
|
|
|
|
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
|
|
|
|
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
|
|
|
|
.toast.show { opacity: 1; transform: none; }
|
|
|
|
@@ -462,7 +485,10 @@
|
|
|
|
|
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); }
|
|
|
|
|
.pill { min-width: 104px; }
|
|
|
|
|
@media (max-width: 374px) { .brand { display: none; } } /* the smallest phones: the pill needs the room */
|
|
|
|
|
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);
|
|
|
|
@@ -674,8 +700,8 @@
|
|
|
|
|
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
|
|
|
|
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
|
|
|
|
<button class="chip" id="battChip" title="Battery and headset details" popovertarget="devPop" aria-haspopup="dialog" hidden>—</button>
|
|
|
|
|
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
|
|
|
|
<button id="reportBtn" data-report title="Report a problem or send feedback" aria-label="Report a problem or send feedback">
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/><path d="M8 9.5h8M8 12.5h5" stroke-linecap="round"/></svg>
|
|
|
|
|
</button>
|
|
|
|
|
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
|
|
|
@@ -1304,18 +1330,25 @@
|
|
|
|
|
</dialog>
|
|
|
|
|
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
|
|
|
|
<h2 id="connTitle">Connection</h2>
|
|
|
|
|
<div class="sub" id="connWhy"></div>
|
|
|
|
|
<h3>This computer</h3>
|
|
|
|
|
<dl class="facts" id="connNet"></dl>
|
|
|
|
|
<h3>Steps</h3>
|
|
|
|
|
<ol class="stages" id="connStages"></ol>
|
|
|
|
|
<h3>Addresses</h3>
|
|
|
|
|
<table class="probes"><tbody id="connProbes"></tbody></table>
|
|
|
|
|
<div class="conn-now"><span class="dot" id="connDot"></span><div class="grow"><b id="connState"></b>
|
|
|
|
|
<div class="sub" id="connWhy"></div></div></div>
|
|
|
|
|
<div class="conn-offer" id="connOffer" hidden></div>
|
|
|
|
|
<h3>Addresses <span class="h3-note">Frame Control tries them all at once; the first in the list wins a tie</span></h3>
|
|
|
|
|
<div class="list" id="connAddrs"></div>
|
|
|
|
|
<form class="addr-add conn-add" id="connAdd">
|
|
|
|
|
<input type="text" id="caHost" maxlength="253" placeholder="Add an IP address or host name" aria-label="New address">
|
|
|
|
|
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
|
|
|
|
|
<button class="small" type="submit">Add</button></form>
|
|
|
|
|
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
|
|
|
|
|
<ol class="stages" id="connStages"></ol>
|
|
|
|
|
<h3>This computer</h3>
|
|
|
|
|
<dl class="facts" id="connNet"></dl>
|
|
|
|
|
</details>
|
|
|
|
|
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
|
|
|
|
|
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
|
|
|
|
|
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
|
|
|
|
|
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
|
|
|
|
|
<button type="button" class="small" id="connClose">Close</button></div>
|
|
|
|
|
<button type="button" class="small desk-only" id="connDevices">All headsets…</button>
|
|
|
|
|
<button type="button" class="small desk-only" id="connSetup" hidden>Set Up Connection…</button>
|
|
|
|
|
<button type="button" class="small" id="connRetryBtn" hidden>Retry now</button>
|
|
|
|
|
<button type="button" class="small" id="connClose" autofocus>Done</button></div>
|
|
|
|
|
</dialog>
|
|
|
|
|
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
|
|
|
|
<form method="dialog" id="addDevForm">
|
|
|
|
@@ -1341,7 +1374,7 @@
|
|
|
|
|
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
|
|
|
|
|
</form>
|
|
|
|
|
</dialog>
|
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
|
<div class="toast" id="toast" popover="manual"></div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
@@ -1384,6 +1417,15 @@ let toastTimer;
|
|
|
|
|
function toast(msg, err = false) {
|
|
|
|
|
const el = $("toast");
|
|
|
|
|
el.textContent = msg.split("\n").slice(-1)[0] || msg;
|
|
|
|
|
// A popover, so it shows above an open dialog (a modal one covers everything else): opened
|
|
|
|
|
// again when a dialog has opened since, which puts it back on top. Opened hidden first, so
|
|
|
|
|
// it still fades in.
|
|
|
|
|
if (el.showPopover && (!el.matches(":popover-open") || document.querySelector("dialog:modal"))) {
|
|
|
|
|
el.className = "toast" + (err ? " err" : "");
|
|
|
|
|
try { el.hidePopover(); } catch {}
|
|
|
|
|
el.showPopover();
|
|
|
|
|
void el.offsetWidth; // the hidden style is in place before .show changes it
|
|
|
|
|
}
|
|
|
|
|
el.className = "toast show" + (err ? " err" : "");
|
|
|
|
|
clearTimeout(toastTimer);
|
|
|
|
|
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
|
|
|
|
@@ -4236,28 +4278,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 = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
|
|
|
|
|
$("conn").title = `${dev}: ${line}. Click for details.`;
|
|
|
|
|
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
|
|
|
|
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
|
|
|
|
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
window.connPill = renderPill;
|
|
|
|
@@ -4281,17 +4331,31 @@ 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, editDev: null, session: 0, 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; }
|
|
|
|
|
// Connected, it's Reconnect: the addresses are tried again in their current order, so an
|
|
|
|
|
// address just added or moved up gets its chance now rather than at the next connection.
|
|
|
|
|
$("connRetryBtn").hidden = !failed && !connected;
|
|
|
|
|
$("connRetryBtn").classList.toggle("action", failed);
|
|
|
|
|
$("connRetryBtn").textContent = failed ? "Retry now" : "Reconnect";
|
|
|
|
|
$("connRetryBtn").title = failed ? "" : "Try the addresses again, in order";
|
|
|
|
|
$("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]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
|
|
|
|
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
|
|
|
@@ -4300,20 +4364,134 @@ function renderConnDlg() {
|
|
|
|
|
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
|
|
|
|
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
|
|
|
|
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
|
|
|
|
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
|
|
|
|
|
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
|
|
|
|
|
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|
|
|
|
|
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
|
|
|
|
|
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) || 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 || "" };
|
|
|
|
|
}
|
|
|
|
|
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 = JSON.stringify([d.id, offer.ip, offer.label]);
|
|
|
|
|
if ($("connOffer").dataset.key !== key) {
|
|
|
|
|
$("connOffer").dataset.key = key;
|
|
|
|
|
$("connOffer").innerHTML = `<div class="grow">The Frame is at <b>${esc(offer.ip)}</b> on this network. Add it, and
|
|
|
|
|
Frame Control tries it first here, reaching the headset directly.</div>
|
|
|
|
|
<button class="small action" id="connOfferAdd">Add ${esc(offer.ip)}</button>`;
|
|
|
|
|
// 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, first: true }, e.currentTarget)
|
|
|
|
|
.then(res => res && testQuietly(d.id));
|
|
|
|
|
}
|
|
|
|
|
} else $("connOffer").dataset.key = "";
|
|
|
|
|
$("connAdd").hidden = !d || !!d.transient;
|
|
|
|
|
if (!d) { list.innerHTML = `<div class="sub">Loading…</div>`; cd.sig = ""; return; }
|
|
|
|
|
if (d.transient) {
|
|
|
|
|
list.innerHTML = `<div class="sub">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.</div>`;
|
|
|
|
|
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: 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;
|
|
|
|
|
// 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-mv='-1']", "[data-mv='1']", "[data-ed]", "[data-rm]"].find(q => document.activeElement.matches(q))] : null;
|
|
|
|
|
cd.refocus = null;
|
|
|
|
|
list.innerHTML = d.addresses.map((a, i) => {
|
|
|
|
|
if (cd.editing === a.host && cd.editDev === d.id) return `<div class="item addr"><div class="addr-edit conn-edit">
|
|
|
|
|
<input type="text" id="ceHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">
|
|
|
|
|
<input type="text" id="ceLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
|
|
|
|
|
<button class="small action" id="ceSave">Save</button><button class="small" id="ceCancel">Cancel</button></div></div>`;
|
|
|
|
|
const p = probes.get(a.host), t = tested.get(a.host);
|
|
|
|
|
const result = a.host === inUse ? `<span class="res-ok">In use${p && p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}</span>`
|
|
|
|
|
: t ? `<span class="res-${esc(t.ssh || t.state)}">${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}</span>`
|
|
|
|
|
: p ? `<span class="res-${esc(p.state)}">${esc(p.detail)}</span>`
|
|
|
|
|
: a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network";
|
|
|
|
|
return `<div class="item addr" data-host="${esc(a.host)}">
|
|
|
|
|
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
|
|
|
|
|
<div class="s">${result}</div></div>
|
|
|
|
|
<button class="small" data-mv="-1" title="Try earlier" aria-label="Move ${esc(a.host)} up"${i ? "" : " disabled"}>↑</button>
|
|
|
|
|
<button class="small" data-mv="1" title="Try later" aria-label="Move ${esc(a.host)} down"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
|
|
|
|
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`;
|
|
|
|
|
}).join("") || `<div class="sub">No addresses yet. Add the Frame's IP address below.</div>`;
|
|
|
|
|
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; cd.editDev = d.id; cd.session++; 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), session = cd.session;
|
|
|
|
|
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;
|
|
|
|
|
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) testQuietly(d.id);
|
|
|
|
|
// Refused: what was typed stays, to correct. Cancelled or another address opened for
|
|
|
|
|
// editing while this was on its way: leave that one alone.
|
|
|
|
|
if (!res || cd.session !== session) return;
|
|
|
|
|
cd.editing = null; cd.sig = ""; cd.refocus = newHost;
|
|
|
|
|
renderConnAddrs();
|
|
|
|
|
};
|
|
|
|
|
$("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.session++; 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();
|
|
|
|
|
link.offset = Date.now() / 1000 - s.now;
|
|
|
|
|
if (!link.live) return;
|
|
|
|
|
renderPill(); renderConnDlg();
|
|
|
|
|
const devId = s.device && s.device.id;
|
|
|
|
|
window.connDevice = devId || null;
|
|
|
|
|
window.connName = s.device ? s.device.name : null;
|
|
|
|
@@ -4348,6 +4526,8 @@ function onConnection(s) {
|
|
|
|
|
link.reload = true; // everything on the page was the other headset's
|
|
|
|
|
$("battChip").hidden = true; closeDevPop();
|
|
|
|
|
}
|
|
|
|
|
// After the other headset's status is cleared: the dialog's offer reads it.
|
|
|
|
|
renderPill(); renderConnDlg();
|
|
|
|
|
if (s.phase !== link.phase || devId !== link.device) {
|
|
|
|
|
if (s.phase === "connected") {
|
|
|
|
|
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
|
|
|
|
@@ -4404,7 +4584,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();
|
|
|
|
@@ -4463,7 +4645,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));
|
|
|
|
@@ -4477,7 +4659,7 @@ function renderDevices() {
|
|
|
|
|
const [dot, text] = deviceStatus(d);
|
|
|
|
|
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
|
|
|
|
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
|
|
|
|
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
|
|
|
|
<div class="s">${esc(text)}</div></div>
|
|
|
|
|
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
|
|
|
|
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
|
|
|
|
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
|
|
|
@@ -4492,6 +4674,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;
|
|
|
|
@@ -4529,19 +4712,13 @@ function renderDetail() {
|
|
|
|
|
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
|
|
|
|
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
|
|
|
|
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
|
|
|
|
|
<form class="dev-form" id="dvForm">
|
|
|
|
|
<form class="dev-name" id="dvNameForm">
|
|
|
|
|
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
|
|
|
|
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
|
|
|
|
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
|
|
|
|
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
|
|
|
|
</form>
|
|
|
|
|
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
|
|
|
|
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
|
|
|
|
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
|
|
|
|
|
<button class="small" type="submit">Rename</button></form>
|
|
|
|
|
<h3>Addresses <span class="h3-note">tried all at once; the first in the list wins a tie</span></h3>
|
|
|
|
|
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
|
|
|
|
<form class="addr-add" id="dvAdd">
|
|
|
|
|
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
|
|
|
|
${kindSel("adKind", "")}
|
|
|
|
|
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
|
|
|
|
<button class="small" type="submit">Add</button></form>
|
|
|
|
|
<div class="row" style="margin-top:14px">
|
|
|
|
@@ -4549,22 +4726,33 @@ function renderDetail() {
|
|
|
|
|
<button class="small" id="dvTs">Find on Tailscale</button>
|
|
|
|
|
<button class="small" id="dvMdns">Find on this network</button>
|
|
|
|
|
<span class="spacer"></span>
|
|
|
|
|
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
|
|
|
|
<button class="small danger" id="dvRemove">Remove…</button></div>
|
|
|
|
|
<button class="small danger" id="dvRemove">Remove headset…</button></div>
|
|
|
|
|
<div class="found" id="dvFound"></div>
|
|
|
|
|
<div class="hint">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:
|
|
|
|
|
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
|
|
|
|
|
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
|
|
|
|
|
$("adKind").value = "";
|
|
|
|
|
<details class="adv"${dv.adv ? " open" : ""}><summary>Advanced</summary>
|
|
|
|
|
<form class="dev-form" id="dvForm">
|
|
|
|
|
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
|
|
|
|
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
|
|
|
|
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
|
|
|
|
</form>
|
|
|
|
|
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
|
|
|
|
<span class="sub">In Terminal it's <code>ssh ${esc(d.alias)}</code>. User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
|
|
|
|
<div class="row" style="margin-top:14px"><button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
|
|
|
|
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
|
|
|
|
: "not saved yet; the next connection saves it."}</span></div>
|
|
|
|
|
<div class="hint">An address's kind is worked out from it: <code>.local</code> names are mDNS, <code>100.x</code> and
|
|
|
|
|
<code>.ts.net</code> are Tailscale, and other IPs are this network. Edit an address to set it yourself.</div>
|
|
|
|
|
</details>`;
|
|
|
|
|
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;
|
|
|
|
|