Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui

Add removable Frame Control panel for SteamVR
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-10-25 04:09:48 +00:00
commit 649185237f
10 files changed
+583 -5

No files matched your search

+1 -1
View File
@@ -298,7 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, removal, and the local extension API |
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
+58
View File
@@ -0,0 +1,58 @@
# Frame Control in the headset
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
the headset, independent of Decky's Quick Access Menu. The panel starts only
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
service. No `sudo`, root filesystem change, or Steam client patch is needed.
The companion copies its bundled UI over the existing trusted SSH connection.
On the Frame, `frame-control-headset.service` runs a loopback server and one
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
it again from SteamVR's dashboard. Closing the panel stops its browser and
server. The service is not enabled at boot.
The page shows battery, free home storage and SteamVR status. Its per-run UI
key is passed in the URL fragment, removed from the address bar after load,
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
line and server log. The usual host/origin and anti-framing checks still apply.
## Real Frame test, 2026-10-25
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
user service, served `/headset`, read live `/api/status` from the browser, and
registered a mapped Chromium window with SteamVR under the dedicated overlay
key. The server rejected a request with the wrong UI key. A read-only status
probe reported the user service running and the overlay registered.
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
screenshot was a uniform dark frame. The documented temporary wake attempt
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
still left the overlay `not_visible`; the setting was restored to 5 and the
dashboard hidden. The X window's `xwd` image was black too. These observations
prove panel creation and page/API execution, **not wearer-visible pixels or
laser/controller input**. A worn-headset check is still required before
describing the panel as visually verified.
The test then removed the managed service and app bundle. Its unit became
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
battery was 100%. Steam and SteamVR were left running. The browser profile and
extensions folder were deliberately kept.
## Remove
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
run:
```sh
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
```
That command removes only Frame Control's managed panel code. The Chromium
profile remains in `~/.local/share/frame-control/headset/profile` so local
extension data is not discarded. Inspect and delete that folder separately if
you no longer want it. Third-party extensions are in
`~/.local/share/frame-control/extensions` and are never removed by this tool.
+65
View File
@@ -0,0 +1,65 @@
"""Headset panel packaging, ownership checks and window targeting."""
import sandbox # noqa: F401
import io
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_headset
import frame_headset_runtime
class HeadsetBundle(unittest.TestCase):
def test_bundle_is_deterministic_and_contains_the_local_ui(self):
first = frame_headset.bundle()
self.assertEqual(first, frame_headset.bundle())
with tarfile.open(fileobj=io.BytesIO(first), mode='r:gz') as archive:
names = set(archive.getnames())
self.assertIn('ui/headset.html', names)
self.assertIn('ui/frame_headset_runtime.py', names)
self.assertIn('ui/server.py', names)
self.assertFalse(any('\\' in name for name in names))
self.assertFalse(any('__pycache__' in name for name in names))
def test_incompatible_device_is_rejected_before_upload(self):
state = {'arch': 'x86_64', 'variant': 'vr'}
calls = []
def ssh(*args, **kwargs):
calls.append(args)
return json.dumps(state)
with patch.object(frame_headset, 'bundle') as bundle:
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
frame_headset.change(ssh, 'install')
bundle.assert_not_called()
self.assertEqual(len(calls), 1)
@unittest.skipIf(os.name == 'nt', 'remote script runs on Linux')
def test_unowned_install_is_never_removed(self):
with tempfile.TemporaryDirectory() as home:
token = secrets.token_hex(12)
result = subprocess.run([sys.executable, '-', 'remove', token, 'none'],
input=frame_headset.APPLY, text=True, capture_output=True,
env={**os.environ, 'HOME': home}, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertIn('No Frame Control managed', result.stderr)
class HeadsetWindow(unittest.TestCase):
def test_uses_only_its_marker_title(self):
tree = ' 0x101 "Other panel"\n 0xabc "Frame Control VR [fc-headset]"\n'
with patch.object(frame_headset_runtime, 'command', return_value=tree):
self.assertEqual(frame_headset_runtime.panel_window(), '0xabc')
with patch.object(frame_headset_runtime, 'command', return_value=' 0x101 "Other panel"'):
self.assertIsNone(frame_headset_runtime.panel_window())
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadHeadsetPanel', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+8
View File
@@ -126,6 +126,14 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(code, 400)
self.assertIn('experimental', body['error'])
def test_headset_page_and_install_acknowledgement(self):
code, _, body = self.request('GET', '/headset')
self.assertEqual(code, 200)
self.assertIn(b'Frame Control VR [fc-headset]', body)
code, body = self.post('/api/headset', {'action': 'install'})
self.assertEqual(code, 400)
self.assertIn('opt-in', body['error'])
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+203
View File
@@ -0,0 +1,203 @@
"""Install and control the removable SteamVR Frame Control panel on the Frame.
The companion sends a deterministic app bundle over SSH. The Frame owns a user
service and a browser profile; nothing is installed under /etc or as root.
"""
import gzip
import hashlib
import io
import json
from pathlib import Path
import secrets
import tarfile
ROOT = Path(__file__).resolve().parent.parent
ARCHIVE_DIRS = ('ui', 'apk-catalog', 'frame/android', 'frame/devkit-utils', 'frame/openxr-compat', 'scripts')
MAX_ARCHIVE = 32 * 1024 * 1024
PROBE = r'''
import json, os, pathlib, platform, subprocess
home = pathlib.Path.home()
base = home / '.local/share/frame-control/headset'
unit = home / '.config/systemd/user/frame-control-headset.service'
try: receipt = json.loads((base/'receipt.json').read_text())
except (OSError, ValueError): receipt = {}
try: state = json.loads((base/'state.json').read_text())
except (OSError, ValueError): state = {}
release = {}
for line in pathlib.Path('/etc/os-release').read_text().splitlines():
if '=' in line:
k, v = line.split('=', 1); release[k] = v.strip('"')
active = subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
overlay = 'valve.steam.desktopgame.2001947813'
try:
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],
capture_output=True, text=True, timeout=5)
registered = p.returncode == 0 and ("'"+overlay+"'") in p.stdout
except (OSError, subprocess.TimeoutExpired): registered = False
print(json.dumps({'arch':platform.machine(),'variant':release.get('VARIANT_ID'),
'os':release.get('VERSION_ID'),'build':release.get('BUILD_ID'),
'managed':unit.is_file() and receipt.get('managedBy') == 'Frame Control',
'version':receipt.get('version'),'running':active,'overlayRegistered':registered,
'window':state.get('window') if active else None,
'extensionsFolder':str(home/'.local/share/frame-control/extensions')}))
'''
APPLY = r'''
import hashlib, io, json, os, pathlib, platform, shutil, subprocess, sys, tarfile, time
action, token, digest = sys.argv[1:]
home = pathlib.Path.home()
base = home / '.local/share/frame-control/headset'
bundle = base / 'bundle'
receipt = base / 'receipt.json'
unit = home / '.config/systemd/user/frame-control-headset.service'
upload = pathlib.Path('/tmp/frame-control-headset-' + token)
stage = base / ('stage-' + token)
old = base / ('old-' + token)
overlay = 'valve.steam.desktopgame.2001947813'
def systemd(*args):
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=55)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'user service failed')
def managed():
try: return unit.is_file() and json.loads(receipt.read_text()).get('managedBy') == 'Frame Control'
except (OSError, ValueError): return False
def show():
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if (base/'state.json').is_file():
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard',overlay],
capture_output=True, text=True, timeout=10)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'dashboard rejected panel')
return {'message':'Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.',
'overlay':overlay}
time.sleep(.25)
raise RuntimeError('The headset panel did not register within 45 seconds; check the user service log')
try:
if action == 'remove':
if not managed(): raise RuntimeError('No Frame Control managed headset panel to remove')
systemd('stop','frame-control-headset.service')
unit.unlink()
systemd('daemon-reload')
shutil.rmtree(bundle)
receipt.unlink()
(base/'server.log').unlink(missing_ok=True)
print(json.dumps({'message':'Removed the Frame Control headset service and app bundle. Extensions and browser profile were kept.'}))
elif action == 'close':
if not managed(): raise RuntimeError('The headset panel is not installed by Frame Control')
systemd('stop','frame-control-headset.service')
print(json.dumps({'message':'Closed the Frame Control headset panel.'}))
elif action == 'open':
if not managed(): raise RuntimeError('Install the headset panel first')
systemd('start','frame-control-headset.service')
print(json.dumps(show()))
else:
if action not in ('install','update'): raise RuntimeError('Invalid headset panel action')
if platform.machine() != 'aarch64': raise RuntimeError('This headset panel needs the Frame ARM64 build')
if action == 'install' and (unit.exists() or receipt.exists() or bundle.exists()):
raise RuntimeError('Headset panel files already exist; no existing installation was changed')
if action == 'update' and not managed(): raise RuntimeError('No Frame Control managed panel to update')
data = upload.read_bytes()
if not 1024 < len(data) <= 32*1024*1024 or hashlib.sha256(data).hexdigest() != digest:
raise RuntimeError('Headset bundle size or digest did not match')
base.mkdir(parents=True, exist_ok=True, mode=0o700)
stage.mkdir(mode=0o700)
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
members = archive.getmembers()
if not members or any(not m.isfile() or not m.name.startswith(('ui/','apk-catalog/','frame/android/',
'frame/devkit-utils/','frame/openxr-compat/','scripts/')) or '..' in pathlib.PurePosixPath(m.name).parts
or m.size > 8*1024*1024 for m in members):
raise RuntimeError('Invalid headset bundle member')
archive.extractall(stage, members=members, filter='data')
if not (stage/'ui/server.py').is_file() or not (stage/'ui/frame_headset_runtime.py').is_file():
raise RuntimeError('Incomplete headset bundle')
unit.parent.mkdir(parents=True, exist_ok=True)
previous_unit = unit.read_bytes() if unit.exists() else None
previous_receipt = receipt.read_bytes() if receipt.exists() else None
if action == 'update':
systemd('stop','frame-control-headset.service')
bundle.rename(old)
try:
stage.rename(bundle)
unit.write_text('[Unit]\nDescription=Frame Control headset panel\nAfter=gamescope-session.target steamvr.service\n'
'[Service]\nType=simple\nExecStart=/usr/bin/python3 -I -u '+str(bundle/'ui/frame_headset_runtime.py')+'\n'
'Restart=no\nKillMode=control-group\nTimeoutStopSec=12\n')
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':digest[:16]})+'\n')
systemd('daemon-reload')
systemd('start','frame-control-headset.service')
result = show()
except Exception:
subprocess.run(['systemctl','--user','stop','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if bundle.exists(): shutil.rmtree(bundle)
if old.exists(): old.rename(bundle)
if previous_unit is None: unit.unlink(missing_ok=True)
else: unit.write_bytes(previous_unit)
if previous_receipt is None: receipt.unlink(missing_ok=True)
else: receipt.write_bytes(previous_receipt)
subprocess.run(['systemctl','--user','daemon-reload'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if previous_unit: subprocess.run(['systemctl','--user','start','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
raise
if old.exists(): shutil.rmtree(old)
print(json.dumps({'message':'Installed Frame Control on the headset. '+result['message'],
'version':digest[:16],'overlay':overlay}))
finally:
upload.unlink(missing_ok=True)
if stage.exists(): shutil.rmtree(stage)
'''
def bundle():
"""Pack app code shipped with the desktop app; never include user data."""
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode='w') as archive:
for dirname in ARCHIVE_DIRS:
folder = ROOT / dirname
if not folder.is_dir():
continue
for path in sorted(folder.rglob('*')):
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
continue
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
continue
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
info.size, info.mtime, info.uid, info.gid = len(data), 0, 0, 0
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
archive.addfile(info, io.BytesIO(data))
with io.BytesIO() as out:
with gzip.GzipFile(fileobj=out, mode='wb', mtime=0) as gz:
gz.write(raw.getvalue())
data = out.getvalue()
if len(data) > MAX_ARCHIVE:
raise ValueError('Headset app bundle is too large')
return data
def status(ssh):
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
def change(ssh, action):
if action not in ('install', 'update', 'open', 'close', 'remove'):
raise ValueError('Invalid headset panel action')
current = status(ssh)
if action in ('install', 'update', 'open') and (current['arch'] != 'aarch64' or current['variant'] != 'vr'):
raise ValueError('This feature needs a Steam Frame running Frame OS')
data = bundle() if action in ('install', 'update') else None
digest = hashlib.sha256(data or b'').hexdigest()
token = secrets.token_hex(12)
remote = '/tmp/frame-control-headset-' + token
try:
if data is not None:
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
return json.loads(ssh(f'python3 - {action} {token} {digest}', stdin=APPLY, timeout=100))
finally:
if data is not None:
try: ssh('rm -f ' + remote, timeout=10)
except Exception: pass
+149
View File
@@ -0,0 +1,149 @@
"""Run Frame Control's opt-in headset panel as one bounded user service.
This process owns only its loopback server and Chromium profile. Steam, SteamVR,
gamescope, other browser profiles, and the user's extensions are never stopped.
"""
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
BASE = Path.home() / '.local/share/frame-control/headset'
PORT = 47813
APPID = 2001947813
OVERLAY = f'valve.steam.desktopgame.{APPID}'
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
TITLE = '[fc-headset]'
class RuntimeError_(RuntimeError):
pass
def browser_command():
chrome = Path.home() / 'chromium-xr/chrome'
if chrome.is_file() and os.access(chrome, os.X_OK):
return [str(chrome)]
if Path('/usr/bin/chromium').is_file():
return ['/usr/bin/chromium']
if subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
return ['flatpak', 'run', 'org.chromium.Chromium']
raise RuntimeError_('Chromium is needed on the Frame to show the headset panel')
def command(*args):
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
if result.returncode:
raise RuntimeError_((result.stderr or result.stdout).strip()[-400:] or f'{args[0]} failed')
return result.stdout
def panel_window():
for line in command('xwininfo', '-root', '-children').splitlines():
match = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-headset\]', line)
if match:
return match[1]
return None
def wait_for_server(proc):
deadline = time.monotonic() + 15
while time.monotonic() < deadline and proc.poll() is None:
try:
with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/headset', timeout=.5) as response:
if response.status == 200 and TITLE.encode() in response.read(200_000):
return
except (OSError, urllib.error.URLError):
pass
time.sleep(.2)
raise RuntimeError_('The headset loopback server did not start')
def terminate_owned(proc):
if proc and proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
def serve():
bundle = BASE / 'bundle/ui'
key = os.urandom(24).hex()
BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
state_file = BASE / 'state.json'
env = {**os.environ, 'FRAME_LOCAL': '1', 'FRAME_DEVICE': 'headset',
'FRAME_HEADSET_RUNTIME': '1', 'FRAME_UI_KEY': key}
env.pop('WAYLAND_DISPLAY', None)
env['DISPLAY'] = ':0'
env['LD_PRELOAD'] = '' # Steam's overlay preload crashes Chromium's zygote.
server = browser = None
stopping = False
def stop(*_):
nonlocal stopping
stopping = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
command('xprop', '-root', 'GAMESCOPE_FOCUSABLE_WINDOWS')
with (BASE / 'server.log').open('ab') as log:
server = subprocess.Popen([sys.executable, '-I', '-u', '-B', str(bundle / 'server.py'),
'--port', str(PORT)], env=env, stdin=subprocess.DEVNULL,
stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
wait_for_server(server)
browser = subprocess.Popen([*browser_command(), '--ozone-platform=x11',
'--user-data-dir=' + str(BASE / 'profile'),
'--no-first-run', '--no-default-browser-check',
'--password-store=basic', '--disable-session-crashed-bubble',
'--noerrdialogs', '--window-size=1200,800',
'--app=http://127.0.0.1:%d/headset#%s' % (PORT, key)],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
deadline = time.monotonic() + 30
win = None
while time.monotonic() < deadline and browser.poll() is None and not stopping:
win = panel_window()
if win:
break
time.sleep(.25)
if not win:
raise RuntimeError_('The Frame Control browser window did not appear')
command('xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(APPID))
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
if OVERLAY in command(VRCMD, '--overlays'):
break
time.sleep(.25)
else:
raise RuntimeError_('SteamVR did not register the Frame Control panel')
state_file.write_text(json.dumps({'port': PORT, 'window': win, 'overlay': OVERLAY,
'started': time.time()}) + '\n')
state_file.chmod(0o600)
print('Frame Control headset panel registered as ' + OVERLAY, flush=True)
while not stopping and server.poll() is None and browser.poll() is None:
time.sleep(.5)
finally:
state_file.unlink(missing_ok=True)
terminate_owned(browser)
terminate_owned(server)
if __name__ == '__main__':
try:
serve()
except (OSError, RuntimeError_, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
+38
View File
@@ -0,0 +1,38 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Frame Control VR [fc-headset]</title>
<style>
:root{color-scheme:dark;font:24px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
*{box-sizing:border-box}body{margin:0;padding:34px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
p{margin:0 0 22px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.card{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:24px;min-height:158px}
.label{display:block;color:#b9c9d6;font-size:20px}.value{display:block;font-size:38px;font-weight:700;margin-top:14px}
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:15px 24px;cursor:pointer}
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}#message{min-height:1.5em;margin-top:18px}
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:20px}}
</style></head><body>
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Open this panel from the dashboard at any time.</p>
<div class="cards">
<div class="card"><span class="label">Battery</span><span class="value" id="battery">…</span></div>
<div class="card"><span class="label">Home storage free</span><span class="value" id="storage">…</span></div>
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
</div>
<p id="message" role="status" aria-live="polite"></p>
<button id="refresh">Refresh status</button>
<script>
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
if(key)sessionStorage.setItem('frameHeadsetKey',key);
history.replaceState(null,'',location.pathname);
const byId=id=>document.getElementById(id);
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
async function refresh(){try{const status=await api('/api/status');
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
const free=status.disk?.home?.free;
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
}catch(error){byId('message').textContent=error.message;}}
byId('refresh').onclick=refresh;refresh();
</script></body></html>
+40 -3
View File
@@ -1436,7 +1436,7 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a><a href="#headsetPanel"><span>Frame Control in VR</span><small>Open tools inside the headset</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="extensions">
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
@@ -1457,6 +1457,14 @@
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
<div class="hint">Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.</div>
</section>
<section class="panel" id="headsetPanel">
<div class="shelf-head"><h2>Frame Control in VR</h2><span class="spacer"></span><button class="small" id="headsetRefresh">Refresh</button></div>
<p class="sub">Open a Frame Control panel in SteamVR's dashboard. The panel runs on the Frame through a loopback server and an opt-in user service.</p>
<div id="headsetState" class="sub">Checking…</div>
<label class="popt"><input type="checkbox" id="headsetAck"> I want to install this local headset panel. Closing or removing it leaves SteamVR and my extensions running.</label>
<div class="row" style="margin-top:10px"><button class="small action" id="headsetInstall" disabled>Install &amp; open</button><button class="small" id="headsetUpdate" disabled>Update</button><button class="small" id="headsetOpen" disabled>Show in SteamVR</button><button class="small" id="headsetClose" disabled>Close</button><button class="small danger" id="headsetRemove" disabled>Remove</button></div>
<p class="hint">Open SteamVR's dashboard to find Frame Control. Remove stops its user service and removes its app bundle; your extension folders and browser profile stay on the Frame.</p>
</section>
<section class="panel" id="wireless" hidden>
<h2>Wireless Adapter doctor</h2>
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
@@ -2194,6 +2202,34 @@ for (const action of ['Install', 'Update', 'Remove']) {
loadDecky();
};
}
let headsetInfo = null;
async function loadHeadsetPanel() {
try {
const d = headsetInfo = await api('/api/headset');
$('headsetState').textContent = `${d.os || 'Unknown Frame OS'} · ${d.arch} · ${d.managed ? `installed ${d.version || ''}` : 'not installed'} · ${d.running ? d.overlayRegistered ? 'SteamVR panel registered' : 'service starting' : 'stopped'}`;
} catch (error) { headsetInfo = null; $('headsetState').textContent = error.message; }
headsetButtons();
}
function headsetButtons() {
const d = headsetInfo, ready = !!d && d.arch === 'aarch64' && d.variant === 'vr';
$('headsetInstall').disabled = !ready || !!d.managed || !$('headsetAck').checked;
$('headsetUpdate').disabled = !ready || !d.managed || !$('headsetAck').checked;
$('headsetOpen').disabled = !ready || !d.managed;
$('headsetClose').disabled = !d?.running;
$('headsetRemove').disabled = !d?.managed || !$('headsetAck').checked;
}
$('headsetAck').onchange = headsetButtons;
$('headsetRefresh').onclick = loadHeadsetPanel;
for (const action of ['Install', 'Update', 'Open', 'Close', 'Remove']) {
$(`headset${action}`).onclick = async () => {
if (!headsetInfo) return;
const verb = action.toLowerCase();
if (verb === 'remove' && !confirm('Remove the Frame Control headset user service and app bundle? Your extensions and browser profile will stay.')) return;
await runJob(`${action} Frame Control in VR`, 'headset-panel',
() => api('/api/headset', {action:verb, acknowledged:$('headsetAck').checked}));
loadHeadsetPanel();
};
}
window.addEventListener('message', event => {
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
const request = event.data;
@@ -6178,10 +6214,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools"};
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools", headsetPanel:"tools"};
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader"};
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader", headsetPanel:"Frame Control in VR"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -6290,6 +6326,7 @@ function showPage(e) {
if (tool === 'panels') loadPanelSwitcher();
if (tool === 'extensions') loadExtensions();
if (tool === 'decky') loadDecky();
if (tool === 'headsetPanel') loadHeadsetPanel();
if (tool === 'backup') loadBackups();
if (tool === 'storage') loadStorage();
if (tool === 'powerReport') loadPower();
+20
View File
@@ -80,6 +80,7 @@ import frame_wired_doctor # noqa: E402
import frame_compat_db # noqa: E402
import frame_extensions # noqa: E402
import frame_decky # noqa: E402
import frame_headset # noqa: E402
frame_host.trust_bundled_cas()
@@ -559,6 +560,17 @@ def decky(body):
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
def headset_action(body):
if LOCAL:
raise Failure("Manage the headset panel from Frame Control on your computer", 400)
action = body.get("action")
if action not in ("install", "update", "open", "close", "remove"):
raise Failure("Choose install, update, open, close, or remove", 400)
if action in ("install", "update", "remove") and body.get("acknowledged") is not True:
raise Failure("Confirm the opt-in headset installation or removal", 400)
return start_job("Headset panel " + action, lambda: frame_headset.change(ssh, action))
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
@@ -3332,6 +3344,7 @@ POST = {
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
"/api/headset": headset_action,
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
@@ -3515,6 +3528,9 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/headset":
# Dedicated, large-control UI for the opt-in SteamVR panel.
self.send_bytes((HERE / "headset.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
@@ -3534,6 +3550,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(frame_extensions.list_extensions())
elif path == "/api/decky":
self.send_json(frame_decky.status(ssh))
elif path == "/api/headset":
if LOCAL:
raise Failure("Manage this panel from Frame Control on your computer", 400)
self.send_json(frame_headset.status(ssh))
elif path.startswith("/extension/"):
parts = path.split("/")
if len(parts) != 4: