diff --git a/README.md b/README.md index de288c5..f33d0c0 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/headset-app.md b/docs/headset-app.md new file mode 100644 index 0000000..a4e548a --- /dev/null +++ b/docs/headset-app.md @@ -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. diff --git a/tests/test_frame_headset.py b/tests/test_frame_headset.py new file mode 100644 index 0000000..9ebd750 --- /dev/null +++ b/tests/test_frame_headset.py @@ -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() diff --git a/tests/test_page_startup.py b/tests/test_page_startup.py index 2a9a22f..6649a63 100644 --- a/tests/test_page_startup.py +++ b/tests/test_page_startup.py @@ -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''' diff --git a/tests/test_server.py b/tests/test_server.py index 1b1693d..8a9b5b9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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"): diff --git a/ui/frame_headset.py b/ui/frame_headset.py new file mode 100644 index 0000000..08f2a35 --- /dev/null +++ b/ui/frame_headset.py @@ -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 diff --git a/ui/frame_headset_runtime.py b/ui/frame_headset_runtime.py new file mode 100644 index 0000000..d810e65 --- /dev/null +++ b/ui/frame_headset_runtime.py @@ -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) diff --git a/ui/headset.html b/ui/headset.html new file mode 100644 index 0000000..a7322b0 --- /dev/null +++ b/ui/headset.html @@ -0,0 +1,38 @@ +
+ +Your Frame, from inside SteamVR. Open this panel from the dashboard at any time.
+Use your own model endpoint or review a proposed MCP action.
Use your own model endpoint or review a proposed MCP action.
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.
+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.
+Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.
@@ -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(); diff --git a/ui/server.py b/ui/server.py index 5ae9b77..0de3255 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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/