@@ -68,10 +68,13 @@
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: 12 50px) { .brand b { display: none ; } }
@media (max-width: 150 0px) { header { gap: 22px ; } }
@media (max-width: 1360px) { .brand b { display: none; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 96 0px) { 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; } }
@media (max-width: 103 0px) { 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; } header .pill .pt > span { font-size: 13px; color: var(--bright); } header .pill { min-width: 118px; } }
@media (max-width: 820px) { nav a { padding: 0 6px; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
@@ -84,7 +87,7 @@
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 115 0px) { nav kbd { display: none; } }
@media (max-width: 120 0px) { nav kbd { display: none; } }
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -97,13 +100,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: 106px ; 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); }
@@ -144,8 +169,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 ---- */
@@ -213,6 +238,10 @@
.zoombar button:hover { background: rgba(255,255,255,.12); }
.zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; }
.viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; }
/* Nothing captured yet: a short strip rather than a big black box. */
.viewer:has(> #viewerEmpty:not([hidden])) { aspect-ratio: auto !important; height: 190px;
background: radial-gradient(500px 160px at 50% 0%, rgba(42,71,94,.55), transparent 70%), #0b1016; }
.viewer:has(> #viewerEmpty:not([hidden])) .empty svg { width: 44px; height: 44px; margin-bottom: 4px; }
.viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; }
.viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase;
background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; }
@@ -240,6 +269,26 @@
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- Home: what the headset's doing, and what you'd do next ---- */
.now-state { margin: 0 0 4px; color: var(--bright); font-size: 15px; }
.now-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; }
.now-actions button { justify-content: center; height: 38px; white-space: normal; line-height: 1.2; text-align: center; }
.now-actions button:first-child { grid-column: 1 / -1; }
@media (max-width: 1150px) { .now { order: -1; } } /* stacked: what's happening comes first */
#padIntro { margin: 0; }
.group-head { margin: 4px 0 -8px; }
a.button-link { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 3px; background: var(--btn);
color: var(--text); text-decoration: none; }
a.button-link:hover { background: var(--btn-hi); color: var(--bright); }
#settingsBtn.on { color: var(--blue); }
/* A panel that folds: its heading row is the summary. */
.fold > summary { list-style: none; cursor: pointer; margin: 0; color: inherit; font-size: inherit; }
.fold > summary::-webkit-details-marker { display: none; }
.fold > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); }
.fold[open] > summary h2::before { content: "▾"; }
.fold:not([open]) > summary { margin-bottom: 0; }
.fold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 4px; }
/* ---- 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);
@@ -345,7 +394,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 +511,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; } /* the tabs are at the bottom here; Refresh is automatic */
.pill .pt > b { display: block; } .pill .via { display: inline; } header .pill .pt > span { font-size: 11.5px; color: var(--muted); }
header .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 +726,11 @@
< 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 18H2 z" / > < 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 4 z" / > < path d = "M8 9.5h8M8 12.5h5" stroke-linecap = "round "/ > < / svg >
< / button >
< button id = "settingsBtn" title = "Settings: privacy, updates, artwork and the assistant" aria-label = "Settings" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" > < circle cx = "12" cy = "12" r = "3" / > < path d = "M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" / > < / 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 >
@@ -785,8 +840,9 @@
< div class = "hint" id = "viewHint" > < / div >
< / section >
< section class = "panel" id = "device" >
< div class = "shelf-head" > < h2 > Volume < / h2 > < / div >
< section class = "panel now " id = "device" >
< div class = "shelf-head" > < h2 > Right now < / h2 > < / div >
< p class = "now-state" id = "nowState" > Waiting for the Frame…< / p >
< 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 >
@@ -794,33 +850,29 @@
< input type = "range" id = "vol" min = "0" max = "100" step = "1" value = "0" aria-label = "Volume" >
< span class = "num" id = "volNum" > —< / span >
< / div >
< div class = "now-actions" >
< button class = "action" id = "castBtn" title = "The live headset view, full screen, for people in the room" > Cast to this screen< / button >
< button data-go = "media" > Play a video or photo< / button >
< button data-go = "macview" id = "nowMac" hidden > Show a Mac window< / button >
< button id = "nowSend" > Send files…< / button >
< button data-go = "pad" > Type on the Frame< / button >
< / div >
< p class = "hint" > Casting shows everything the wearer sees, including anything private.< / p >
< / section >
< / div >
< section class = "panel" id = "vrTool s">
< 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. Battery and temperature are in the battery 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 >
< section id = "shot s">
< div class = "shelf-head" > < h2 > Screenshots < / h2 > < span class = "count" id = "shotCount" > < / span > < span class = "spacer" > < / span >
< button class = "small " id = "shotsRefresh" > Refresh < / button >
< button class = "small" id = "shotsFolder" title = "Open the SteamFrame folder in your Pictures" > Show folder < / button >
< button class = "action small" id = "shotsSaveNew" disabled > Save new to this computer < / button >
< / div >
< div class = "hint" > The HUD is ours and uses the Frame’ s built-in tools. Place its panel with SteamVR’ s Float in World or controller docking controls. It updates every two seconds until closed. < / div >
< h3 > Playspace and seated comfort < / h3 >
< p class = "sub" id = "comfortNote" > Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’ s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.< / p >
< div class = "hint" > For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’ s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.< / div >
< details style = "margin-top:16px" > < summary > Optional VR software< / summary >
< p class = "sub" > None of Frame Control’ s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.< / p >
< button class = "small" id = "vrUtilitiesRefresh" > Check ownership and reports< / button >
< div class = "list" id = "vrUtilities" > < div class = "sub" > Load this optional list to check the Frame account.< / div > < / div >
< / details >
< div class = "shot-grid" id = "shotGrid" > < div class = "sub" > Loading… < / div > < / div >
< div class = "hint" > Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to < code > ~/Pictures/SteamFrame < / code > . < / div >
< / section >
< section class = "panel" id = "comfort" style = "margin:20px 0;padding:16px" >
< div class = "shelf-head" > < h2 > Family and comfort< / h2 > < span class = "spacer" > < / span >
< button class = "action small" id = "castBtn" > Cast headset view< / button >
< / div >
< p class = "hint" > Share this screen with people in the room. Casting shows everything the wearer sees, including private content.< / p >
< section class = "panel" id = "comfort" >
< div class = "shelf-head" > < h2 > Family and comfort< / h2 > < span class = "count" > Session timer and reminders < / span > < / div >
< form id = "comfortForm" >
< div class = "row" style = "flex-wrap:wrap;gap:12px" >
< label > Session limit (minutes) < input id = "sessionMinutes" type = "number" min = "1" max = "240" value = "30" required style = "width:75px" > < / label >
@@ -850,6 +902,9 @@
< div class = "shelf-head" > < h2 > Keyboard and trackpad< / h2 > < span class = "sub" id = "padState" > < / span > < span class = "spacer" > < / span >
< button class = "small" id = "padOn" > Turn on< / button >
< / div >
< p class = "sub" id = "padIntro" > Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the
desktop's Linux apps.< / p >
< div id = "padBody" hidden >
< div class = "pad-area" id = "padArea" tabindex = "0" role = "application" aria-label = "Trackpad for the Frame" >
< div class = "pad-hint" id = "padHint" > < / div >
< / div >
@@ -875,17 +930,29 @@
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. < a href = "#" data-about > Licences< / a > < / div >
< / div >
< / section >
< section id = "shot s" >
< div class = "shelf-head" > < h2 > Screenshots < / h2 > < span class = "count" id = "shotCount" > < / span > < span class = "spacer" > < / span >
< button class = "small" id = "shotsRefresh" > Refresh < / button >
< button class = "small " id = "shotsFolder" title = "Open the SteamFrame folder in your Pictures" > Show folder < / button >
< button class = "action small" id = "shotsSaveNew" disabled > Save new to this computer < / button >
< details class = "panel fold" id = "vrTool s" >
< summary class = "shelf-head" > < h2 > VR performance < / h2 > < span class = "count" > Frame timing, the HUD, playspace and optional software < / span >
< span class = "spacer" > < / span > < span class = "sub" id = "vrUpdated" > Waiting for the Frame < / span > < / summary >
< 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. Battery and temperature are in the battery 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 >
< / div >
< div class = "shot-grid" id = "shotGrid" > < div class = "sub" > Loading… < / div > < / div >
< div class = "hint" > Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to < code > ~/Pictures/SteamFrame < / code > . < / div >
< / section >
< div class = "hint" > The HUD is ours and uses the Frame’ s built-in tools. Place its panel with SteamVR’ s Float in World or controller docking controls. It updates every two seconds until closed. < / div >
< h3 > Playspace and seated comfort < / h3 >
< p class = "sub" id = "comfortNote" > Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’ s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode. < / p >
< div class = "hint" > For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’ s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.< / div >
< details style = "margin-top:16px" > < summary > Optional VR software< / summary >
< p class = "sub" > None of Frame Control’ s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.< / p >
< button class = "small" id = "vrUtilitiesRefresh" > Check ownership and reports< / button >
< div class = "list" id = "vrUtilities" > < div class = "sub" > Load this optional list to check the Frame account.< / div > < / div >
< / details >
< / details >
< / div >
< div class = "page" data-page = "games" >
@@ -1004,7 +1071,65 @@
< / div >
< div class = "page" data-page = "tools" >
< section class = "panel" > < h2 > Assistant and AI agents < / h2 > < p > Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in. < / p > < a href = "/assistant" > Open assistant < / a > < / section >
< div class = "shelf-head group-head" > < h2 > In the headset < / h2 > < span class = "count" > Show your computer, films and photos, and panels < / span > < / div >
< div class = "grid-3" >
< 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 >
< 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 >
< div class = "list mv-windows" id = "mvWindows" > < div class = "sub" > Loading…< / div > < / div >
< div class = "row" style = "margin-top:12px" >
< button class = "small" data-mv = "show" data-src = "test" data-title = "Test pattern" > Test pattern< / button >
< button class = "small danger" data-mv = "stopall" > Stop all< / button >
< / div >
< div class = "hint" > Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.< / div >
< / section >
< section class = "panel" id = "media" >
< div class = "shelf-head" > < h2 > Media player< / h2 > < span class = "count" > Preview< / span > < / div >
< p class = "sub" > Watch your own films and stereo photos on a big screen in the headset.< / p >
< div class = "row" > < button class = "small" id = "mediaChoose" > Send media…< / button >
< button class = "small" id = "mediaRefresh" > Refresh< / button > < / div >
< input type = "file" id = "mediaInput" accept = ".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden >
< div class = "row" style = "margin-top:12px" > < label for = "mediaFile" > On the Frame< / label >
< select id = "mediaFile" > < option value = "" > Refresh to load your media< / option > < / select > < / div >
< div class = "row" style = "margin-top:8px" > < label for = "mediaLayout" > Layout< / label >
< select id = "mediaLayout" >
< option value = "auto" > Detect from name or metadata< / option > < option value = "mono" > 2D< / option >
< option value = "sbs" > Half SBS · left / right< / option > < option value = "ou" > Half OU · top / bottom< / option >
< option value = "full-sbs" > Full SBS · left / right< / option > < option value = "full-ou" > Full OU · top / bottom< / option >
< / select > < / div >
< div class = "row" style = "margin-top:8px" > < label > < input type = "checkbox" id = "mediaTheatre" checked >
Theatre · bigger screen, dark surround< / label > < / div >
< div class = "row" style = "margin-top:12px" > < button class = "action small" id = "mediaPlay" > Play< / button >
< button class = "small" id = "mediaStop" > Stop< / button > < / div >
< p class = "sub" id = "mediaState" role = "status" aria-live = "polite" > Choose a file to begin.< / p >
< div class = "hint" > H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
Stop works from here; playback also ends after four hours.< / div >
< / section >
< section class = "panel" id = "panels" >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< 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 >
< / div >
< div class = "shelf-head group-head" > < h2 > Send and manage< / h2 > < span class = "count" > Files, clipboard, Linux apps, remote access and power< / span > < / div >
< div class = "grid-3" >
< section class = "panel" id = "transfer" >
< div class = "shelf-head" > < h2 > Send to Frame< / h2 > < / div >
@@ -1031,31 +1156,6 @@
< div class = "hint" > Clipboard needs the desktop panel open in the headset.< / div >
< / section >
< section class = "panel" id = "media" >
< div class = "shelf-head" > < h2 > Media in the headset< / h2 > < span class = "count" > Preview< / span > < / div >
< p class = "sub" > Play your movies and stereo photos with Frame Control's own player.< / p >
< div class = "row" > < button class = "small" id = "mediaChoose" > Send media…< / button >
< button class = "small" id = "mediaRefresh" > Refresh< / button > < / div >
< input type = "file" id = "mediaInput" accept = ".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden >
< div class = "row" style = "margin-top:12px" > < label for = "mediaFile" > On the Frame< / label >
< select id = "mediaFile" > < option value = "" > Refresh to load your media< / option > < / select > < / div >
< div class = "row" style = "margin-top:8px" > < label for = "mediaLayout" > Layout< / label >
< select id = "mediaLayout" >
< option value = "auto" > Detect from name or metadata< / option > < option value = "mono" > 2D< / option >
< option value = "sbs" > Half SBS · left / right< / option > < option value = "ou" > Half OU · top / bottom< / option >
< option value = "full-sbs" > Full SBS · left / right< / option > < option value = "full-ou" > Full OU · top / bottom< / option >
< / select > < / div >
< div class = "row" style = "margin-top:8px" > < label > < input type = "checkbox" id = "mediaTheatre" checked >
Theatre · bigger screen, dark surround< / label > < / div >
< div class = "row" style = "margin-top:12px" > < button class = "action small" id = "mediaPlay" > Play< / button >
< button class = "small" id = "mediaStop" > Stop< / button > < / div >
< p class = "sub" id = "mediaState" role = "status" aria-live = "polite" > Choose a file to begin.< / p >
< div class = "hint" > H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
Stop works from here; playback also ends after four hours.< / div >
< / section >
< section class = "panel" id = "apps" >
< div class = "shelf-head" > < h2 > Linux apps< / h2 > < span class = "count" > Flatpak< / span > < / div >
< div class = "list" id = "flatpaks" > < div class = "sub" > Loading…< / div > < / div >
@@ -1066,20 +1166,6 @@
< div class = "row" style = "margin-top:8px" id = "fpQuick" > < / div >
< / section >
< section class = "panel" id = "artworkSettings" >
< div class = "shelf-head" > < h2 > Library artwork settings< / h2 > < / div >
< p class = "sub" > SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.< / p >
< label for = "steamGridKey" > SteamGridDB API key< / label >
< input type = "password" id = "steamGridKey" autocomplete = "off" spellcheck = "false" placeholder = "Optional API key" >
< div class = "row" style = "margin-top:8px" >
< button id = "saveSteamGridKey" class = "action small" > Save key< / button >
< button id = "clearSteamGridKey" class = "small" > Remove saved key< / button >
< a href = "https://www.steamgriddb.com/profile/preferences/api" target = "_blank" rel = "noreferrer" > Get a free key< / a >
< / div >
< div id = "steamGridStatus" class = "hint" role = "status" > < / div >
< button id = "refreshAndroidArt" class = "small" style = "margin-top:12px" > Refresh artwork for installed apps and titles< / button >
< / section >
< section class = "panel" id = "power" >
< div class = "shelf-head" > < h2 > Remote & power< / h2 > < / div >
< div class = "actions" >
@@ -1094,45 +1180,17 @@
< div class = "hint desk-only" > Sleep, Restart and Shut down open a terminal window for the Developer Mode password.< / div >
< div class = "hint mobile-only" > Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.< / div >
< div class = "row mobile-only" style = "margin-top:14px" > < button class = "small" id = "changeHeadset" > Change headset…< / button > < / div >
< div class = "row" style = "margin-top:14px" > < button class = "small" data-about > About and licences< / button > < / div >
< div class = "links desk-only" >
< div > < a href = "https://store.steampowered.com/remoteplay" target = "_blank" > Steam Link< / a > : Valve's remote view of the headset< / div >
< div > < a href = "https://streamframe.app/" target = "_blank" > Stream Frame< / a > : third-party recorder (macOS 14+)< / div >
< div > < a href = "https://framedropvr.com" target = "_blank" > FrameDrop< / a > : sideloader (Windows only for now)< / div >
< / div >
< / section >
< section class = "panel" id = "panels" >
< div class = "shelf-head" > < h2 > Panel switcher < / h2 > < span class = "spacer" > < / span >
< button class = "small" id = "panelsRefresh" > Refr esh < / 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 >
< 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 >
< div class = "list mv-windows" id = "mvWindows" > < div class = "sub" > Loading…< / div > < / div >
< div class = "row" style = "margin-top:12px" >
< button class = "small" data-mv = "show" data-src = "test" data-title = "Test pattern" > Test pattern< / button >
< button class = "small danger" data-mv = "stopall" > Stop all< / button >
< / div >
< div class = "hint" > Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.< / div >
< / section >
< / div >
< / div >
< div class = "page" data-page = "settings" >
< div class = "shelf-head group-head" > < h2 > Settings < / h2 > < span class = "s pacer" > < / span > < button class = "small" data-about > About and licenc es< / button > < / div >
< div class = "grid-3" >
< section class = "panel" id = "privacy" >
< div class = "shelf-head" > < h2 > Privacy & updates< / h2 > < span class = "spacer" > < / span > < span class = "sub" id = "appVersion" > < / span > < / div >
< label class = "popt" > < input type = "checkbox" id = "tUsage" > < b > Anonymous usage statistics< / b >
@@ -1150,6 +1208,27 @@
< button class = "small" id = "updateCheck" hidden > Check for updates< / button >
< a class = "sub" href = "https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target = "_blank" > What's collected, exactly< / a > < / div >
< / section >
< section class = "panel" id = "artworkSettings" >
< div class = "shelf-head" > < h2 > Library artwork< / h2 > < / div >
< p class = "sub" > SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.< / p >
< label for = "steamGridKey" > SteamGridDB API key< / label >
< input type = "password" id = "steamGridKey" autocomplete = "off" spellcheck = "false" placeholder = "Optional API key" >
< div class = "row" style = "margin-top:8px" >
< button id = "saveSteamGridKey" class = "action small" > Save key< / button >
< button id = "clearSteamGridKey" class = "small" > Remove saved key< / button >
< a href = "https://www.steamgriddb.com/profile/preferences/api" target = "_blank" rel = "noreferrer" > Get a free key< / a >
< / div >
< div id = "steamGridStatus" class = "hint" role = "status" > < / div >
< button id = "refreshAndroidArt" class = "small" style = "margin-top:12px" > Refresh artwork for installed apps and titles< / button >
< / section >
< section class = "panel" id = "assistant" >
< div class = "shelf-head" > < h2 > Assistant and AI agents< / h2 > < / div >
< p class = "sub" > Use your own model endpoint, and review what an AI agent asks to do over MCP before it happens. Nothing is
sent to a model until you turn it on.< / p >
< div class = "row" style = "margin-top:12px" > < a class = "button-link" href = "/assistant" > Open assistant settings< / a > < / div >
< / section >
< / div >
< / div >
< div class = "page" data-page = "devices" >
@@ -1304,18 +1383,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" >
@@ -1341,7 +1427,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 +1470,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);
@@ -1404,6 +1499,7 @@ function applyHostWording(h) {
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
$("clipMac").textContent = `Send ${h.computer} clipboard`;
if (h.mobile) $("offSetup").textContent = "Change headset…";
$("nowMac").hidden = !!h.mobile || h.computer !== "Mac";
}
// Images to the phone (the app offers Save Image to Photos, Files and so on).
function blobBase64(blob) {
@@ -1487,6 +1583,7 @@ function setOnline(ok, why) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true; closeDevPop();
$("nowState").textContent = "Waiting for the Frame…";
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
@@ -1561,7 +1658,7 @@ $("vrUtilities").onclick = async e => {
};
// The Frame owns the clock. Poll independently of status and the selected tab.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false, comfortActive = false;
async function localNotification(message, request = false) {
try {
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
@@ -1589,12 +1686,19 @@ function renderComfort(s) {
if (s.id !== comfortSession) {
comfortSession = s.id;
comfortSeen.clear();
if (s.options) for (const [key, value] of Object.entries(s.options)) {
// A finished session's settings (a one-minute test, say) mustn't replace the defaults.
if (s.active & & s.options) for (const [key, value] of Object.entries(s.options)) {
const el = $(key === "minutes" ? "sessionMinutes" : key);
if (!el) continue;
if (el.type === "checkbox") el.checked = value; else el.value = value;
}
}
// When the running session ends, the form goes back to the page's defaults for the next one.
if (comfortActive & & !s.active) for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"]) {
const el = $(id);
if (el.type === "checkbox") el.checked = el.defaultChecked; else el.value = el.defaultValue;
}
comfortActive = !!s.active;
let statusText = s.error || (s.active
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
: "No session running.");
@@ -1615,7 +1719,8 @@ function renderComfort(s) {
// Keep only the server's bounded history; a new page seeds it without replay.
comfortSeen = new Set((s.events || []).map(e => e.id));
if (latest !== null) $("comfortAnnouncement").textContent = latest;
const history = (s.events || []).slice(-3).map(e => e.message).join(" · " );
const at = t => new Date(t * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" } );
const history = (s.events || []).slice(-3).map(e => `${at(e.time)} ${e.message}`).join(" · ");
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
@@ -1646,6 +1751,13 @@ $("testNotification").onclick = () => act("Test notification", async () => {
await localNotification("Comfort notifications are enabled on this device.", true);
return {message: "Test notification sent. Check your device's notification settings if it didn't appear."};
});
// Home's shortcuts to the things people come to do next.
document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => {
const to = b.dataset.go;
if (location.hash === "#" + to) showPage(); else location.hash = to;
if (to === "pad") { if (pad.state === "off" || pad.state === "error") padStart(); $("padType").focus({ preventScroll: true }); }
});
$("nowSend").onclick = () => $("fileInput").click();
$("castBtn").onclick = () => {
setView("headset");
if (!live) toggleLive(true);
@@ -1726,6 +1838,8 @@ function render(s) {
const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop],
["Lepton", s.services.lepton], ["RDP", s.services.rdp]];
$("nowState").textContent = s.services.steamvr ? "In VR: SteamVR is running." :
"SteamVR isn't running, so there's no headset view until it starts.";
$("services").innerHTML = svc.map(([n, on]) => `< span class = "chip" > < span class = "dot ${on ? " on " : " " } " > < / span > ${esc(n)}< / span > `).join("");
if (s.volume & & !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted);
@@ -2598,7 +2712,11 @@ $("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard
const drop = $("drop");
drop.onclick = () => $("fileInput").click();
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
$("fileInput").onchange = () => {
// Chosen from Home: show Tools' file panel, where the progress bar is (phones have no Activity bar).
if ($("fileInput").files.length & & page !== "tools") location.hash = "#transfer";
sendFiles([...$("fileInput").files]);
};
// Files can be dropped anywhere in the window; an overlay says what happens.
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
let dragDepth = 0;
@@ -3713,20 +3831,27 @@ $("macview").onclick = async e => {
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
const PAGES = ["home", "games", "android", "tools"];
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library : "ga mes ", sideloade d: "ga mes ", getgame s: "ga mes ",
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
const SECTION_PAGE = { view: "home", device: "home", shots: "home", comfort : "ho me", pa d: "ho me", vrTool s: "ho me",
library: "games", sideloaded: "games", getgames: "games", display: "android",
transfer: "tools", apps: "tools", power: "tools", macview: "tools", media: "tools", panels: "tools",
privacy: "settings", artworkSettings: "settings", assistant: "settings" };
let page = "home";
function showPage() {
function showPage(e ) {
const id = location.hash.slice(1);
const next = PAGES.includes(id) ? id : SECTION_PAGE[id] || "home";
if (next !== page & & page === "home" & & live) toggleLive(false); // don't stream video nobody is watching
const prev = page;
page = next;
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
$("settingsBtn").classList.toggle("on", page === "settings");
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 scrollToY(0);
if (page === "tools") { loadMacView(); loadPanels(); }
// Another computer may have added or removed a title: phones have no Refresh button. Only
// on navigating here (e: the hashchange); at startup the first load is already on its way.
if (e & & page === "games" & & prev !== "games") loadTitles();
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
@@ -3755,6 +3880,8 @@ function padShow(status) {
$("padOn").hidden = ready || busy;
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
$("padArea").classList.toggle("off", !ready);
$("padBody").hidden = pad.state === "off";
$("padIntro").hidden = pad.state !== "off";
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
@@ -4236,28 +4363,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 +4416,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 +4449,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 +4611,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 +4669,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();
@@ -4425,7 +4692,8 @@ function setUpActive() {
$("connSetup").onclick = setUpActive;
// ---- Devices tab ----
PAGES.push("devices");
PAGES.push("devices", "settings"); // Settings has no tab: the gear in the header, or key 6
$("settingsBtn").onclick = () => { location.hash = "settings"; };
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
async function loadDevices() {
if (!link.live) return;
@@ -4463,7 +4731,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 +4745,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 +4760,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 +4798,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" >
@@ -4549,22 +4812,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;