@@ -27,8 +27,10 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
between them, so neither covers anything. Phones scroll the window (see the phone rules below). */
body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden;
padding-bottom: calc(42px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
@@ -38,13 +40,14 @@
::-webkit-scrollbar-track { background: transparent; }
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; ga p: 30 px;
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-to p: 12 px; }
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0;
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; }
.brand svg { width: 30px; height: 30px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
nav { display: flex; gap: 4px; height: 100%; }
nav { display: flex; gap: 4px; height: 100%; flex: none; }
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
@@ -58,10 +61,18 @@
.dot.off { background: var(--bad); }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; 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: 820px) { .brand b { display: none; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
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: 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 > 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; }
@@ -87,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: 4 20px; min-width: 190 px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 3 20px; min-width: 124 px; 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: hidd en; text-overflow: ellipsis; white-space: nowrap; }
/* "Connected" is never cut short: wh en 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); }
@@ -134,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 ---- */
@@ -149,8 +182,10 @@
/* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700 ; letter-spacing: 2px; text-transform: uppercase; color: var(--text) ; }
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
.shelf-head { display: flex; align-items: baseline ; f lex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px ; }
.panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
@@ -179,6 +214,11 @@
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
/* 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-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; }
/* ---- headset view ---- */
@@ -223,15 +263,21 @@
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- device ---- */
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
.battery svg { width: 74px; height: 38px; flex: none; }
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums ; }
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top : 6 px; }
.battery .state.charging { co lor : var(--green-hi); } .battery .state.full { color: var(--green) ; }
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted) ; }
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
/* ---- the headset's details: a menu under the battery chip ---- */
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
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-bot tom : 10 px; }
.pop .shelf-head h2 { min-width: 0; overf low : hidden; text-overflow: ellipsis ; }
.pop .stats { margin-top: 12px ; }
.pop .chips { margin-top: 1 2px; }
.batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; }
.batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); }
.batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); }
.batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
button.chip { height: 32px; padding: 0 12px; font-size: 13px; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
@@ -322,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; }
@@ -350,7 +396,7 @@
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm select:focus { border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
@@ -397,7 +443,6 @@
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm .row { margin-top: 8px; }
@@ -406,7 +451,7 @@
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
.disp select:focus { border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; }
@@ -433,11 +478,17 @@
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
body { height: auto; display: block; overflow: visible; }
#scroll { overflow: visible; }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
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; } /* 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);
@@ -623,7 +674,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; } }
< / style >
< / head >
@@ -648,15 +699,29 @@
< div class = "spacer" > < / div >
< 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 >
< span class = "chip" id = "battChip" title = "Battery" > —< / span >
< 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 18H2 z" / > < path d = "M12 10v5M12 18v.5 " / > < / svg >
< 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 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 4 z" / > < 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 >
< / button >
< / header >
< div class = "pop" id = "devPop" popover role = "dialog" aria-labelledby = "devPopTitle" >
< div class = "shelf-head" > < h2 id = "devPopTitle" > Headset< / h2 > < span class = "count" id = "devName" > < / span > < span class = "spacer" > < / span > < span class = "sub" id = "updated" > < / span > < / div >
< div class = "batt" > < div class = "state draining" id = "battState" > < / div > < div class = "detail" id = "battDetail" > < / div > < / div >
< div class = "stats" >
< div class = "stat" > < div class = "k" > Storage free< / div > < div class = "v" id = "sDisk" > —< / div > < div class = "meter" > < i id = "mDisk" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Memory used< / div > < div class = "v" id = "sMem" > —< / div > < div class = "meter" > < i id = "mMem" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Temperature< / div > < div class = "v" id = "sTemp" > —< / div > < div class = "d" id = "dTemp" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" aria-label = "Running on the headset" > < / div >
< / div >
< div id = "scroll" >
< main >
< div class = "notice" id = "updateBar" hidden >
< div class = "grow" id = "updateText" > < / div >
@@ -747,29 +812,7 @@
< / section >
< section class = "panel" id = "device" >
< div class = "shelf-head" > < h2 > Device < / h2 > < span class = "count" id = "devName" > < / span > < span class = "spacer" > < / span > < span class = "sub" id = "updated" > < / span > < / div >
< div class = "battery" >
< svg viewBox = "0 0 74 38" aria-hidden = "true" >
< rect x = "1.5" y = "1.5" width = "64" height = "35" rx = "5" fill = "none" stroke = "#8f98a0" stroke-width = "3" / >
< rect x = "67" y = "12" width = "5.5" height = "14" rx = "2" fill = "#8f98a0" / >
< rect id = "battFill" x = "6" y = "6" width = "0" height = "26" rx = "2.5" fill = "#59bf40" / >
< path id = "battBolt" d = "M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill = "#fff" stroke = "#1e2329" stroke-width = "1.6" stroke-linejoin = "round" visibility = "hidden" / >
< / svg >
< div style = "min-width:0" >
< div class = "pct" id = "battPct" > —< / div >
< div class = "state draining" id = "battState" > < / div >
< div class = "detail" id = "battDetail" > < / div >
< / div >
< / div >
< div class = "stats" >
< div class = "stat" > < div class = "k" > Storage free< / div > < div class = "v" id = "sDisk" > —< / div > < div class = "meter" > < i id = "mDisk" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Memory used< / div > < div class = "v" id = "sMem" > —< / div > < div class = "meter" > < i id = "mMem" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Temperature< / div > < div class = "v" id = "sTemp" > —< / div > < div class = "d" id = "dTemp" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" > < / div >
< div class = "shelf-head" > < h2 > Volume < / h2 > < / div >
< div class = "vol" >
< button class = "small" id = "muteBtn" aria-label = "Mute" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "currentColor" aria-hidden = "true" > < path d = "M4 9h4l5-4v14l-5-4H4z" / > < path id = "volWaves" d = "M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill = "none" stroke = "currentColor" stroke-width = "2" / > < / svg >
@@ -783,7 +826,7 @@
< section class = "panel" id = "vrTools" >
< div class = "shelf-head" > < h2 > VR comfort and performance< / h2 > < span class = "spacer" > < / span > < span class = "sub" id = "vrUpdated" > Waiting for the Frame< / span > < / div >
< div class = "stats" id = "vrMetrics" > < / div >
< div class = "hint" > Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. T emperature is the ho ttest readable sensor .< / div >
< div class = "hint" > Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and t emperature are in the ba ttery menu at the top .< / div >
< div class = "chips" >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
@@ -1087,22 +1130,23 @@
< section class = "panel" id = "panels" >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< button class = "small" id = "panelsRefresh" > Refresh< / button >
< button class = "small action" id = "panelsOpen" > Open in headset< / button > < / div >
< button class = "small" id = "panelsRefresh" > Refresh< / button > < / div >
< div class = "list" id = "panelList" > < div class = "sub" > Refresh to see open panels.< / div > < / div >
< div class = "row" style = "margin-top:12px" > < button class = "small action" id = "panelsOpen" > Open in headset< / button > < / div >
< div class = "hint" > Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
Saved spatial layouts are not available yet.< / div >
< / section >
< section class = "panel" id = "macview" hidden >
< div class = "shelf-head" > < h2 > Mac in the headset< / h2 > < span class = "count" id = "mvCount" > < / span > < span class = "spacer" > < / span >
< select id = "mvQuality" title = "Picture quality and bandwidth" >
< option value = "sharp" > Sharp< / option > < option value = "balanced" selected > Balanced< / option >
< option value = "light" > Light< / option > < option value = "compatible" > Compatible (JPEG)< / option >
< / select >
< button class = "small" id = "mvRefresh" > Refresh< / button > < / div >
< label class = "mv-opt" > < input type = "checkbox" id = "mvSeparate" checked > Give each window its own display
< span class = "sub" > its menus and sheets come along, nothing covers it, and it goes back when you stop< / span > < / label >
< div class = "row" style = "margin:0 0 12px" > < label for = "mvQuality" > Picture quality< / label >
< select id = "mvQuality" title = "Sharper pictures use more of your network" >
< option value = "sharp" > Sharp< / option > < option value = "balanced" selected > Balanced< / option >
< option value = "light" > Light< / option > < option value = "compatible" > Compatible (JPEG)< / option >
< / select > < / div >
< div class = "mv-perm" id = "mvPerm" hidden > < / div >
< div class = "list" id = "mvDisplays" > < / div >
< div class = "shelf-head" style = "margin-top:16px" > < h2 > Windows< / h2 > < span class = "count" id = "mvWinCount" > < / span > < / div >
@@ -1154,6 +1198,7 @@
< / div >
< / div >
< / main >
< / div >
< div class = "dropzone" id = "dropzone" hidden > < div > < b > Drop to send to the Frame< / b >
< span class = "sub" > Files go to < code > ~/Downloads< / code > ; < code > .apk< / code > files install as Android apps;
a game's < code > .zip< / code > , folder or < code > .exe< / code > becomes a Steam library title.< / span > < / div > < / div >
@@ -1285,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 = "connStage s" > < / 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 = "li st" id = "connAddr s" > < / 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" > Clos e< / 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 > Don e< / button > < / div >
< / dialog >
< dialog id = "addDevDlg" class = "dlg" aria-labelledby = "addDevTitle" >
< form method = "dialog" id = "addDevForm" >
@@ -1322,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);
@@ -1344,6 +1396,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&",
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones.
function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); }
function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); }
function meter(el, pct, warnAt, badAt) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
@@ -1362,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);
@@ -1464,7 +1528,7 @@ function setOnline(ok, why) {
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
@@ -1512,7 +1576,7 @@ function renderPerformance(s) {
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"] ];
["GPU clock", p.gpuMHz, " MHz"]];
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `< div class = "stat" > < div class = "k" > ${label}< / div > < div class = "v" > ${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}< / div > < / div > `).join("");
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
@@ -1644,10 +1708,6 @@ function battery(b, power) {
const draining = !full & & !charging & & !idle & & (!power || b?.status === "Discharging" || b?.watts < 0 ) ;
const low = !charging & & !full & & pct != null & & pct < = 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null & & pct < = 30 ? "#d9a23a" : "#dcdedf";
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
$("battFill").setAttribute("fill", color);
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
let label, cls, detail = [];
if (!b) { label = "Battery unknown"; cls = "draining"; }
@@ -1663,7 +1723,7 @@ function battery(b, power) {
if (b?.timeToEmpty > 0 & & b.timeToEmpty < 86400 ) detail . push ( ` About $ { dur ( b . timeToEmpty ) } left ` ) ;
}
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
@@ -1672,13 +1732,25 @@ function battery(b, power) {
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
$("battChip").innerHTML = pct == null ? "—" :
`< svg width = "22" height = "12" viewBox = "0 0 22 12" aria-hidden = "true" > < rect x = ".75" y = ".75" width = "18" height = "10.5" rx = "2" fill = "none" stroke = "#8f98a0" stroke-width = "1.5" / > < rect x = "19.8" y = "3.5" width = "1.8" height = "5" rx = ".6" fill = "#8f98a0" / > < rect x = "2.5" y = "2.5" width = "${(14.5 * chipPct / 100).toFixed(1)}" height = "7" rx = "1" fill = "${color}" / > < / svg > ${bolt}< span > ${pct}%< / span > `;
$("battChip").title = label;
$("battChip").hidden = pct == null ;
$("battChip").title = `${ label}. Click for the headset's details.` ;
$("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 {} }
// The battery chip's menu opens under the chip, right-aligned with it.
$("devPop").addEventListener("beforetoggle", e => {
if (e.newState !== "open") return;
const r = $("battChip").getBoundingClientRect();
$("devPop").style.top = `${Math.round(r.bottom + 8)}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 = $("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;
@@ -2695,7 +2767,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
if (res & & res.app) await offerTest(res.app);
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
@@ -2929,7 +3001,7 @@ function renderChips() {
}
function filterGames(keepScroll) {
const y = window .scrollY ;
const y = scroller() .scrollTop ;
if (gm.src === "owned") {
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
@@ -2945,7 +3017,7 @@ function filterGames(keepScroll) {
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
if (!gm.results.length) $("gmGrid").innerHTML = `< div class = "sub" > ${gm.src === "store" & & !gm.storeQ
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}< / div > `;
if (keepScroll) window. scrollTo(0, y);
if (keepScroll) scrollToY (y);
}
function price(p) {
@@ -3300,7 +3372,7 @@ document.body.addEventListener("click", async e => {
if (action === "probe") { loadCatalog(); loadReports(); }
}
loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
});
loadCatalog();
@@ -3695,7 +3767,7 @@ function showPage() {
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) & & id & & $(id);
if (section) section.scrollIntoView(); else window. scrollTo(0, 0);
if (section) section.scrollIntoView(); else scrollToY (0);
if (page === "tools") { loadMacView(); loadPanels(); }
}
window.addEventListener("hashchange", showPage);
@@ -4206,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;
@@ -4251,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 >
@@ -4270,22 +4364,137 @@ 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;
if (link.device !== null & & devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
@@ -4315,8 +4524,10 @@ function onConnection(s) {
if ($("wiDlg").open & & !wi.job & & !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "< option > Loading…< / option > "; $("dispSel").disabled = true; $("dispCtl").hidden = true;
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
$("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");
@@ -4373,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();
@@ -4432,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));
@@ -4446,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 => {
@@ -4461,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;
@@ -4498,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 = "dvName Form" >
< 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" >
@@ -4518,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;