Compare commits

...
Author SHA1 Message Date
saphid 87884f382b Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:26:07 +10:00
saphid f98f3e728b Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:21:07 +10:00
saphid cc9af9eba4 Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:08:06 +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 c2c1cf0168 Check every JavaScript script type, and keep the name check without acorn
From the review of the parser change: scripts typed text/ecmascript,
application/x-javascript and the other JavaScript MIME types run as classic
scripts too, so they're checked now. And the duplicate-name check (V8 only)
no longer skips with the var check when a Node lacks acorn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:43 +10:00
saphid 01ba796881 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:37:12 +10:00
saphid 3fb876ad5a Merge branch 'ui-3-home' into ui-4-store 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 9bd6890fe8 Find var declarations with a parser, and check only classic inline scripts
The fix review showed the var regex flagged the word in comments and strings and
missed var/*c*/x and var {x}. The test now uses the acorn parser Node ships
(--expose-internals; it skips if a Node ever drops it) to find real var
declarations, and reads the page with html.parser so it checks inline classic
scripts only: no src, and no module or JSON data type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:29:12 +10:00
saphidandClaude Opus 5.5 0d181b676e Retry from the hero after a failed install, and keep var out of the page
From the fix review:

- The hero's Install skipped installing whenever a job existed, including one
  that had failed, so it only opened the details. It now tries again after a
  failure, and still only opens the details while installing or once installed.
- Declaring a var twice is legal, so the strict-mode check couldn't see it. The
  page's one var (a block-local in the capture code) is now let, and the test
  rejects var outright (CSS var() in strings is fine). It also checks every
  inline script, whatever its attributes.
- Clear filters still set the removed source filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:04:52 +10:00
saphid 8de0292568 Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 22:03:10 +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
saphid 30509da71f Merge branch 'ui-3-home' into ui-4-store 2026-10-01 21:32:42 +10:00
saphid d4b34e1bad Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:32:42 +10:00
saphidandClaude Opus 5.5 497b5d9ae6 Don't launch from the hero's Install, and check page names with V8
From the independent review of this PR:

- After installing the featured app, the hero's Install on the Frame took the
  installed branch and launched it on the headset. It now opens the details,
  whose button says Open in Steam, without acting.
- The duplicate-name test matched declarations at column zero only, so an
  indented second loadPanels (the bug it was written for) passed, and so did
  classes, destructuring and later declarators. It now compiles both inline
  scripts as one strict-mode block, where V8 rejects any name declared twice
  in the shared scope, with fixtures for each case and for same-named helpers
  in different functions (which are fine).
- A card's "Won't run" overstated the check's "Might not work"; it now says
  May not run.
