Compare commits

...
22 Commits
Author SHA1 Message Date
saphid f98f3e728b Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:21:07 +10:00
saphidandClaude Opus 5.5 90034540ae Keep the toast's fade-in, and join the test's threads on failure
From the review: opening the toast popover with .show already set skipped the
fade-in; it now opens hidden and then shows. And the superseded-test test now
joins its threads in finally, so a failure can't leave them running into
cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:21:07 +10:00
saphid bc0e0fc043 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 5b4efc87b3 Show toasts above open dialogs, and harden the superseded-test test
From the review of the Reconnect change:

- A toast raised while a dialog was open (Reconnect refused during an install,
  say) sat under the dialog's backdrop, dimmed. The toast is now a manual
  popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
  and didn't check its threads finished. It now waits on events with time
  limits, releases everything in finally, and also checks the first test
  finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
  address wins still depends on ranking and timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:03:08 +10:00
saphid 01ba796881 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 b9f96866e5 Replace Use now with a plain Reconnect, and ignore superseded tests
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.

The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.

The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 8e8c24f370 Don't break startup at Games links, and reload titles in the iPhone app too
The fix review found the Games reload added to showPage() read `link`, which
the page declares later: opening Frame Control at #games, #library,
#sideloaded or #getgames threw at startup and skipped the rest of the setup.
And the iPhone app never watches the connection, so `link.s` stayed null and
titles still never reloaded there.

The reload now runs only when navigating to Games (the hashchange), where the
first load is already done, and doesn't depend on connection state; a failed
load shows its error in the list as Refresh did. tests/test_page_startup.py runs
the real showPage() at every page and section link with everything declared
later still uninitialised, and fails on the old line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:02:53 +10:00
saphid 79dda60bde Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:00:27 +10:00
saphidandClaude Opus 5.5 0bb2f9eb03 Offer Use now from the finished test's own order
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.

The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:54:16 +10:00
saphid d4b34e1bad Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:32:42 +10:00
saphid 22548bc42f Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 21:32:41 +10:00
saphidandClaude Opus 5.5 0bceb5c928 Reload titles on Games, show Home uploads' progress, reset the comfort form
From the independent review of this PR:

- Phones lost the Refresh button, and nothing else reloaded sideloaded titles,
  so one added or removed from another computer never showed. Opening Games
  now reloads them.
- Send files... on Home uploaded with its progress bar in the hidden Tools
  page, and phones have no Activity bar. Choosing files from Home now opens
  the file panel on Tools.
