Compare commits

..
Author SHA1 Message Date
SAPHID f9320cda2a Reject malformed flags and linked plugin folders 2026-10-25 06:05:43 +00:00
SAPHID 399d02b1a5 Add managed Decky store plugins and Frame compatibility audit 2026-10-25 06:04:28 +00:00
Alex SouthwellandSAPHID d79efb4730 Bump Frame Control to 0.4.30 (#207)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:39:30 +00:00
Alex SouthwellandSAPHID 95643b7ea0 Support restricted split APKs in Android backup import (#206)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:29:21 +00:00
Alex SouthwellandSAPHID 9dab523af9 docs: distinguish current Frame OS checks from September notes (#205)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:13:12 +00:00
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
27 changed files with 1566 additions and 83 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Import a base APK with supported native-library or asset splits from a local
Android backup or XAPK/APKS/APKM bundle. Check package and version before
install; refuse resource, code and feature splits and device-variant sets.
This route has local fixture coverage, not a real split-game headset test.
- Save Steam screenshots under a separate folder for each headset, Steam
account and game. Equal screenshot filenames no longer overwrite one another;
existing files in the old flat folder are left in place.
+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 plugin author guide](docs/frame-plugin-author.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.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.28",
"version": "0.4.30",
"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.30",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+10 -4
View File
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
@@ -37,9 +37,13 @@ are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
This flow handles one base APK, supported native-library or asset splits, and
standard OBB files for one game at a time. It checks the package and version in
each split manifest before touching the headset. The existing Quest merger
rejects resource, code and feature splits and sets with multiple device
variants. A merged APK is re-signed; that can affect a game's licence or login.
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
@@ -55,3 +59,5 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
The supported-split backup route was checked with generated local fixtures and
existing merger tests, not a real split game on the Frame.
+11 -9
View File
@@ -436,13 +436,13 @@ permissions remain unverified. They do not change APK install or launch behavior
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
one base APK, optional supported splits and matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
@@ -450,7 +450,7 @@ sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
@@ -463,12 +463,14 @@ data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
**Limits:** this backup importer requires exactly one base APK. For split sets,
each split must identify the same package and version, and pass the existing
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
feature splits and does not choose among device variants. Merging re-signs the
APK, which can affect licences. It does not restore `Android/data`, private app
data, saves or entitlements. These split imports have local fixture and merger
tests, but no real split game has been installed with this backup route on the
Frame.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
+5 -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.30**, 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 |
|---|---|---|---|---|
@@ -17,9 +17,11 @@ Current for Frame Control **v0.4.28**, 25 October 2026. “Real Frame + physical
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
| 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) |
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
| 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.
+92 -6
View File
@@ -4,7 +4,59 @@
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.
## Official store plugin audit, 2026-10-25
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
reported `activity_level` 3 (standby). The battery was 99% before and after.
The test downloaded the official store's current archives, checked each SHA-256
against store metadata, and inspected their manifests and binaries. For seven
non-root candidates, the removable user-service loader started each plugin in
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
The service and test folders were removed after the run; the owned shared lock
was released. **A load message is not proof that a plugin's page, settings, or
action works in VR.** No plugin was tried by a wearer.
| Official store plugin (tested version) | Result | Evidence and limit |
| --- | --- | --- |
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
The table reflects the tested versions only. Other releases can change their
files or behavior. The official store's digest checks the archive in transit;
it does not certify plugin code. Plugins run with the headset user's access.
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
The new store transaction was also exercised on the same real Frame:
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
store update → store remove → managed loader remove**. Status reported the
managed store ID and version while the plugin was installed. The service
remained active after each store change. The battery was 99% before and after.
The test removed its plugin folder, loader, and owned lock. This validates the
store file and service path over SSH, not a visible VR plugin page.
Frame Control's desktop app and opt-in headset panel now show official store
entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
shows installed plugins and store actions, but plugin pages, settings, routes,
and Decky toasts are **not** available there yet. The panel itself still needs
a worn-headset visual and controller check.
## What was checked on the real headset
@@ -35,6 +87,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)
@@ -58,8 +137,8 @@ its published SHA-256 digest, and checks the ELF architecture. It installs a
officially supported. Plugins that require root, assume `~/homebrew`, or rely
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
plugins already present in its managed folder and the upstream `~/homebrew`
folder; it does not install plugins while their Frame UI and compatibility
are unverified.
folder. The opt-in store manager installs compatible archives only into its
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
The latest tested release was
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
@@ -75,6 +154,9 @@ notice, check the acknowledgement box, and choose **Install**. **Update**
downloads and verifies the latest ARM64 release again. **Remove managed
Decky** disables and removes only Frame Control's user service and binary.
It preserves plugin files and settings so unrelated user data is not deleted.
Open **Official plugin store** to review a plugin, then confirm each install,
update, or remove. Root and x86-only plugins are blocked. The manager only
removes plugin folders that it installed; it leaves other Decky folders alone.
Restart Steam or the headset to clear the frontend already injected into its
current web helper process. No action wipes the headset.
@@ -94,6 +176,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
+34
View File
@@ -0,0 +1,34 @@
# Write a plugin for Frame
There are two paths. Choose the one that matches your feature.
## Frame Control extension
Use a Frame Control extension for a panel, status view, or guide that does not
need to change the headset. Put `extension.json` and `index.html` in one folder
under `~/.local/share/frame-control/extensions`. The opt-in headset panel lists
the folder and opens it in a sandboxed iframe. The version 1 bridge has only
`capabilities` and read-only `status`. It cannot call Steam, change settings,
or run privileged code. See [the extension API](extensions.md) and its example.
## Decky plugin
Use a Decky plugin only if you need Decky's backend or Gamepad UI integration.
Build an official-format ZIP with one top-level folder, `plugin.json`, and a
frontend bundle in `dist/index.js`. A Python `main.py` backend is optional.
Test on ARM64. If you ship an ELF helper, build it for AArch64 (ELF machine
183). Avoid root, `sudo`, Steam Deck TDP controls, fixed x86 binaries, and
paths under `/etc`. Frame Control's managed loader is a normal user service;
it blocks root plugins and x86 archives. Keep data under your plugin folder or
the user's home directory, and provide a removal path.
The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access
Menu. A backend `Loaded` log only proves backend startup. Test any frontend
page, setting, notification, route, and controller input while wearing a Frame
before describing it as working in VR. Until a QAM bridge is verified, a Frame
Control extension is the practical route for a visible read-only VR panel.
To test safely, use a separate Frame Control managed Decky install, verify the
ZIP digest from the official store, install one plugin at a time, and inspect
`journalctl --user -u frame-control-decky.service`. Remove only your own
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
+92
View File
@@ -0,0 +1,92 @@
# 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 panel also lists Decky plugins and can install, update, or remove compatible
plugins from the official store when the opt-in managed loader is running.
Each change asks for confirmation. The Frame VR shell does not show Decky's
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
separates backend loading from VR use. Authors can use the
[Frame plugin and extension guide](frame-plugin-author.md).
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.
+30 -9
View File
@@ -1,10 +1,29 @@
# Open questions and on-device checks
Research as of 2026-09-25, eight days after the Frame's retail release
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
pages. Searches of Reddit and the Steam forums turned up **almost no
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
"not documented yet", not "doesn't work".
The September sections below record early checks after the Frame's retail
release (2026-09-18). They are a historical test log, not a current setup
guide. For current connection steps, see [SteamOS October 2026
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
[charge-limit.md](charge-limit.md).
## Current Frame OS 0.5.6 status (2026-10-25)
- `frame.local` no longer resolves on the checked 0.5.6 beta build
(`20261022.6153909`). Avahi was active but did not publish addresses. The
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
the same network after verifying the pinned SSH key, or a manual IP/SSH
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
- Read-only checks on that build found no SteamOS Manager
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
charging switches are root-owned and managed by Valve's charger service.
No charging control was written. An 80% limit is **not supported** by Frame
Control; see [charge-limit.md](charge-limit.md) and the
[headset check](evidence/round3-headset-2026-10-25.md).
- Active passthrough in the recording mirror remains **unknown**: the only
captured mirror frame was uniform while the headset was unworn in standby.
A worn, active passthrough check is still needed; see the
[headset check](evidence/round3-headset-2026-10-25.md).
## Verified on device (2026-09-25)
@@ -14,7 +33,9 @@ build 20260922.6101926, kernel 6.18, aarch64):
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
the hostname is `frame`.
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
- **3.** `frame.local` resolved from the Mac on the September build;
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
beta build; see the current status above.
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
@@ -148,9 +169,9 @@ check in the headset:
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
no backend in the Frame's device config, and the battery node is a
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
on a headset.
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
See [charge-limit.md](charge-limit.md).
## Unconfirmed claims made in these docs
+55 -3
View File
@@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_android_import as backup
import server
from apk_fixtures import apk, manifest
from test_quest import apk_bytes
PKG = 'com.Armature.VR4'
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
@@ -66,6 +67,41 @@ class BackupTests(unittest.TestCase):
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
base = self.root / 'not-named-base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'arm64.apk'
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}))
plan = backup.inspect([str(split), str(base)])
self.assertEqual(plan['apk'], base)
self.assertEqual(plan['splits'], [split])
self.assertEqual(backup.public(plan)['splits'], ['arm64.apk'])
with patch.object(backup.quest, '_running', return_value=False), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META) as install:
backup.install(plan)
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
base = self.root / 'base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'split.apk'
with patch.object(backup.android, 'ssh') as ssh:
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
files={'lib/arm64-v8a/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
files={'res/drawable/icon.png': b'png'}))
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
files={'lib/x86_64/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
backup.inspect([str(base), str(split)])
ssh.assert_not_called()
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
with patch.object(backup.android, 'read_meta', return_value=None):
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
@@ -204,6 +240,21 @@ class BundleTests(unittest.TestCase):
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
source = self.bundle({
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}),
}, suffix='.apks')
with patch.object(backup.android, 'ssh') as ssh:
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
ssh.assert_not_called()
self.assertEqual(read['plan']['apk'], 'base.apk')
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
folders = list(server._android_backups[read['token']]['dirs'])
server.android_backup({'action': 'discard', 'token': read['token']})
self.assertTrue(all(not Path(p).exists() for p in folders))
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
source = self.bundle()
token = server.android_backup({'action': 'begin'})['token']
@@ -221,7 +272,7 @@ class BundleTests(unittest.TestCase):
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
cases = [
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
@@ -229,7 +280,7 @@ class BundleTests(unittest.TestCase):
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
({'manifest.json': b'{}'}, 'standalone APK'),
({'manifest.json': b'{}'}, 'base APK'),
]
for entries, message in cases:
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
@@ -277,7 +328,8 @@ class BundleTests(unittest.TestCase):
('x' * len('isSplitRequired')).encode('utf-16-le'))
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
with self.subTest(attrs=attrs):
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.inspect([str(self.apk)])
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
+128
View File
@@ -0,0 +1,128 @@
"""Official Decky store transport and owned plugin transaction."""
import sandbox # noqa: F401
import hashlib
import io
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_decky_store
def archive(flags=(), extra=None):
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)}))
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
if extra:
bundle.writestr(*extra)
data = stream.getvalue()
return data, hashlib.sha256(data).hexdigest()
class ArchiveTests(unittest.TestCase):
def test_catalog_selects_valid_visible_latest_versions(self):
records = [{'id': 42, 'name': 'Example', 'visible': True, 'versions':
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
[{'name': '1.0', 'hash': 'b' * 64}]},
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
with self.assertRaisesRegex(ValueError, 'SHA-256'):
frame_decky_store.archive_info(data, '0' * 64, 'Example')
def test_rejects_root_x86_and_escape(self):
for data in (archive(flags=['root'])[0],
archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0],
archive(extra=('../outside', b'oops'))[0]):
with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]):
with self.assertRaises(ValueError):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
class StoreTransaction(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
home = Path(self.tmp.name)
unit = home / '.config/systemd/user/frame-control-decky.service'
unit.parent.mkdir(parents=True)
unit.write_text('[Service]\n')
fake = home / 'systemctl'
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n')
fake.chmod(0o700)
self.env = {**os.environ, 'HOME': self.tmp.name,
'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
self.folder.parent.mkdir(parents=True)
def apply(self, action, data=None):
token = secrets.token_hex(12)
source = Path('/tmp/frame-control-decky-plugin-' + token)
if data is not None:
source.write_bytes(data)
digest = hashlib.sha256(data or b'').hexdigest()
try:
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
input=frame_decky_store.APPLY, text=True, capture_output=True,
env=self.env, timeout=15)
finally:
source.unlink(missing_ok=True)
def test_install_update_remove_only_owned_folder(self):
first, _ = archive()
result = self.apply('install', first)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42)
second, _ = archive(extra=('Example/data.txt', 'new'))
result = self.apply('update', second)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.folder / 'data.txt').read_text(), 'new')
result = self.apply('remove')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.folder.exists())
self.folder.mkdir()
(self.folder / 'plugin.json').write_text('{}')
result = self.apply('remove')
self.assertNotEqual(result.returncode, 0)
self.assertTrue(self.folder.exists())
external = Path(self.tmp.name) / 'other-plugin'
external.mkdir()
(external / '.frame-control-store.json').write_text('{"storeId":42}')
for item in self.folder.iterdir(): item.unlink()
self.folder.rmdir()
self.folder.symlink_to(external, target_is_directory=True)
result = self.apply('remove')
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
class StoreHost(unittest.TestCase):
def test_owned_plugin_can_be_removed_without_store_network(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
def ssh(command, **kwargs):
return '{}'
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog') as network:
frame_decky_store.change(ssh, 'remove', 42)
network.assert_not_called()
if __name__ == '__main__':
unittest.main()
+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"):
+58 -14
View File
@@ -131,10 +131,30 @@ def unpack_bundle(path, root):
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
paths = [str(local) for local, _, _, _ in entries]
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
raise android.FrameError('Android bundles must contain a base APK and optional supported splits or OBB files.')
return paths
def _apk_identity(path):
"""Use the manifest, not the filename, to identify a base or split APK."""
try:
with zipfile.ZipFile(path) as archive:
elements = frame_apk.manifest_elements(
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
attrs = next((attrs for tag, attrs in elements if tag == 'manifest'), {})
package = (attrs.get('package') or (None, None, None))[2]
split = (attrs.get('split') or (None, None, None))[2]
code = (attrs.get('versionCode') or (None, None, None))[1]
major = (attrs.get('versionCodeMajor') or (None, 0, None))[1]
if not package or not android.PKG_RE.fullmatch(package):
raise android.FrameError(f'{path.name}: no valid package in APK manifest')
return package, split, (major, code) if code is not None else None
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, UnicodeError,
ValueError, RuntimeError, EOFError, zlib.error, struct.error,
IndexError) as error:
raise android.FrameError(f'Could not read APK manifest in {path.name}: {error}') from error
def _check_standalone(path):
try:
with zipfile.ZipFile(path) as archive:
@@ -168,19 +188,41 @@ def inspect(paths, discover=False):
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
apks = [p for p in files if p.suffix.lower() == '.apk']
if len(apks) > 1:
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
apk = apks[0] if apks else None
apk, splits = None, []
if apks:
identities = [(p, *_apk_identity(p)) for p in apks]
bases = [p for p, _, split, _ in identities if not split]
if len(bases) != 1:
raise android.FrameError('Choose one game at a time: exactly one base APK is required.')
apk = bases[0]
package, version = next((pkg, code) for p, pkg, _, code in identities if p == apk)
split_names = set()
for path, pkg, split, code in identities:
if path == apk:
continue
if pkg != package or split in split_names:
raise android.FrameError('Split APKs must have the same package and distinct split names.')
if version is None or code is None or code != version:
raise android.FrameError('Split APK version cannot be verified against the base APK.')
split_names.add(split)
splits.append(path)
if apk:
_check_standalone(apk)
if not splits:
_check_standalone(apk)
info = android.apk_info(str(apk))
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
checked = quest.inspect(info['package'], str(apk))
if not splits:
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
if splits:
added = android.split_entries(str(apk), [str(p) for p in splits])
if any(name.startswith('lib/') and not name.startswith('lib/arm64-v8a/') for name in added):
raise android.FrameError('This split set includes native libraries for another device. '
'Choose only the arm64-v8a split for the Frame.')
checked = quest.inspect(info['package'], str(apk), [str(p) for p in splits])
pkg = checked['package']
if discover and len(files) == 1:
files.extend(p for p in sorted(apk.parent.iterdir())
@@ -213,13 +255,14 @@ def inspect(paths, discover=False):
if not path.is_file() or not path.stat().st_size or path.name in names:
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
names.add(path.name)
return {'apk': apk, 'obb': obb, 'checked': checked,
return {'apk': apk, 'splits': splits, 'obb': obb, 'checked': checked,
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
def public(plan):
checked = plan['checked']
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
'splits': [p.name for p in plan['splits']],
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
'size': sum(size for size, _ in plan['signatures'].values())}
@@ -243,7 +286,8 @@ def install(plan, display='auto', progress=None):
quest._check_downgrade(android.read_meta(pkg), checked, False)
report('Installing the game app', 15)
meta = android.install(str(plan['apk']), source=plan['apk'].name,
flatscreen=None if display == 'auto' else display == 'flat')
flatscreen=None if display == 'auto' else display == 'flat',
splits=[str(p) for p in plan['splits']])
else:
meta = android._meta_or_fail(pkg)
if not plan['obb']:
+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)
+7 -1
View File
@@ -31,8 +31,14 @@ def plugins(folder):
if info.is_symlink() or not info.is_file() or info.stat().st_size > 16384: continue
try:
data = json.loads(info.read_text())
marker = child / '.frame-control-store.json'
try:
owned = json.loads(marker.read_text()) if marker.is_file() and not marker.is_symlink() else {}
except (OSError, ValueError): owned = {}
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
'author': str(data.get('author', ''))[:80]})
'author': str(data.get('author', ''))[:80],
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
'version': str(owned.get('version', ''))[:40]})
except (ValueError, OSError, UnicodeError): pass
return found
os_release = {}
+197
View File
@@ -0,0 +1,197 @@
"""Verified official-store archives for the opt-in Frame Decky user service.
Only Frame Control owned plugin folders can be updated or removed. The store
digest validates transport, not plugin trust; store plugins run as the user.
"""
import hashlib
import io
import json
import re
import secrets
import urllib.request
import zipfile
import frame_decky
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/'
MAX_STORE = 4 * 1024 * 1024
MAX_ZIP = 50 * 1024 * 1024
MAX_UNPACKED = 150 * 1024 * 1024
SAFE_FOLDER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z')
SHA = re.compile(r'[0-9a-f]{64}\Z')
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
def catalog():
request = urllib.request.Request(STORE_URL, headers={'User-Agent': 'Frame-Control'})
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read(MAX_STORE + 1)
if len(raw) > MAX_STORE:
raise ValueError('Decky store response is too large')
records = json.loads(raw)
if not isinstance(records, list):
raise ValueError('Invalid Decky store response')
result = []
for item in records:
if not isinstance(item, dict) or item.get('visible') is False:
continue
versions = item.get('versions')
if type(item.get('id')) is not int or item['id'] <= 0 or not isinstance(item.get('name'), str) or not isinstance(versions, list):
continue
latest = next((v for v in versions if isinstance(v, dict) and isinstance(v.get('name'), str)
and VERSION.fullmatch(v['name']) and isinstance(v.get('hash'), str) and SHA.fullmatch(v['hash'])), None)
if not latest:
continue
tags = item.get('tags') if isinstance(item.get('tags'), list) else []
result.append({'id': item['id'], 'name': item['name'][:100],
'description': str(item.get('description') or '')[:300],
'version': latest['name'][:40], 'sha256': latest['hash'],
'root': 'root' in tags})
return result
def archive_info(data, expected_hash, expected_name):
if not 1024 < len(data) <= MAX_ZIP or hashlib.sha256(data).hexdigest() != expected_hash:
raise ValueError('Plugin archive size or SHA-256 did not match the store')
with zipfile.ZipFile(io.BytesIO(data)) as archive:
files = archive.infolist()
if not files or len(files) > 3000 or sum(f.file_size for f in files) > MAX_UNPACKED:
raise ValueError('Plugin archive exceeds the file or size limit')
names = [f.filename for f in files]
manifests = [n for n in names if n.count('/') == 1 and n.endswith('/plugin.json')]
if len(manifests) != 1:
raise ValueError('Plugin archive needs one top-level manifest')
folder = manifests[0].split('/')[0]
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Invalid plugin folder')
for entry in files:
name = entry.filename
mode = entry.external_attr >> 16
if ('\\' in name or name.startswith('/') or '..' in name.split('/') or
name.split('/')[0] != folder or entry.file_size > 30 * 1024 * 1024 or
mode & 0o170000 == 0o120000):
raise ValueError('Unsafe plugin archive member')
if entry.is_dir():
continue
with archive.open(entry) as source:
header = source.read(20)
if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183:
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
manifest = json.loads(archive.read(manifests[0]))
if manifest.get('name') != expected_name:
raise ValueError('Plugin name differs from the official store')
flags = manifest.get('flags', [])
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
raise ValueError('Invalid plugin flags')
if 'root' in flags or '_root' in flags:
raise ValueError('This plugin needs root and is not supported by the Frame user service')
return folder
APPLY = r'''
import hashlib,json,pathlib,shutil,subprocess,sys,zipfile
action,token,plugin_id,digest,folder,version = sys.argv[1:]
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
target=base/folder
archive=pathlib.Path('/tmp/frame-control-decky-plugin-'+token)
stage=base/('.frame-control-stage-'+token)
backup=base/('.frame-control-old-'+token)
marker='.frame-control-store.json'
def owned():
if target.is_symlink() or not target.is_dir(): return False
try: return json.loads((target/marker).read_text()).get('storeId') == int(plugin_id)
except (OSError,ValueError): return False
def service(*args):
p=subprocess.run(['systemctl','--user',*args],capture_output=True,text=True,timeout=25)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'Decky service failed')
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
try:
if action!='remove':
data=archive.read_bytes()
if not 1024<len(data)<=50*1024*1024 or hashlib.sha256(data).hexdigest()!=digest:
raise RuntimeError('Plugin archive digest mismatch on Frame')
with zipfile.ZipFile(archive) as z:
files=z.infolist()
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
raise RuntimeError('Plugin archive too large')
if any(f.filename.split('/')[0]!=folder or f.filename.startswith('/') or
'..' in f.filename.split('/') or '\\' in f.filename or
f.file_size>30*1024*1024 or f.external_attr>>16&0o170000==0o120000 for f in files):
raise RuntimeError('Unsafe plugin archive')
stage.mkdir(mode=0o700)
z.extractall(stage)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
if running: service('stop','frame-control-decky.service')
try:
if action!='install': target.rename(backup)
if action!='remove': (stage/folder).rename(target)
if running: service('start','frame-control-decky.service')
except Exception:
if target.exists(): shutil.rmtree(target)
if backup.exists(): backup.rename(target)
if running: subprocess.run(['systemctl','--user','start','frame-control-decky.service'],timeout=25)
raise
if backup.exists(): shutil.rmtree(backup)
past={'install':'Installed','update':'Updated','remove':'Removed'}[action]
print(json.dumps({'message':f'{past} {folder}. Plugin UI in VR is not yet verified.'}))
finally:
archive.unlink(missing_ok=True)
if stage.exists(): shutil.rmtree(stage)
'''
def change(ssh, action, plugin_id):
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
raise ValueError('Choose a valid store plugin action')
state = frame_decky.status(ssh)
if not state['managed'] or not state['running']:
raise ValueError('Start Frame Control managed Decky before changing plugins')
installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id]
if action == 'remove':
if len(installed) != 1:
raise ValueError('Only an installed Frame Control store plugin can be removed')
folder = installed[0]['folder']
plugin = {'sha256': '0' * 64, 'version': 'removed'}
else:
plugin = next((item for item in catalog() if item['id'] == plugin_id), None)
if not plugin:
raise ValueError('Plugin is not in the official Decky store')
if plugin['root']:
raise ValueError('Root plugins are not supported by the Frame user service')
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
if action == 'install' and existing_name:
raise ValueError('A plugin with this name is already installed')
if action == 'update' and len(installed) != 1:
raise ValueError('Only an installed Frame Control store plugin can be updated')
data = None
if action != 'remove':
url = CDN + plugin['sha256'] + '.zip'
with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response:
data = response.read(MAX_ZIP + 1)
folder = archive_info(data, plugin['sha256'], plugin['name'])
if action == 'update' and installed[0]['folder'] != folder:
raise ValueError('The store changed its plugin folder; remove the old version before installing')
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Unsafe installed plugin folder')
token = secrets.token_hex(12)
remote = '/tmp/frame-control-decky-plugin-' + 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} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}',
stdin=APPLY, timeout=90))
finally:
if data is not None:
try: ssh('rm -f ' + remote, timeout=10)
except Exception: pass
+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)
+130
View File
@@ -0,0 +1,130 @@
<!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>Decky plugins</h2><p>Manage official store plugins here. Their backend can run on Frame, but Decky's Steam Deck Quick Access Menu is absent from this VR shell. Plugin pages, settings, routes, and toasts are not yet available in this panel.</p>
<div class="row"><button id="deckyRefresh" class="secondary">Refresh plugins</button><button id="deckyStoreLoad" class="secondary">Open official store</button></div>
<p id="deckyState" role="status"></p><div id="deckyInstalled" class="stack"></div>
<div id="deckyStorePanel" hidden><label>Find a plugin <input id="deckySearch" type="search" placeholder="Search Decky store"></label><div id="deckyStoreList" class="stack"></div></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;}}
let deckyState=null, deckyStore=[];
async function refreshDecky(){try{deckyState=await api('/api/decky');
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.';
const list=byId('deckyInstalled');list.replaceChildren();
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
if(plugin.storeId){const button=document.createElement('button');button.className='danger';button.textContent='Remove';
button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
async function loadDeckyStore(){try{const data=await api('/api/decky/store');deckyStore=data.plugins;
byId('deckyStorePanel').hidden=false;showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
const q=byId('deckySearch').value.trim().toLowerCase();
for(const plugin of deckyStore.filter(p=>!q||`${p.name} ${p.description}`.toLowerCase().includes(q)).slice(0,30)){
const row=document.createElement('div');row.className='block game';
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version} · ${plugin.description}`;
const button=document.createElement('button');button.textContent='Install';
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
button.disabled=!deckyState?.running||plugin.root;
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
async function deckyChange(action,plugin){if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
byId('deckyState').textContent=`${action} ${plugin.name}…`;
try{const job=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
byId('deckyState').textContent=job.message||'Request queued. Refresh after it finishes.';
setTimeout(refreshDecky,5000);}catch(error){byId('deckyState').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('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=showDeckyStore;
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();refreshDecky();
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
</script></body></html>
+103 -8
View File
@@ -1373,7 +1373,7 @@
<div class="store-owned" id="storeOwned">
<section class="panel" id="androidBackup">
<div class="shelf-head"><h2>Import Android game</h2></div>
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
<p class="sub">Have a backup? Choose the game's base APK, supported native-library or asset splits, and all its .obb files together, or one XAPK/APKS/APKM bundle. Resource, code and feature splits and Android/data restore are not supported here.
Frame Control installs the app and puts its data in the right place.</p>
<button class="action small" id="androidBackupChoose">Choose game files…</button>
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" multiple hidden>
@@ -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>
@@ -1453,9 +1453,23 @@
<p class="sub">Experimental. Decky's ARM64 backend can start on Frame OS, but its plugin menu has not been verified in the VR shell. Some Decky plugins need root and will not work with this user service.</p>
<div id="deckyState" class="sub">Checking…</div>
<div id="deckyPlugins" class="hint"></div>
<div id="deckyManagedList" class="stack"></div>
<label class="popt"><input type="checkbox" id="deckyAck"> I understand that the VR menu and individual plugins may not work. Install only if I want to test Decky.</label>
<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>
<h3>Official plugin store</h3>
<p class="sub">Plugins run code as your Frame user. Store installs are opt-in and only work with the managed loader. Root plugins and x86-only archives are blocked. A loaded backend does not prove its VR page works.</p>
<label>Find a plugin <input id="deckyStoreSearch" type="search" placeholder="Search store"></label>
<div class="row"><button class="small" id="deckyStoreRefresh">Load store</button></div>
<div id="deckyStoreState" class="hint"></div><div id="deckyStoreList" class="stack"></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>
@@ -1908,7 +1922,7 @@
</main>
</div>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
<span class="sub">Android backups: drop a base APK with supported splits and OBB files, or one XAPK/APKS/APKM bundle.
Other files go to <code>~/Downloads</code>;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
@@ -2167,14 +2181,62 @@ $('extensionClose').onclick = () => {
$('extensionPanel').hidden = true;
};
let deckyInfo = null;
let deckyCatalog = [];
async function loadDecky() {
try {
const d = deckyInfo = await api('/api/decky');
$('deckyState').textContent = `${d.frameOS || 'Unknown'} ${d.variant || ''} · ${d.arch} · CEF ${d.cef ? 'ready' : 'unavailable'} · ${d.managed ? `managed Decky ${d.version || ''} ${d.running ? 'running' : 'stopped'}` : d.upstreamInstall ? 'upstream Decky installation found' : 'Decky not installed'}`;
const plugins = [...d.plugins.map(x => `${x.name} (managed)`), ...d.upstreamPlugins.map(x => `${x.name} (upstream)`)];
$('deckyPlugins').textContent = plugins.length ? `Installed plugins: ${plugins.join(', ')}` : 'No Decky plugins found.';
const managedList=$('deckyManagedList');managedList.replaceChildren();
for(const plugin of d.plugins||[]){if(!plugin.storeId)continue;
const row=document.createElement('div');row.className='hint';
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
remove.disabled=!d.running||!$('deckyAck').checked;
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
row.append(label,remove);managedList.append(row);}
deckyButtons();
} catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); }
showDeckyStore();
} catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); showDeckyStore(); }
}
async function loadDeckyStore() {
$('deckyStoreState').textContent = 'Loading the official store…';
try { deckyCatalog = (await api('/api/decky/store')).plugins;
$('deckyStoreState').textContent = `${deckyCatalog.length} plugins in the official store. Select a plugin to install it.`;
showDeckyStore();
} catch(error) { $('deckyStoreState').textContent = error.message; }
}
function showDeckyStore() {
const list = $('deckyStoreList'); list.replaceChildren();
const query = $('deckyStoreSearch').value.trim().toLowerCase();
const installed = [...(deckyInfo?.plugins||[]), ...(deckyInfo?.upstreamPlugins||[])];
for (const plugin of deckyCatalog.filter(p => !query || `${p.name} ${p.description}`.toLowerCase().includes(query)).slice(0, 40)) {
const row = document.createElement('div'); row.className = 'hint';
const title = document.createElement('strong'); title.textContent = `${plugin.name} · ${plugin.version}`;
const description = document.createElement('span'); description.textContent = ` ${plugin.description}`;
const entry = installed.find(p => p.storeId === plugin.id);
const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id);
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
row.append(title, description);
if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); }
else {
const button=document.createElement('button'); button.className='small';
button.textContent=entry ? (entry.version===plugin.version?'Reinstall':'Update') : 'Install';
button.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin);
row.append(button);
if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove';
remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);}
}
list.append(row);
}
}
async function changeDeckyPlugin(action, plugin) {
if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return;
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true}));
await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; }
}
function deckyButtons() {
const ready = !!deckyInfo && deckyInfo.arch === 'aarch64' && deckyInfo.variant === 'vr' && deckyInfo.cef && !deckyInfo.upstreamInstall;
@@ -2183,8 +2245,11 @@ function deckyButtons() {
$('deckyUpdate').disabled = !ack || !ready || !deckyInfo.managed;
$('deckyRemove').disabled = !ack || !deckyInfo?.managed;
}
$('deckyAck').onchange = deckyButtons;
$('deckyAck').onchange = () => { deckyButtons(); showDeckyStore();
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; };
$('deckyRefresh').onclick = loadDecky;
$('deckyStoreRefresh').onclick = loadDeckyStore;
$('deckyStoreSearch').oninput = showDeckyStore;
for (const action of ['Install', 'Update', 'Remove']) {
$(`decky${action}`).onclick = async () => {
if (!$('deckyAck').checked || !deckyInfo) return;
@@ -2194,6 +2259,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;
@@ -3944,7 +4037,8 @@ async function importAndroidFiles(files, dev, probe = false) {
if (!dlg.open) return true;
const plan = read.plan;
$("androidImportName").textContent = `${plan.label}${plan.version ? " · " + plan.version : ""}`;
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>`
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>` +
(plan.splits || []).map(f => `<div>${esc(f)} <span class="sub">split APK</span></div>`).join("")
: `<div class="sub">Game app already installed</div>`) +
`<div class="sub" style="margin-top:12px">Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}</div>` +
plan.obb.map(f => `<div>${esc(f.name)} <span class="sub">${gb(f.size)}</span></div>`).join("");
@@ -6178,10 +6272,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 +6384,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();
+96 -4
View File
@@ -80,6 +80,8 @@ import frame_wired_doctor # noqa: E402
import frame_compat_db # noqa: E402
import frame_extensions # noqa: E402
import frame_decky # noqa: E402
import frame_decky_store # noqa: E402
import frame_headset # noqa: E402
frame_host.trust_bundled_cas()
@@ -88,6 +90,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 +562,28 @@ def decky(body):
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
def decky_plugin(body):
action = body.get('action')
plugin_id = body.get('id')
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int:
raise Failure('Choose an official store plugin and action', 400)
if body.get('acknowledged') is not True:
raise Failure('Confirm that store plugins run code as your Frame user', 400)
return start_job('Decky plugin ' + action,
lambda: frame_decky_store.change(ssh, action, plugin_id))
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 +852,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 +885,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 +3409,9 @@ POST = {
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
"/api/decky/plugin": decky_plugin,
"/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 +3595,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 +3617,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(frame_extensions.list_extensions())
elif path == "/api/decky":
self.send_json(frame_decky.status(ssh))
elif path == "/api/decky/store":
self.send_json({'plugins': frame_decky_store.catalog()})
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 +3784,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 +4114,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