- Removed styles and the source-filter state that nothing used any more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:21:34 +10:00
saphid 04995c066f Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 21:10:07 +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
saphid fab04f0558 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:03:13 +10:00
saphid 002c040223 Merge branch 'ui-3-home' into ui-4-store 2026-09-30 20:58:23 +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 9e25ae9025 Make the Android store look and read like the rest of the app
Apps without a banner or screenshot show their icon on a blurred wash of
itself, or a VR or app glyph, rather than a giant letter. Every card says
whether it will run on the Frame (works, should install, not checked yet,
won't run), in place of "Free" on every card.

The featured app isn't repeated in the rows below, and no row repeats
another. Its buttons stay in the app: Install on the Frame opens the details
with the install under way, and Details opens them. It's chosen from apps
that can run, with real artwork, VR first.

The stray "Browse SideQuest" link goes; the Sources dialog lists SideQuest
with a link to its website instead of a switch that did nothing. The "All
sources" drop-down goes too, as the Sources dialog already chooses them.
The copy is plain and British ("Apps and games for the Frame", no "Find
your next favorite"), and Games and Android point at each other.

Two functions were each declared twice across the page's scripts, so the
later one silently replaced the earlier everywhere: the store's details
dialog opened empty (the Devices tab's renderDetail ran instead), and Home's
Desktop view never found the headset's panels (the panel switcher's loader
ran instead). They're renamed, and a test
fails on any top-level name declared twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +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
14 changed files with 569 additions and 188 deletions

No files matched your search

+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
+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
+11 -6
View File
@@ -17,11 +17,16 @@ 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
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
@@ -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).
+1 -1
View File
@@ -98,7 +98,7 @@ The same error is sent at most once every 10 minutes.
## Report a problem
**Report a problem** is the speech-bubble button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
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
+2
View File
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
- `search()` raises a user-readable `SourceError` with the browse URL (zero
limit returns no rows). It does not invent app results or report a false
“no matching games”. The aggregate search UI should surface this source error.
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
with a link to its website instead of an on/off switch.
- `details()` accepts a numeric listing id and returns its canonical page link,
`downloadable: False`, empty versions/tags/headsets and the `images` shape
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
+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)
+141
View File
@@ -0,0 +1,141 @@
"""The page's inline scripts share one global scope, so a second top-level function or
variable with a name already used replaces the first everywhere, silently."""
import html.parser
import json
import pathlib
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
# indented or declared: function, class, let, const, destructuring), while helpers with the
# same name inside different functions stay legal. var is the exception (declaring one twice
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
# real var declarations, not the word in comments, strings or CSS var().
DUPLICATES = r'''
const vm = require('vm');
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
catch (e) { console.log(e.message); }
'''
VARS = r'''
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
let acorn, walk;
try {
acorn = require('internal/deps/acorn/acorn/dist/acorn');
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
} catch { console.log('no parser'); process.exit(0); }
for (const code of scripts) {
let found = null;
try {
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
});
} catch (e) { console.log(e.message); process.exit(0); }
if (found) { console.log('declares with var: ' + found); process.exit(0); }
}
console.log('ok');
'''
def node(script, scripts, *flags):
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
return r.stdout.strip() or r.stderr.strip()
def check(scripts):
"""'ok', or V8's message for the first name declared twice."""
return node(DUPLICATES, scripts)
def check_vars(scripts):
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
out = node(VARS, scripts, '--expose-internals')
if out == 'no parser':
raise unittest.SkipTest("this Node doesn't include acorn")
return out
class ClassicScripts(html.parser.HTMLParser):
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
module has its own scope, and JSON data isn't code). The types a browser runs as script:
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
def __init__(self):
super().__init__()
self.scripts, self.current = [], None
def handle_starttag(self, tag, attrs):
if tag == 'script':
a = dict(attrs)
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
self.current = [] if inline else None
def handle_data(self, data):
if self.current is not None:
self.current.append(data)
def handle_endtag(self, tag):
if tag == 'script' and self.current is not None:
self.scripts.append(''.join(self.current))
if tag == 'script':
self.current = None
def classic_scripts(page):
p = ClassicScripts()
p.feed(page)
return p.scripts
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
class PageScripts(unittest.TestCase):
def test_no_top_level_name_is_declared_twice(self):
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
self.assertGreaterEqual(len(scripts), 2)
self.assertEqual(check(scripts), 'ok')
def test_the_page_declares_nothing_with_var(self):
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
self.assertEqual(check_vars(scripts), 'ok')
def test_the_check_finds_what_it_should(self):
twice = {
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
'class': ['class Panel {}', 'class Panel {}'],
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
'function and const': ['function f() {}', 'const f = 1;'],
}
for what, scripts in twice.items():
with self.subTest(what):
self.assertIn('already been declared', check(scripts))
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
self.assertEqual(check(helpers), 'ok')
def test_the_var_check_finds_what_it_should(self):
# var: legal to declare twice, so not allowed at all; however it's written.
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
with self.subTest(code):
self.assertIn('declares with var', check_vars([code]))
# The word var elsewhere is fine.
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
self.assertEqual(check_vars(text), 'ok')
def test_only_inline_classic_scripts_are_checked(self):
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
'<script data-src="y" type="text/javascript">let c;</script>'
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
if __name__ == '__main__':
unittest.main()
+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()
+309 -173
View File
@@ -68,11 +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: 1340px) { .brand b { display: none; } header { gap: 22px; } }
@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; }
@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; } .pill .pt > span { font-size: 13px; color: var(--bright); } }
.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; }
@@ -85,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;
@@ -98,7 +100,7 @@
.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: 124px; 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; } }
@@ -236,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; }
@@ -263,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);
@@ -485,9 +511,9 @@
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
.brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */
.pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); }
.pill { min-width: 104px; }
.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;
@@ -549,8 +575,9 @@
.store-chips { display:flex; flex-wrap:wrap; gap:7px; }
.store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; }
.store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; }
.store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; }
.store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; }
.store-heading a, .cross-link a { color:var(--link); }
.cross-link { margin:-6px 0 12px; }
body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); }
body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; }
.store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; }
@@ -558,14 +585,24 @@
.store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; }
.store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); }
.store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
.art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; }
.store-art > img.art-wash { inset:-24px; width:calc(100% + 48px); height:calc(100% + 48px); z-index:0; filter:blur(26px) saturate(1.4); opacity:.55; }
.store-art > img.art-icon { inset:0; margin:auto; width:64px; height:64px; border-radius:15px; z-index:0; box-shadow:0 10px 28px #0007; }
.detail-banner > img.art-icon, .store-hero .store-art > img.art-icon { width:88px; height:88px; border-radius:20px; }
.store-art .art-glyph { position:absolute; inset:0; display:grid; place-items:center; color:hsl(var(--hue) 45% 72% / .38); }
.store-art .art-glyph svg { width:34%; max-width:84px; height:auto; }
.runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; }
.runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; }
.runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; }
.store-hero > .store-art { position:absolute; inset:0; }
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
.hero-copy .store-eyebrow { color:#c2e7ff; }
.hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
.hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; }
.hero-copy .hero-sum { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
.hero-actions { display:flex; gap:10px; margin-top:6px; }
.hero-actions button { height:38px; border-radius:6px; padding:0 18px; }
.hero-details { background:#ffffff20; color:#fff; border:1px solid #ffffff40; }
.store-hero { cursor:pointer; }
.store-row { margin-bottom:34px; }
.store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
.store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; }
@@ -581,10 +618,8 @@
.store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; }
.store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
.store-card-foot b { color:#dbeecf; font-weight:500; }
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
@@ -641,6 +676,7 @@
.source-setting strong { display:block; font-size:14px; color:white; }
.source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; }
.source-setting small { color:var(--muted); font-size:10px; }
.source-web { font-size:12px; white-space:nowrap; }
.source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; }
.source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; }
.source-toggle:checked { background:#208dca; }
@@ -664,7 +700,7 @@
.store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; }
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
.hero-copy .hero-sum { font-size:13px; max-width:260px; }
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
@@ -692,7 +728,7 @@
<nav id="nav">
<a href="#home" class="on" title="Headset view, status and screenshots (1)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="4"/><circle cx="8.5" cy="12" r="1.6"/><circle cx="15.5" cy="12" r="1.6"/></svg>Home<kbd>1</kbd></a>
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#android" title="Android apps and VR games from outside Steam (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
</nav>
@@ -703,6 +739,9 @@
<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>
</button>
@@ -811,8 +850,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>
@@ -820,33 +860,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>
@@ -876,6 +912,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>
@@ -901,17 +940,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">
@@ -931,6 +982,8 @@
<section id="getgames">
<div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
<span class="sub" id="gmDl"></span></div>
<p class="sub cross-link">Your Steam library and the Steam store. Free VR games and apps from outside Steam are on
<a href="#android">Android</a>.</p>
<div class="panel">
<div class="cat-tools">
<div class="seg" id="gmSrc" aria-label="Where to look">
@@ -953,11 +1006,11 @@
<div class="page" data-page="android">
<section id="android">
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small" title="Choose where Discover looks for apps">Sources…</button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Discover apps">
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
<div class="store-toolbar"><div class="store-heading"><div><h1>Apps and games for the Frame</h1><p>Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in <a href="#games">Games</a>.</p></div></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">All</button><button data-filter="vr" aria-pressed="false">VR</button><button data-filter="flat" aria-pressed="false">Flat-screen</button><button data-filter="installable" aria-pressed="false">Installs on the Frame</button></div></div>
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
<div id="searchGrid" aria-busy="false"></div>
@@ -1030,7 +1083,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>
@@ -1057,31 +1168,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>
@@ -1092,20 +1178,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">
@@ -1120,45 +1192,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>
@@ -1176,6 +1220,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">
@@ -1216,9 +1281,9 @@
</dialog>
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div class="sources-intro"><h1 id="sourcesTitle">Sources</h1><p>Where Discover looks for apps. Turn a source off to hide its apps.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Add an F-Droid repository by its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -1446,6 +1511,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) {
@@ -1529,6 +1595,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) {
@@ -1603,7 +1670,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);
@@ -1631,12 +1698,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.");
@@ -1657,7 +1731,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() {
@@ -1688,6 +1763,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);
@@ -1768,6 +1850,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);
@@ -1922,7 +2006,7 @@ async function capture() {
let url = null;
try {
let url_ = "/api/screenshot?view=headset";
var shotPanel = null;
let shotPanel = null;
if (view === "flat") {
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
if (!shotPanel) throw new Error("no app panel is open in the headset");
@@ -2640,7 +2724,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;
@@ -3107,56 +3195,90 @@ document.body.addEventListener("click", async e => {
loadOwned();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const sourceState = { vr: null, installable: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
// Artwork: the banner or first screenshot. Under it (and instead of it, when there's none, or it
// doesn't load) the app's icon on a blurred wash of itself, or failing that a VR or app glyph.
const GLYPH_VR = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="5" y="15" width="38" height="20" rx="8"/><circle cx="17" cy="25" r="3.5"/><circle cx="31" cy="25" r="3.5"/></svg>`;
const GLYPH_APP = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="8" y="8" width="13" height="13" rx="3.5"/><rect x="27" y="8" width="13" height="13" rx="3.5"/><rect x="8" y="27" width="13" height="13" rx="3.5"/><rect x="27" y="27" width="13" height="13" rx="3.5"/></svg>`;
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
const icon = !path && /^\/source-image\/[\w-]+$/.test(o.artwork?.icon || "") ? o.artwork.icon : "";
const under = path ? "" : icon
? `<img class="art-wash" src="${esc(icon)}" alt="" loading="lazy" decoding="async"><img class="art-icon" src="${esc(icon)}" alt="" loading="lazy" decoding="async">`
: `<span class="art-glyph">${o.vr ? GLYPH_VR : GLYPH_APP}</span>`;
return `<span class="store-art ${cls}${image ? "" : " no-image"}" style="--hue:${storeHue(o.name)}">${under}${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
document.addEventListener("error", e => {
if (!e.target.matches?.(".store-art img, .store-icon img")) return;
const art = e.target.closest(".store-art");
e.target.remove();
// Nothing left to show: the glyph rather than an empty wash.
if (art && !art.querySelector("img") && !art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `<span class="art-glyph">${GLYPH_APP}</span>`);
}, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
function sourceDescription(s) {
const kinds = {fdroid:"Free and open-source Android apps", itch:"Games from independent developers", github:"Releases straight from the developers",
sidequest:"VR games and apps. Its terms don't allow searching it from here, so browse it on its website."};
return s.description || kinds[s.kind] || "Apps from this repository";
}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}>`}</div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
// Will it run? Short enough for a card; the full sentence is in its tooltip and the details.
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
function storeRuns(o) {
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
return `<span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>`;
}
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span>${storeKind(o)}</span>${storeRuns(o)}</span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2>${subtitle ? `<p>${esc(subtitle)}</p>` : ""}</div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty(message) {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>${message ? "Couldn't load apps" : "No apps found"}</h2><p>${message ? esc(message) : "Try another name, or clear the filters."}</p><button class="action" id="storeReset">${message ? "Try again" : "Clear filters"}</button></div>`;
}
function renderStore() {
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true">&nbsp; ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
// The featured app: something that runs, with real artwork, and preferably VR. It isn't repeated below.
const runs=a=>a.offers[0].verdict?.tone!=='blocked', art=a=>a.offers[0].artwork?.banner;
const featured=apps.find(a=>a.offers[0].vr && art(a) && runs(a)) || apps.find(a=>art(a) && runs(a)) || apps.find(runs) || apps[0], o=featured.offers[0];
const rest=apps.filter(a=>a!==featured);
const tested=rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : rest.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
// Rows don't repeat each other either: each takes the next apps not shown yet.
const shown=new Set(), take=list=>{ const out=list.filter(a=>!shown.has(a)).slice(0,5); out.forEach(a=>shown.add(a)); return out; };
const vrRow=take(vr);
const likely={works:0,ready:1,unknown:2,blocked:3}, rank=a=>likely[a.offers[0].verdict?.tone] ?? 2;
const popular=take([...rest].sort((a,b)=>rank(a)-rank(b) || (b.offers[0].popularity||0)-(a.offers[0].popularity||0)));
const recent=take(rest.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||''))));
const canInstall=o.downloadable && runs(featured);
$("searchGrid").innerHTML=`<div class="store-hero" data-app="${apps.indexOf(featured)}">${storeArt(o,"",true)}<div class="hero-copy"><span class="store-eyebrow">Featured${o.vr ? " in VR" : ""}</span><strong>${esc(featured.name)}</strong><span class="hero-sum">${esc(storeText(featured.summary))}</span><span class="hero-runs">${storeRuns(o)}</span><span class="hero-actions">${canInstall ? `<button class="action" data-hero-install="${apps.indexOf(featured)}">Install on the Frame</button>` : ""}<button class="hero-details" data-app="${apps.indexOf(featured)}">Details</button></span></div></div>` +
storeRow(tested.length ? "VR that works on the Frame" : "VR games and experiences", tested.length ? "Checked on a Frame" : "Made for headsets; most aren't checked on the Frame yet", vrRow) +
storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular" : "More to try", "", popular) +
storeRow("Recently updated", "New versions from their developers", recent);
}
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
async function searchSources(quiet) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
const params=new URLSearchParams({q:$("sourceQ").value,installable:String(sourceState.installable)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3169,20 +3291,20 @@ async function searchSources(quiet) {
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
$("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>` <a href="${esc(x.url)}" target="_blank" rel="noopener">Browse ${esc(storeName(x.name))} ↗</a>`).join("");
$("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length;
$("sourceStatus").textContent=notes.join(" ");
$("sourceStatus").hidden=!notes.length;
clearTimeout(sourceState.retry);
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
renderStore();await loadSources();
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } }
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty("Couldn't reach the app sources. Try again in a moment.");$("sourceStatus").hidden=true; } }
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function renderDetail() {
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function renderAppDetail() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3190,16 +3312,21 @@ function updateInstall() {
const o=currentOffer();if(!o || !$("detailInstall"))return;
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page);
b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗';
b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on the Frame') : 'Get from the creator ↗';
$("detailProgress").hidden=!job || job.done;
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
$("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
$("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Installing. You can close this and keep browsing.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
}
async function openStoreApp(index) {
async function openStoreApp(index, install=false) {
const app=sourceState.apps[index];if(!app)return;
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
// The hero's Install: the details show its progress. Already installed or installing: just
// the details, whose button says what it does (Open in Steam would launch it). After a
// failed install it tries again.
const o=currentOffer(), job=sourceState.jobs.get(`${o.source}:${o.id}`);
if(install&&(!job||job.done&&!job.result))installStoreApp();
await loadOfferDetails();
}
async function loadOfferDetails() {
@@ -3208,7 +3335,7 @@ async function loadOfferDetails() {
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail();
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
}
async function installStoreApp() {
@@ -3230,17 +3357,16 @@ async function installStoreApp() {
}
async function changeSource(body) {
$("sourceError").textContent="Saving your sources…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;sourceState.source='';await loadSources();await searchSources(); }
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
let storeSearchTimer;
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("searchGrid").onclick=e=>{const h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
@@ -3249,7 +3375,7 @@ async function addSource(body) {
let error="";
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
if(result){$("addSource").reset();await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
@@ -3659,7 +3785,7 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
async function loadPanels() {
async function loadPanelSwitcher() {
const seq = ++panelSeq;
try {
const s = await api("/api/panels", {action: "list"});
@@ -3670,7 +3796,7 @@ async function loadPanels() {
: '<div class="sub">No open panels.</div>';
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
}
$("panelsRefresh").onclick = loadPanels;
$("panelsRefresh").onclick = loadPanelSwitcher;
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
() => api("/api/panels", {action: "open"}), e.currentTarget);
$("panelList").onclick = e => {
@@ -3755,20 +3881,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(); }
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
// 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).
@@ -3797,6 +3930,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"
@@ -4607,7 +4742,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;