- A session that finished while the page was open left its settings (1 / 0 / 0
  after a test) in the form. The form goes back to its defaults when the
  session ends. The test now covers that, and its mock uses the page's real
  break default (20 minutes, not 30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:09:58 +10:00
saphidandClaude Opus 5.5 22bb9c6469 Offer Use now only where a reconnect goes, and keep newer edits and headsets apart
From the second independent review of this PR:

- Use now sends a plain reconnect, which picks the first-ranked address that
  answers, but it was offered on every tested address listed above the one in
  use. The devices list now carries each address's rank on the current network
  (frame_devices.order_addresses), and only the address a reconnect would pick
  gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
  closed the second editor and lost what was typed. Each edit now has its own
  session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
  previous headset's status before it was cleared, so Add could save its IP to
  the new headset. The dialog now renders after the old status is cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:03:13 +10:00
saphid fab04f0558 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:03:13 +10:00
saphid 0520082586 Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 36dd5a05f2 Make the offered home address the one that gets used, and keep edits safe
An interrupted review pass pointed at four problems in the pill's dialog, each
confirmed against the Frame:

- The offer to add the Frame's LAN address appended it after the Tailscale name,
  which then kept winning on that network, so nothing changed. The offer now adds
  it first in the list (address-add takes first: true); away from home the
  Tailscale name still leads. A tested address that ranks above the one in use
  gets a Use now button that reconnects through it.
- A half-typed edit was thrown away when the connection state changed after
  focus left the input, and when the server refused the save. The edit row now
  stays until it is saved or cancelled.
- Pressing Enter twice sent the update twice.
- The offer's button could act on the previously selected headset.

Keyboard focus also stays on the same button of the same address when the rows
are rebuilt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 21:28:38 +10:00
saphidandClaude Opus 5.5 4b1bb1557d Make room in the header for the settings button
With one more button in the header the pill lost its route at a few widths
(970-1020, 1160-1180 and 1350-1410 px). The header's spacing now tightens below
1500 px, the shortcut hints go at 1200, and the compact header starts at 1030,
so the pill reads "Connected · Tailscale" in full at every width above that,
including the 1400 px default window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:58:23 +10:00
saphid b151ae691e Merge branch 'ui-2-connection' into ui-3-home
# Conflicts:
#	ui/index.html
2026-09-30 20:40:03 +10:00
saphidandClaude Opus 5.5 36db6b1ac6 Never cut the pill's status word short
At 1260 to 1400 px wide the header was a few pixels too tight and the pill read
"Connected · Tails…"; on a phone it read "Connected · T…". The status word now
always shows whole: when the route doesn't fit beside it, the route drops out
instead of being clipped. The wordmark gives way a little earlier so the route
shows at every desktop width above 960 px, and the smallest phones lose the
logo rather than squeeze the pill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:43 +10:00
saphid 7ea999d5b4 Merge branch 'ui-1-layout' into ui-2-connection 2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 ed4a0ad5cd Lead Home with what's happening, and give settings a page
Home answers "what's my headset doing, and what do I want to do now". A
Right now card says whether SteamVR is running and holds the volume and the
next things people do: cast to this screen, play a video or photo, show a
Mac window, send files, type on the Frame. With nothing captured, the
headset view is a short strip rather than a big black box; the keyboard and
trackpad fold to one line until turned on; VR performance folds away.
Screenshots and Family and comfort follow.

Tools is grouped: in the headset (Mac in the headset, the media player, the
panel switcher), then sending files, Linux apps, remote and power. Privacy
and updates, library artwork and the assistant move to a Settings page,
opened by a gear in the header (key 6), with About and licences.

Family and comfort no longer fills the form with a finished session's
settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0,
and its recent events carry their times.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:57 +10:00
saphidandClaude Opus 5.5 cb0a90a9c3 Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.

The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.

On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:39:52 +10:00
18 changed files with 658 additions and 207 deletions

No files matched your search

+1 -1
View File
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
+1 -1
View File
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
To put the same page in the headset, with the HTTP server still running:
```sh
+16 -4
View File
@@ -3,8 +3,9 @@
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
@@ -57,7 +58,7 @@ and ranks them:
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
@@ -122,7 +133,8 @@ file per headset instead, so saving or forgetting one headset's key never touche
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
SSH alias, user and port) lets the next
connection save the new one.
## One server at a time
+1 -1
View File
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
## Casting
**Cast headset view** starts the existing headset Live view and requests full
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
screen where supported. Show that screen to people in the room, or use the
computer/phone's own screen mirroring. It creates no new stream transport,
public URL or LAN server. iPhone uses the inline viewer if full screen is not
+14 -9
View File
@@ -17,14 +17,19 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (sending files and text,
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
The window has five tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
player and the panel switcher; then sending files and text, Flatpaks, remote and
power) and **Devices** (your headsets and their addresses). Keys 1–5 switch
between them. The gear in the header (key 6) opens **Settings**: privacy and
updates, library artwork and the assistant. The battery chip opens the headset's
details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
@@ -178,5 +183,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
require a separate user approval; power also retains its password prompt. The
assistant uses a user-chosen endpoint and sends nothing until the user opts in
for a message. Screenshot context is separately opt-in. Model replies cannot
operate the headset. Tools → Open assistant opens the page; the linked guide
operate the headset. Settings → Open assistant settings opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
+2 -2
View File
@@ -114,8 +114,8 @@ don't.
## Family and comfort
The shared Home card sets session limits, breaks and check-ins, and offers
**Cast headset view**. **Enable / test notifications** requests iOS notification
The shared Home card sets session limits, breaks and check-ins; **Cast to this
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
permission and sends a local test. These are local notifications, not APNs push;
iOS background suspension can interrupt phone alerts. The headset timer still
runs. See [the behavior and verification limits](family-comfort.md).
+1 -1
View File
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
separate from Frame Control. Public reports describe some Proton support;
Windows-only does not by itself prove a Frame app cannot run. Local status
and sources are in [VR utilities](vr-utilities.md).
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
headset creates its own gamescope panel using built-in tools. It needs no
third-party overlay app. [Metrics and verification](vr-utilities.md).
+2 -2
View File
@@ -97,8 +97,8 @@ The same error is sent at most once every 10 minutes.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
**Report a problem** is the speech-bubble button in the header, also in the
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
+1 -1
View File
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
an installed app with a blank tile.
Artwork preference is **SteamGridDB → source images → generated fallback**.
Set the optional free key in Frame Control's **Library artwork settings**, or
Set the optional free key in Frame Control's **Settings → Library artwork**, or
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
settings override the saved key. Without a key there are no provider calls or
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
+1 -1
View File
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
## Our performance HUD
On **Home → VR comfort and performance**, the app shows a timestamped sample
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
+1 -1
View File
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
## Use it
In **Tools → Media in the headset**, send a file, choose its layout and press
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
**Stop** removes both. Refresh reads the library and the player's state.
The screen follows your head; it isn't a saved world-space panel.
+42
View File
@@ -55,6 +55,48 @@ const log = ()=>{}, toast = ()=>{};
assert.equal($('sessionMinutes').disabled,false);
assert.equal($('sessionCancel').disabled,true);
})().catch(e=>{console.error(e);process.exitCode=1;});
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_a_finished_session_leaves_the_defaults(self):
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
setup = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
// The form's own defaults, as in the page's HTML.
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
return elements.get(id);
};
const window = {frameApp:{notify:async()=>{}}};
const log = ()=>{}, toast = ()=>{};
'''
checks = r'''
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
assert.equal($('sessionMinutes').value, '30');
assert.equal($('breakMinutes').value, '20');
assert.equal($('batteryAlert').checked, true);
assert.match($('comfortEvents').textContent, /Session ended/);
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
assert.equal($('heatAlert').checked, false);
// The page stays open while that session ends: the next one starts from the defaults again.
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
assert.equal($('sessionMinutes').value, '30');
assert.equal($('breakMinutes').value, '20');
assert.equal($('heatAlert').checked, true);
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
$('sessionMinutes').value = '50'; // what's typed for the next session
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+14
View File
@@ -296,6 +296,20 @@ class Pins(Base):
class Registry(Base):
def test_an_address_added_first_wins_on_its_own_network(self):
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
# worked here before. The LAN address has to go ahead of it to be used at home.
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
addrs = self.reg.get(d["id"])["addresses"]
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
self.assertEqual(at_home[0], "192.168.1.40")
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
def test_address_editing(self):
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
+41
View File
@@ -457,6 +457,47 @@ class Connecting(unittest.TestCase):
self.assertEqual(self.routes, [])
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
d = self.device("nothing.invalid")
entered = {1: threading.Event(), 2: threading.Event()}
release = {1: threading.Event(), 2: threading.Event()}
calls = []
def probe(host, port, update=None):
calls.append(host)
n = len(calls)
entered[n].set()
release[n].wait(10)
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
with mock.patch.object(fl, "probe", probe):
try:
tests[0].start()
self.assertTrue(entered[1].wait(5), "the first test never probed")
tests[1].start()
self.assertTrue(entered[2].wait(5), "the second test never probed")
# The first finishes while the second is still probing: it mustn't show its
# rows or mark the second done.
release[1].set()
tests[0].join(10)
self.assertFalse(tests[0].is_alive())
running = self.link.snapshot()["tests"][d["id"]]
self.assertFalse(running["done"])
self.assertEqual(running["rows"][0]["detail"], "Waiting")
release[2].set()
tests[1].join(10)
self.assertFalse(tests[1].is_alive())
finally:
for e in release.values():
e.set()
for t in tests:
if t.ident: # started
t.join(10)
result = self.link.snapshot()["tests"][d["id"]]
self.assertTrue(result["done"])
self.assertEqual(result["rows"][0]["detail"], "test 2")
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
+55
View File
@@ -0,0 +1,55 @@
"""The page calls showPage() while its first script is still running, before names declared
later (in that script or the second one) exist. Touching one of them there throws, and the
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
import json
import pathlib
import re
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles']
RUN = r'''
const vm = require('vm');
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const failures = [];
for (const hash of hashes) {
const context = vm.createContext({
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
});
try { vm.runInContext(code, context); }
catch (e) { failures.push(`${hash}: ${e.message}`); }
}
console.log(JSON.stringify(failures));
'''
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
class PageStartup(unittest.TestCase):
def test_opening_any_page_or_section_at_startup_runs(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
tables = first[first.index('const PAGES ='):first.index('let page =')]
start = first.index('function showPage(')
show = first[start:first.index('\n}\n', start) + 3]
# Everything declared after the startup call is still uninitialised when it runs.
call = re.search(r'^showPage\(\);', first, re.M).end()
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
self.assertIn('link', later) # the name that broke #games
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
for n in STUBS if n != '$')
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#nowhere']
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(json.loads(r.stdout), [])
if __name__ == '__main__':
unittest.main()
+4 -2
View File
@@ -623,7 +623,9 @@ class Registry:
return a
raise DeviceError(f"{host} isn't one of this headset's addresses")
def add_address(self, device_id, host, kind=None, label=""):
def add_address(self, device_id, host, kind=None, label="", first=False):
"""Add an address at the end of the list, or at the front (first=True), where the
user's order makes it win over the others that work on the same network."""
with self._changing():
d = self._find(device_id)
a = new_address(host, kind, label)
@@ -631,7 +633,7 @@ class Registry:
raise DeviceError(f"{a['host']} is already on the list")
if len(d["addresses"]) >= 32:
raise DeviceError("That's enough addresses for one headset")
d["addresses"].append(a)
d["addresses"].insert(0 if first else len(d["addresses"]), a)
self.save()
return copy.deepcopy(a)
+7 -1
View File
@@ -169,6 +169,7 @@ class Link:
self.version = 0
self.stopped = False
self.kicks = [] # reasons someone asked for a (re)connect
self.test_gen = {} # device id -> its newest test of the addresses (see test())
self.busy = False # the loop is handling kicks
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
@@ -955,9 +956,13 @@ class Link:
started = now()
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
with self.cond:
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
def put(**fields):
with self.cond:
if gen != self.test_gen[device_id]:
return # a newer test has started: its results are the ones to show
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
self.version += 1
self.cond.notify_all()
@@ -1111,7 +1116,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
elif action == "address-add":
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
first=body.get("first") is True)
if is_active and link.state["phase"] == "failed":
link.kick("retry")
msg = f"Added {a['host']}"
+454 -180
View File
@@ -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: 1250px) { .brand b { display: none; } }
@media (max-width: 1500px) { 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: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
.brand, #refreshAll { display: none; } .pill .pt span { display: none; } }
@media (max-width: 1030px) { 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: 1150px) { nav kbd { display: none; } }
@media (max-width: 1200px) { 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: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* "Connected" is never cut short: when the route doesn't fit beside it, the route wraps out of sight. */
.pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; display: flex; flex-wrap: wrap; height: 1.2em; overflow: hidden; white-space: nowrap; }
.pill .via { white-space: pre; }
.conn-now { display: flex; gap: 10px; align-items: baseline; }
.conn-now .dot { position: relative; top: -1px; }
.conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; }
.conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px;
background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; }
.conn-offer .grow { flex: 1 1 260px; }
.h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
#connAddrs .item { gap: 6px; }
#connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; }
.conn-add { grid-template-columns: 2fr 1.2fr auto; }
.conn-edit { grid-template-columns: 2fr 1.2fr auto auto; }
.addr-add button.small, .dev-name button.small { height: 40px; }
.dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; }
.adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; }
.adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; }
.conn-more { margin-top: 16px; }
.conn-more summary { margin-top: 0; }
/* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */
#connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px;
background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
@@ -144,8 +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 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
<button id="reportBtn" data-report title="Report a problem or send feedback" aria-label="Report a problem or send feedback">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/><path d="M8 9.5h8M8 12.5h5" stroke-linecap="round"/></svg>
</button>
<button id="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="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. 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="shots">
<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="shots">
<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="vrTools">
<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 &amp; 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">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>
<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="spacer"></span><button class="small" data-about>About and licences</button></div>
<div class="grid-3">
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; 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="connStages"></ol>
<h3>Addresses</h3>
<table class="probes"><tbody id="connProbes"></tbody></table>
<div class="conn-now"><span class="dot" id="connDot"></span><div class="grow"><b id="connState"></b>
<div class="sub" id="connWhy"></div></div></div>
<div class="conn-offer" id="connOffer" hidden></div>
<h3>Addresses <span class="h3-note">Frame Control tries them all at once; the first in the list wins a tie</span></h3>
<div class="list" id="connAddrs"></div>
<form class="addr-add conn-add" id="connAdd">
<input type="text" id="caHost" maxlength="253" placeholder="Add an IP address or host name" aria-label="New address">
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
<button class="small" type="submit">Add</button></form>
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
<ol class="stages" id="connStages"></ol>
<h3>This computer</h3>
<dl class="facts" id="connNet"></dl>
</details>
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
<button type="button" class="small" id="connClose">Close</button></div>
<button type="button" class="small desk-only" id="connDevices">All headsets…</button>
<button type="button" class="small desk-only" id="connSetup" hidden>Set Up Connection…</button>
<button type="button" class="small" id="connRetryBtn" hidden>Retry now</button>
<button type="button" class="small" id="connClose" autofocus>Done</button></div>
</dialog>
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
<form method="dialog" id="addDevForm">
@@ -1341,7 +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: "games", sideloaded: "games", getgames: "games",
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
const SECTION_PAGE = { view: "home", device: "home", shots: "home", comfort: "home", pad: "home", vrTools: "home",
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="dvNameForm">
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
</form>
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
<button class="small" type="submit">Rename</button></form>
<h3>Addresses <span class="h3-note">tried all at once; the first in the list wins a tie</span></h3>
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
<form class="addr-add" id="dvAdd">
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
${kindSel("adKind", "")}
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
<button class="small" type="submit">Add</button></form>
<div class="row" style="margin-top:14px">
@@ -4549,22 +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;