Compare commits

..
9 Commits
Author SHA1 Message Date
Alex SouthwellandSAPHID a29fac2594 Bump Frame Control to 0.4.29 (#204)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:21:10 +00:00
Alex SouthwellandSAPHID 6d5cb20847 Document ProtonDB Badges test on Steam Frame (#202)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:11:01 +00:00
Alex Southwell 987c36c5f6 Merge pull request #201 from saphid/fleet/se9k61wn4f-headset-actions
Add headset quick actions and extension panels
2026-10-25 04:10:29 +00:00
Alex Southwell 649185237f Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui
Add removable Frame Control panel for SteamVR
2026-10-25 04:09:48 +00:00
SAPHID 0cb085dc50 Check POSIX screenshot mode only on POSIX 2026-10-25 03:56:24 +00:00
SAPHID 61db62edcf Make headset screenshot test portable 2026-10-25 03:52:23 +00:00
SAPHID b0a65129f1 Add headset quick actions and extension panels 2026-10-25 03:47:30 +00:00
SAPHID d44b6121ae Use POSIX member paths in headset bundle on Windows 2026-10-25 03:39:53 +00:00
SAPHID af6f97ca5a Add removable Frame Control headset panel 2026-10-25 03:33:58 +00:00
17 changed files with 855 additions and 36 deletions

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 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.28",
"version": "0.4.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.28",
"version": "0.4.29",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.28",
"version": "0.4.29",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+4 -3
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.28**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.29**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
@@ -19,7 +19,8 @@ Current for Frame Control **v0.4.28**, 25 October 2026. “Real Frame + physical
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; no packaged physical-PC extension check recorded | No | Third-party extensions and Windows/macOS extension folders were not tested on physical hosts | 25 Oct, v0.4.27: [PR #194](https://github.com/saphid/frame-control/pull/194), [extension guide](extensions.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed | No | A usable Decky menu in the VR shell and individual plugin compatibility remain unverified | 25 Oct, v0.4.27: [PR #195](https://github.com/saphid/frame-control/pull/195), [headset findings](decky-frame.md) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; ProtonDB Badges loaded in Decky backend and Steam shared UI registry | No | Decky and plugin UI in the VR shell, wearer-visible badges, controller input and other plugins remain unverified | 25 Oct, v0.4.29: [PR #195](https://github.com/saphid/frame-control/pull/195), [PR #202](https://github.com/saphid/frame-control/pull/202), [headset findings](decky-frame.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
+36 -4
View File
@@ -4,7 +4,8 @@
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
verified. Frame Control therefore labels its user-service manager experimental.
No individual Decky plugin has been tested on the headset.
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
VR user interface has not been seen or used.
## What was checked on the real headset
@@ -35,6 +36,33 @@ remained in Steam's running web helper until that helper was restarted. After a
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
temporary trial files and the test's empty managed data folder were removed.
## ProtonDB Badges compatibility test
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
was tested on the same Frame OS build. It is a popular, passive Decky plugin
(133 GitHub stars at test time) that adds badges to game pages. The published
archive SHA-256 was
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
Its six archive entries had no links or path traversal, and its manifest declared
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
no backend `main.py`; its source backend only manages its own settings.
The test copied the archive into Frame Control's managed Decky plugin folder,
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
overlay. The headset was in standby, so no in-headset plugin page or badge
interaction was verified. The backend's unauthenticated root URL returned
HTTP 403, as expected.
The test removed the managed Decky service and binary, the test plugin folder,
and its shared-device lock. The service was no longer running, the plugin list
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
already injected frontend may remain in the running Steam web helper until
Steam normally restarts; the test did not restart Steam or SteamVR.
## Why the official Decky installer is not used
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
@@ -94,6 +122,10 @@ Control refuses to install alongside an upstream system service.
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
Quick Access Menu after an opt-in install. If none appears, inspect whether
the VR shell exposes a different menu component and whether Decky's frontend
can target it without patching Steam globally. Then test one harmless plugin's
install, visible UI, restart persistence and removal. Until those checks pass,
Frame Control should not describe Decky plugins as supported on the Frame.
can target it without patching Steam globally. Confirm the plugin's visible
UI, controller interaction, and persistence across a normal Steam restart.
Until those checks pass,
Frame Control should not describe Decky plugins as usable in VR yet. A worn
headset check is needed for both the Decky menu and the Frame Control SteamVR
panel; only then can their controller interaction and plugin presentation be
confirmed.
+6 -2
View File
@@ -16,8 +16,12 @@ and **Open Headset Glance**. The folder is normally:
- Windows: `%APPDATA%\Frame Control\extensions`
To remove it, close the panel and delete only its `headset-glance` folder. The
extension is local to this computer; the iPhone app reads the folder on the
Frame, where its server runs.
desktop app reads the folder on its computer. The iPhone app and the opt-in
[SteamVR headset panel](headset-app.md) read the folder on the Frame. To use an
extension in VR, copy its folder to
`steamos@frame:~/.local/share/frame-control/extensions/`, then select
**Extensions → Refresh extensions → Open** in the headset panel. The iframe,
read-only bridge and asset rules are the same there.
## Make an extension
+84
View File
@@ -0,0 +1,84 @@
# 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. It can request
SteamVR's main Game Mode dashboard, list and force quit a running game after an
unsaved-progress confirmation, save an OpenVR screenshot to
`~/Pictures/FrameControl`, and record the SteamVR mirror to
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
work folder from desktop Frame Control's recorder; they may have no audio if
the monitor source is unavailable. Captures made in standby can be dark.
The page also discovers extensions from
`~/.local/share/frame-control/extensions` and opens them in sandboxed iframes.
It offers the version 1 read-only `capabilities` and `status` bridge from
[the extension guide](extensions.md). Installing an extension still means
copying its folder to the Frame; the panel does not download third-party code.
The 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.
A second test on the same build installed the expanded panel with the
`headset-glance` example extension. The headset's authenticated loopback API
returned live status (99% battery), discovered and served the extension, and
listed no running games. The Game Mode dashboard request succeeded; an invalid
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
reported an audio source. The per-run UI key was absent from the server log.
After another wake and dashboard request, SteamVR still reported the panel
`not_visible`. No wearer-visible screenshot or controller interaction was
obtained. The test removed its example extension and captured media, then
uninstalled the service. The unit, port, bundle and overlay were absent after
removal; Steam and SteamVR remained running.
## 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()
+47
View File
@@ -0,0 +1,47 @@
"""Local headset actions keep writes scoped to the opt-in VR server."""
import sandbox # noqa: F401
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_clips
import server
class HeadsetActions(unittest.TestCase):
def test_quick_actions_are_unavailable_outside_headset_runtime(self):
with patch.object(server, 'HEADSET_RUNTIME', False):
with self.assertRaisesRegex(server.Failure, 'local VR panel'):
server.headset_quick({'action':'game-mode'})
def test_game_mode_targets_steamvr_main_dashboard(self):
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server.frame_panels, 'focus') as focus:
response = server.headset_quick({'action':'game-mode'})
focus.assert_called_once_with('valve.steam.gamepadui.main')
self.assertIn('Game Mode', response['message'])
def test_screenshot_is_saved_only_in_user_pictures(self):
with tempfile.TemporaryDirectory() as home, patch.object(server.Path, 'home', return_value=Path(home)), \
patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server, 'headset_view', return_value=b'PNG data'):
result = server.headset_quick({'action':'screenshot'})
file = Path(result['file'])
self.assertEqual(file.parent, Path(home) / 'Pictures/FrameControl')
self.assertEqual(file.read_bytes(), b'PNG data')
if os.name != 'nt': # Windows reports emulated mode bits for user files.
self.assertEqual(file.stat().st_mode & 0o777, 0o600)
def test_local_recording_uses_separate_work_folder(self):
recorder = frame_clips.Recorder(Mock(), Mock(), Mock(), Mock(),
work='.cache/frame-control/headset-video',
destination_label='~/Videos/FrameControl')
self.assertIn('headset-video', frame_clips.probe_command(recorder.work))
self.assertIn('headset-video', frame_clips.record_command('rec-20261025-030000', False, work=recorder.work))
self.assertNotIn('headset-video', frame_clips.probe_command())
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"):
+30 -15
View File
@@ -77,7 +77,7 @@ def recording_name(now=None):
return time.strftime("rec-%Y%m%d-%H%M%S", time.localtime(now))
def probe_command():
def probe_command(work=WORK):
"""Checks before a recording, run on the Frame: prints audio=1 or audio=0.
One recording runs at a time, so anything left in the folder is from one that was
@@ -85,7 +85,7 @@ def probe_command():
return (f"set -u; export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; "
f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"command -v ffmpeg >/dev/null || {{ echo 'ffmpeg is missing on the Frame' >&2; exit 3; }}; "
f"d=\"$HOME/{WORK}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
f"d=\"$HOME/{work}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
f"free=$(df -Pk \"$d\" | awk 'NR==2 {{print $4}}'); "
f"[ \"${{free:-0}}\" -ge {NEED_KB} ] || {{ echo 'Not enough free space on the Frame to record: "
f"it needs {NEED_KB // 1024 // 1024 + 1} GB' >&2; exit 4; }}; "
@@ -93,7 +93,7 @@ def probe_command():
f">/dev/null 2>&1; then echo audio=1; else echo audio=0; fi")
def record_command(name, audio, max_seconds=MAX_SECONDS):
def record_command(name, audio, max_seconds=MAX_SECONDS, work=WORK):
"""The recording, run on the Frame through `bash -c`.
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
@@ -118,7 +118,7 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
# ffmpeg's stdin is a FIFO the shell holds open on fd 3 (opening it waits for both
# ends, so ffmpeg is started first); it's unlinked once both ends are open.
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{work}\"; "
f"trap '' PIPE; c=\"$d/{name}.ctl\"; rm -f \"$c\"; mkfifo \"$c\" || exit 1; "
f"ffmpeg -hide_banner -loglevel error -y {inputs} {maps} {codecs} -t {max_seconds} "
f"\"$d/{name}.mkv\" <\"$c\" >/dev/null 2>\"$d/{name}.log\" & p=$!; "
@@ -131,20 +131,20 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
return "bash -c " + shlex.quote(script)
def finish_command(name):
def finish_command(name, work=WORK):
"""After the recording: make the .mp4 the computer gets (no re-encode), on the Frame."""
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return (f"cd \"$HOME/{WORK}\" || exit 1; "
return (f"cd \"$HOME/{work}\" || exit 1; "
f"[ -s {name}.mkv ] || {{ tail -c 2000 {name}.log >&2 2>/dev/null; echo 'The recording is empty' >&2; exit 1; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -y -i {name}.mkv -map 0 -c copy -movflags +faststart {name}.mp4 "
f"&& rm -f {name}.mkv && wc -c < {name}.mp4")
def cleanup_command(name):
def cleanup_command(name, work=WORK):
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return f"rm -f \"$HOME/{WORK}/{name}\".*"
return f"rm -f \"$HOME/{work}/{name}\".*"
class Recorder:
@@ -158,10 +158,12 @@ class Recorder:
release(proc) forgets a channel once it has closed
"""
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None):
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None,
work=WORK, destination_label='~/Videos/SteamFrame'):
self.open_channel, self.run, self.fetch = open_channel, run, fetch
self.start_thread, self.release = start_thread, release
self.folder = Path(folder) if folder else VIDEOS_DIR
self.work, self.destination_label = work, destination_label
self._lock = threading.Lock()
self._state = {"state": "idle"}
self._proc = None
@@ -179,11 +181,12 @@ class Recorder:
raise ClipError("Already recording, or still saving the last recording", 409)
self._state = {"state": "starting"}
try:
out = self.run(probe_command(), 30)
out = self.run(probe_command() if self.work == WORK else probe_command(self.work), 30)
audio = "audio=1" in out
name = recording_name()
errors = tempfile.TemporaryFile()
proc = self.open_channel(record_command(name, audio), errors)
proc = self.open_channel(record_command(name, audio) if self.work == WORK else
record_command(name, audio, work=self.work), errors)
lines = _line_reader(proc.stdout)
first = _next_line(lines, 20)
if first != "started":
@@ -222,6 +225,18 @@ class Recorder:
_close(proc.stdin) # the shell on the Frame sees EOF and stops ffmpeg
return {"state": "stopping", "message": "Stopping the recording…"}
def shutdown(self, timeout=30):
"""Finish an owned recording before a headset user service exits."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
state = self.status()['state']
if state == 'recording':
self.stop()
elif state not in ('starting', 'stopping', 'saving'):
break
time.sleep(.2)
return self.status()
def _watch(self, proc, name, audio, lines, errors):
# Waits for ffmpeg to finish: after Stop, or at the time limit (a margin
# past it, so a stuck channel can't hold the recording open forever).
@@ -242,14 +257,14 @@ class Recorder:
self._state = dict(self._state, state="saving")
try:
self.save(name)
msg = f"Saved {name}.mp4 to ~/Videos/SteamFrame" + ("" if audio else " (no sound)")
msg = f"Saved {name}.mp4 to {self.destination_label}" + ("" if audio else " (no sound)")
state = {"state": "saved", "file": str(self.folder / f"{name}.mp4"), "audio": audio,
"seconds": seconds, "message": msg}
except Exception as e:
state = {"state": "failed", "error": f"Couldn't save the recording: {e}"}
finally:
try:
self.run(cleanup_command(name), 20)
self.run(cleanup_command(name) if self.work == WORK else cleanup_command(name, self.work), 20)
except Exception:
pass # the next recording's checks remove it
with self._lock:
@@ -257,12 +272,12 @@ class Recorder:
def save(self, name):
"""Make the .mp4 on the Frame and copy it here, never leaving a partial file in the folder."""
out = self.run(finish_command(name), 600)
out = self.run(finish_command(name) if self.work == WORK else finish_command(name, self.work), 600)
size = int(out.strip().splitlines()[-1]) if out.strip() else 0
_make_room(self.folder, size)
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=self.folder))
try:
self.fetch(f"{WORK}/{name}.mp4", incoming / f"{name}.mp4")
self.fetch(f"{self.work}/{name}.mp4", incoming / f"{name}.mp4")
os.replace(incoming / f"{name}.mp4", self.folder / f"{name}.mp4")
finally:
shutil.rmtree(incoming, ignore_errors=True)
+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=45\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, timeout=5):
if proc and proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=timeout)
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, timeout=35) # allow the local recorder to finalize its MP4
if __name__ == '__main__':
try:
serve()
except (OSError, RuntimeError_, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
+97
View File
@@ -0,0 +1,97 @@
<!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:22px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
*{box-sizing:border-box}body{margin:0;padding:30px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
h2{font-size:29px;margin:25px 0 10px}p{margin:0 0 18px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.card,.block{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:20px}.card{min-height:140px}
.label{display:block;color:#b9c9d6;font-size:19px}.value{display:block;font-size:34px;font-weight:700;margin-top:10px}
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.stack{display:grid;gap:12px}
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:13px 20px;cursor:pointer;min-height:62px}
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disabled{opacity:.55;cursor:default}
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
</style></head><body>
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</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>
<div class="row"><button id="refresh">Refresh status</button><button id="gameMode" class="secondary">Return to Game Mode</button>
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
<p id="actionStatus" role="status" aria-live="polite"></p>
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
<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);
let latestStatus=null, activeExtension=null, recordBusy=false;
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=latestStatus=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){latestStatus=null;byId('message').textContent=error.message;}}
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
async function refreshGames(){const list=byId('games');try{const data=await api('/api/running');list.replaceChildren();
for(const game of data.running){const row=document.createElement('div');row.className='block game';
const name=document.createElement('strong');name.textContent=game.name||`App ${game.appid}`;
const quit=document.createElement('button');quit.className='danger';quit.textContent=`Force quit ${name.textContent}`;
quit.onclick=async()=>{if(!confirm(`Force quit ${name.textContent}? Unsaved progress may be lost.`))return;
quit.disabled=true;try{const result=await api('/api/running',{action:'quit',appid:String(game.appid)});
byId('actionStatus').textContent=result.message||`Quit ${name.textContent}`;await refreshGames();}
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
async function refreshRecord(){try{const data=await api('/api/record');
byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording';
byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state);
if(data.state==='recording')byId('actionStatus').textContent=`Recording ${Math.round(data.elapsed||0)} s${data.audio?' with sound':' without sound'}`;
else if(data.state==='saved'||data.state==='failed')byId('actionStatus').textContent=data.message||data.error;
}catch(error){byId('record').disabled=true;byId('actionStatus').textContent=error.message;}}
async function toggleRecord(){if(recordBusy)return;recordBusy=true;byId('record').disabled=true;
try{const state=await api('/api/record');const action=state.state==='recording'?'stop':'start';
const result=await api('/api/record',{action});byId('actionStatus').textContent=result.message||result.state;}
catch(error){byId('actionStatus').textContent=error.message;}finally{recordBusy=false;refreshRecord();}}
async function refreshExtensions(){const list=byId('extensions');try{const data=await api('/api/extensions');
byId('extensionsFolder').textContent=`Folder: ${data.folder}`;list.replaceChildren();
for(const ext of data.extensions){const row=document.createElement('div');row.className='block game';
const label=document.createElement('span');label.textContent=`${ext.name} · ${ext.description}`;
const open=document.createElement('button');open.textContent=`Open ${ext.name}`;open.onclick=()=>{
activeExtension=ext.id;byId('extensionTitle').textContent=ext.name;
byId('extensionFrame').src=`/extension/${encodeURIComponent(ext.id)}/index.html`;
byId('extensionPanel').hidden=false;byId('extensionPanel').scrollIntoView();};
row.append(label,open);list.append(row);}if(!data.extensions.length)list.textContent='No extensions installed.';
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
}catch(error){list.textContent=error.message;}}
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return;
const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return;
let value;if(request.action==='capabilities')value={apiVersion:1,actions:['capabilities','status']};
else if(request.action==='status')value={connected:!!latestStatus,
os:{version:latestStatus?.os?.version||null,build:latestStatus?.os?.build||null},
battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr};
else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');});
refresh();refreshGames();refreshRecord();refreshExtensions();
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
</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();
+81 -4
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()
@@ -88,6 +89,7 @@ HERE = Path(__file__).resolve().parent
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
HEADSET_RUNTIME = LOCAL and os.environ.get("FRAME_HEADSET_RUNTIME") == "1"
if LOCAL:
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
@@ -559,6 +561,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
@@ -827,6 +840,32 @@ recorder = frame_clips.Recorder(_record_channel, lambda cmd, timeout: ssh(cmd, t
lambda fn: busy_thread(fn).start(), release=lambda proc: _live_tunnels.discard(proc))
def _headset_record_run(command, timeout):
result = subprocess.run(['bash', '-c', command], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=timeout)
if result.returncode:
raise frame_clips.ClipError((result.stderr or result.stdout).strip()[-600:] or 'Recording command failed')
return result.stdout
def _headset_record_channel(command, stderr):
return subprocess.Popen(['bash', '-c', command], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=stderr, start_new_session=True)
def _headset_record_fetch(relative, target):
source = Path.home() / relative
if source.parent != Path.home() / '.cache/frame-control/headset-video' or not source.is_file() or source.is_symlink():
raise frame_clips.ClipError('The headset recording file is missing')
shutil.copyfile(source, target)
headset_recorder = frame_clips.Recorder(
_headset_record_channel, _headset_record_run, _headset_record_fetch,
lambda fn: busy_thread(fn).start(), folder=Path.home() / 'Videos/FrameControl',
work='.cache/frame-control/headset-video', destination_label='~/Videos/FrameControl') if HEADSET_RUNTIME else None
def _clips_here():
if LOCAL:
raise Failure("Recordings are saved to a computer: use Frame Control on your computer", 400)
@@ -834,16 +873,42 @@ def _clips_here():
def record(body):
"""{"action": "start"|"stop"}: record what the headset shows, with its sound."""
_clips_here()
if not HEADSET_RUNTIME:
_clips_here()
action = body.get("action")
if action not in ("start", "stop"):
raise Failure("action must be start or stop", 400)
try:
return recorder.start() if action == "start" else recorder.stop()
owned_recorder = headset_recorder if HEADSET_RUNTIME else recorder
return owned_recorder.start() if action == "start" else owned_recorder.stop()
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
def headset_quick(body):
"""Narrow actions for the local SteamVR panel, never exposed to the phone server."""
if not HEADSET_RUNTIME:
raise Failure('Headset quick actions require the local VR panel', 400)
action = body.get('action')
if action == 'game-mode':
try:
frame_panels.focus('valve.steam.gamepadui.main')
except frame_panels.PanelError as error:
raise Failure(str(error), 502) from error
return {'message':'Asked SteamVR to show the Game Mode dashboard.'}
if action == 'screenshot':
data = headset_view()
folder = Path.home() / 'Pictures/FrameControl'
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
name = time.strftime('frame-%Y%m%d-%H%M%S-') + secrets.token_hex(3) + '.png'
path = folder / name
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as file:
file.write(data)
return {'message': f'Saved ~/Pictures/FrameControl/{name}. A sleeping headset may capture a dark frame.',
'file': str(path)}
raise Failure('Choose Game Mode or screenshot', 400)
def _clips_frame(args, timeout):
return steam_frame(*args, timeout=timeout, script="frame_clips.py")
@@ -3332,6 +3397,8 @@ POST = {
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
"/api/headset": headset_action,
"/api/headset/quick": headset_quick,
"/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 +3582,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 +3604,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:
@@ -3695,8 +3769,9 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query))
elif path == "/api/record":
_clips_here()
self.send_json(recorder.status())
if not HEADSET_RUNTIME:
_clips_here()
self.send_json((headset_recorder if HEADSET_RUNTIME else recorder).status())
elif path == "/api/clips":
self.send_json(list_clips())
elif path == "/api/autosync":
@@ -4024,6 +4099,8 @@ def main():
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_power.shutdown()
if HEADSET_RUNTIME:
headset_recorder.shutdown(timeout=30)
_stop_sync.set()
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes