mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 13:01:47 +01:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad7be10cdf | ||
|
|
d79efb4730 | ||
|
|
95643b7ea0 | ||
|
|
9dab523af9 | ||
|
|
a29fac2594 | ||
|
|
6d5cb20847 | ||
|
|
987c36c5f6 | ||
|
|
649185237f | ||
|
|
0cb085dc50 | ||
|
|
61db62edcf | ||
|
|
b0a65129f1 | ||
|
|
d44b6121ae | ||
|
|
af6f97ca5a |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -298,7 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, removal, and the local extension API |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
+36
-4
@@ -4,7 +4,8 @@
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
No individual Decky plugin has been tested on the headset.
|
||||
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
|
||||
VR user interface has not been seen or used.
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
@@ -35,6 +36,33 @@ remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## ProtonDB Badges compatibility test
|
||||
|
||||
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
|
||||
was tested on the same Frame OS build. It is a popular, passive Decky plugin
|
||||
(133 GitHub stars at test time) that adds badges to game pages. The published
|
||||
archive SHA-256 was
|
||||
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
|
||||
Its six archive entries had no links or path traversal, and its manifest declared
|
||||
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
|
||||
no backend `main.py`; its source backend only manages its own settings.
|
||||
|
||||
The test copied the archive into Frame Control's managed Decky plugin folder,
|
||||
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
|
||||
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
|
||||
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
|
||||
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
|
||||
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
|
||||
overlay. The headset was in standby, so no in-headset plugin page or badge
|
||||
interaction was verified. The backend's unauthenticated root URL returned
|
||||
HTTP 403, as expected.
|
||||
|
||||
The test removed the managed Decky service and binary, the test plugin folder,
|
||||
and its shared-device lock. The service was no longer running, the plugin list
|
||||
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
|
||||
already injected frontend may remain in the running Steam web helper until
|
||||
Steam normally restarts; the test did not restart Steam or SteamVR.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
@@ -94,6 +122,10 @@ Control refuses to install alongside an upstream system service.
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Then test one harmless plugin's
|
||||
install, visible UI, restart persistence and removal. Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as supported on the Frame.
|
||||
can target it without patching Steam globally. Confirm the plugin's visible
|
||||
UI, controller interaction, and persistence across a normal Steam restart.
|
||||
Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as usable in VR yet. A worn
|
||||
headset check is needed for both the Decky menu and the Frame Control SteamVR
|
||||
panel; only then can their controller interaction and plugin presentation be
|
||||
confirmed.
|
||||
+14
-2
@@ -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
|
||||
|
||||
@@ -30,11 +34,19 @@ The manifest ID must match the folder name:
|
||||
"id": "your-id",
|
||||
"name": "Your feature",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
`apiVersion` selects the Frame Control bridge contract, independently of your
|
||||
extension's own `version`. This app supports integer `1`; a missing field means
|
||||
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
|
||||
and their assets are not served. Declare it in new extensions so a future API
|
||||
change can fail clearly instead of running incompatible code. The runtime
|
||||
`capabilities` reply below reports the bridge version too.
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Frame Control in the headset
|
||||
|
||||
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
|
||||
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
|
||||
the headset, independent of Decky's Quick Access Menu. The panel starts only
|
||||
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
|
||||
service. No `sudo`, root filesystem change, or Steam client patch is needed.
|
||||
|
||||
The companion copies its bundled UI over the existing trusted SSH connection.
|
||||
On the Frame, `frame-control-headset.service` runs a loopback server and one
|
||||
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
|
||||
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
|
||||
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
|
||||
it again from SteamVR's dashboard. Closing the panel stops its browser and
|
||||
server. The service is not enabled at boot.
|
||||
|
||||
The page shows battery, free home storage and SteamVR status. It can request
|
||||
SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
unsaved-progress confirmation, save an OpenVR screenshot to
|
||||
`~/Pictures/FrameControl`, and record the SteamVR mirror to
|
||||
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
|
||||
work folder from desktop Frame Control's recorder; they may have no audio if
|
||||
the monitor source is unavailable. Captures made in standby can be dark.
|
||||
|
||||
The page also discovers extensions from
|
||||
`~/.local/share/frame-control/extensions` and opens them in sandboxed iframes.
|
||||
It offers the version 1 read-only `capabilities` and `status` bridge from
|
||||
[the extension guide](extensions.md). Installing an extension still means
|
||||
copying its folder to the Frame; the panel does not download third-party code.
|
||||
|
||||
The per-run UI key is passed in the URL fragment, removed from the address bar after load,
|
||||
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
|
||||
line and server log. The usual host/origin and anti-framing checks still apply.
|
||||
|
||||
## Real Frame test, 2026-10-25
|
||||
|
||||
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
|
||||
user service, served `/headset`, read live `/api/status` from the browser, and
|
||||
registered a mapped Chromium window with SteamVR under the dedicated overlay
|
||||
key. The server rejected a request with the wrong UI key. A read-only status
|
||||
probe reported the user service running and the overlay registered.
|
||||
|
||||
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
|
||||
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
|
||||
screenshot was a uniform dark frame. The documented temporary wake attempt
|
||||
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
|
||||
still left the overlay `not_visible`; the setting was restored to 5 and the
|
||||
dashboard hidden. The X window's `xwd` image was black too. These observations
|
||||
prove panel creation and page/API execution, **not wearer-visible pixels or
|
||||
laser/controller input**. A worn-headset check is still required before
|
||||
describing the panel as visually verified.
|
||||
|
||||
The test then removed the managed service and app bundle. Its unit became
|
||||
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
|
||||
battery was 100%. Steam and SteamVR were left running. The browser profile and
|
||||
extensions folder were deliberately kept.
|
||||
|
||||
A second test on the same build installed the expanded panel with the
|
||||
`headset-glance` example extension. The headset's authenticated loopback API
|
||||
returned live status (99% battery), discovered and served the extension, and
|
||||
listed no running games. The Game Mode dashboard request succeeded; an invalid
|
||||
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
|
||||
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
|
||||
reported an audio source. The per-run UI key was absent from the server log.
|
||||
After another wake and dashboard request, SteamVR still reported the panel
|
||||
`not_visible`. No wearer-visible screenshot or controller interaction was
|
||||
obtained. The test removed its example extension and captured media, then
|
||||
uninstalled the service. The unit, port, bundle and overlay were absent after
|
||||
removal; Steam and SteamVR remained running.
|
||||
|
||||
## Remove
|
||||
|
||||
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
|
||||
run:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
|
||||
```
|
||||
|
||||
That command removes only Frame Control's managed panel code. The Chromium
|
||||
profile remains in `~/.local/share/frame-control/headset/profile` so local
|
||||
extension data is not discarded. Inspect and delete that folder separately if
|
||||
you no longer want it. Third-party extensions are in
|
||||
`~/.local/share/frame-control/extensions` and are never removed by this tool.
|
||||
+30
-9
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Extension compatibility and filesystem boundaries, without running plugin code."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_extensions
|
||||
|
||||
|
||||
class ExtensionFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'extensions'
|
||||
self.root.mkdir()
|
||||
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def extension(self, name='test-panel', **fields):
|
||||
folder = self.root / name
|
||||
folder.mkdir(exist_ok=True)
|
||||
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
|
||||
'description': 'A read-only panel', 'entry': 'index.html', **fields}
|
||||
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
||||
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
|
||||
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
|
||||
return folder
|
||||
|
||||
def test_existing_and_explicit_v1_manifests_are_compatible(self):
|
||||
self.extension('legacy')
|
||||
self.extension('explicit', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
|
||||
[('explicit', 1), ('legacy', 1)])
|
||||
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
|
||||
(b'/* extension code */', 'text/javascript; charset=utf-8'))
|
||||
|
||||
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
|
||||
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
|
||||
with self.subTest(apiVersion=version):
|
||||
self.extension(apiVersion=version)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['extensions'], [])
|
||||
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
|
||||
frame_extensions.asset('test-panel', 'index.html')
|
||||
|
||||
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
|
||||
self.extension('future', apiVersion=2)
|
||||
self.extension('working', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
|
||||
self.assertEqual(result['errors'][0]['folder'], 'future')
|
||||
|
||||
def test_sample_is_discoverable_and_its_assets_are_served(self):
|
||||
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
|
||||
shutil.copytree(source, self.root / source.name)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual(result['extensions'][0]['id'], source.name)
|
||||
for asset in ('index.html', 'main.js', 'style.css'):
|
||||
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
|
||||
(source / asset).read_bytes())
|
||||
|
||||
def test_private_nested_and_traversal_paths_are_rejected(self):
|
||||
self.extension()
|
||||
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', name)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('../test-panel', 'main.js')
|
||||
|
||||
def test_oversized_assets_are_not_read(self):
|
||||
folder = self.extension()
|
||||
with (folder / 'large.js').open('wb') as output:
|
||||
output.truncate(5 * 1024 * 1024 + 1)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'large.js')
|
||||
|
||||
def test_symlinks_cannot_expose_files_outside_a_panel(self):
|
||||
folder = self.extension()
|
||||
outside = Path(self.tmp.name) / 'outside.js'
|
||||
outside.write_text('private file', encoding='utf-8')
|
||||
try:
|
||||
(folder / 'escape.js').symlink_to(outside)
|
||||
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable for this user')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'escape.js')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('alias', 'main.js')
|
||||
(folder / 'manifest.json').unlink()
|
||||
(folder / 'manifest.json').symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('test-panel', 'main.js')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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'''
|
||||
|
||||
@@ -126,6 +126,32 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('experimental', body['error'])
|
||||
|
||||
def test_incompatible_extension_is_reported_and_assets_are_unavailable(self):
|
||||
folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel'
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future',
|
||||
'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2}))
|
||||
(folder / 'index.html').write_text('<p>Incompatible panel</p>')
|
||||
try:
|
||||
self.assertEqual(self.request('GET', '/api/extensions')[0], 403)
|
||||
code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'})
|
||||
self.assertEqual(code, 200)
|
||||
data = json.loads(body)
|
||||
self.assertNotIn('future-panel', [x['id'] for x in data['extensions']])
|
||||
self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error']
|
||||
for x in data['errors']))
|
||||
self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404)
|
||||
finally:
|
||||
shutil.rmtree(folder)
|
||||
|
||||
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
@@ -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
@@ -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,6 +7,7 @@ import frame_host
|
||||
|
||||
|
||||
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
|
||||
API_VERSION = 1
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
||||
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8", ".json": "application/json",
|
||||
@@ -27,6 +28,11 @@ def _manifest(folder):
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or data.get("id") != folder.name:
|
||||
raise ValueError("manifest id must match its folder")
|
||||
# Older manifests predate this field and use bridge v1. Check again when
|
||||
# serving assets, so an incompatible panel cannot bypass discovery.
|
||||
api_version = data.get("apiVersion", 1)
|
||||
if type(api_version) is not int or api_version != API_VERSION:
|
||||
raise ValueError(f"unsupported apiVersion; this app supports {API_VERSION}")
|
||||
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
@@ -34,7 +40,8 @@ def _manifest(folder):
|
||||
entry = data.get("entry", "index.html")
|
||||
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
|
||||
raise ValueError("entry must be a regular index.html")
|
||||
return {key: data[key] for key in ("id", "name", "version", "description")}
|
||||
return {**{key: data[key] for key in ("id", "name", "version", "description")},
|
||||
"apiVersion": api_version}
|
||||
|
||||
|
||||
def list_extensions():
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,97 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Frame Control VR [fc-headset]</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;font:22px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
|
||||
*{box-sizing:border-box}body{margin:0;padding:30px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
|
||||
h2{font-size:29px;margin:25px 0 10px}p{margin:0 0 18px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.card,.block{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:20px}.card{min-height:140px}
|
||||
.label{display:block;color:#b9c9d6;font-size:19px}.value{display:block;font-size:34px;font-weight:700;margin-top:10px}
|
||||
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.stack{display:grid;gap:12px}
|
||||
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:13px 20px;cursor:pointer;min-height:62px}
|
||||
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disabled{opacity:.55;cursor:default}
|
||||
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
|
||||
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
|
||||
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
|
||||
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
||||
</style></head><body>
|
||||
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</p>
|
||||
<div class="cards">
|
||||
<div class="card"><span class="label">Battery</span><span class="value" id="battery">…</span></div>
|
||||
<div class="card"><span class="label">Home storage free</span><span class="value" id="storage">…</span></div>
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
<div class="row"><button id="refresh">Refresh status</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
||||
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
|
||||
<p id="actionStatus" role="status" aria-live="polite"></p>
|
||||
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
|
||||
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
|
||||
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
|
||||
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
|
||||
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
|
||||
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
|
||||
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
|
||||
<script>
|
||||
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
|
||||
if(key)sessionStorage.setItem('frameHeadsetKey',key);
|
||||
history.replaceState(null,'',location.pathname);
|
||||
const byId=id=>document.getElementById(id);
|
||||
let latestStatus=null, activeExtension=null, recordBusy=false;
|
||||
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
|
||||
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
|
||||
async function refresh(){try{const status=latestStatus=await api('/api/status');
|
||||
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
|
||||
const free=status.disk?.home?.free;
|
||||
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
|
||||
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
|
||||
}catch(error){latestStatus=null;byId('message').textContent=error.message;}}
|
||||
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
|
||||
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
|
||||
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
|
||||
async function refreshGames(){const list=byId('games');try{const data=await api('/api/running');list.replaceChildren();
|
||||
for(const game of data.running){const row=document.createElement('div');row.className='block game';
|
||||
const name=document.createElement('strong');name.textContent=game.name||`App ${game.appid}`;
|
||||
const quit=document.createElement('button');quit.className='danger';quit.textContent=`Force quit ${name.textContent}`;
|
||||
quit.onclick=async()=>{if(!confirm(`Force quit ${name.textContent}? Unsaved progress may be lost.`))return;
|
||||
quit.disabled=true;try{const result=await api('/api/running',{action:'quit',appid:String(game.appid)});
|
||||
byId('actionStatus').textContent=result.message||`Quit ${name.textContent}`;await refreshGames();}
|
||||
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
|
||||
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
async function refreshRecord(){try{const data=await api('/api/record');
|
||||
byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording';
|
||||
byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state);
|
||||
if(data.state==='recording')byId('actionStatus').textContent=`Recording ${Math.round(data.elapsed||0)} s${data.audio?' with sound':' without sound'}`;
|
||||
else if(data.state==='saved'||data.state==='failed')byId('actionStatus').textContent=data.message||data.error;
|
||||
}catch(error){byId('record').disabled=true;byId('actionStatus').textContent=error.message;}}
|
||||
async function toggleRecord(){if(recordBusy)return;recordBusy=true;byId('record').disabled=true;
|
||||
try{const state=await api('/api/record');const action=state.state==='recording'?'stop':'start';
|
||||
const result=await api('/api/record',{action});byId('actionStatus').textContent=result.message||result.state;}
|
||||
catch(error){byId('actionStatus').textContent=error.message;}finally{recordBusy=false;refreshRecord();}}
|
||||
async function refreshExtensions(){const list=byId('extensions');try{const data=await api('/api/extensions');
|
||||
byId('extensionsFolder').textContent=`Folder: ${data.folder}`;list.replaceChildren();
|
||||
for(const ext of data.extensions){const row=document.createElement('div');row.className='block game';
|
||||
const label=document.createElement('span');label.textContent=`${ext.name} · ${ext.description}`;
|
||||
const open=document.createElement('button');open.textContent=`Open ${ext.name}`;open.onclick=()=>{
|
||||
activeExtension=ext.id;byId('extensionTitle').textContent=ext.name;
|
||||
byId('extensionFrame').src=`/extension/${encodeURIComponent(ext.id)}/index.html`;
|
||||
byId('extensionPanel').hidden=false;byId('extensionPanel').scrollIntoView();};
|
||||
row.append(label,open);list.append(row);}if(!data.extensions.length)list.textContent='No extensions installed.';
|
||||
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
|
||||
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
|
||||
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
|
||||
window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return;
|
||||
const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return;
|
||||
let value;if(request.action==='capabilities')value={apiVersion:1,actions:['capabilities','status']};
|
||||
else if(request.action==='status')value={connected:!!latestStatus,
|
||||
os:{version:latestStatus?.os?.version||null,build:latestStatus?.os?.build||null},
|
||||
battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr};
|
||||
else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');});
|
||||
refresh();refreshGames();refreshRecord();refreshExtensions();
|
||||
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
|
||||
</script></body></html>
|
||||
+44
-6
@@ -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 & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & 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 & 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 & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & 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 & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
@@ -1457,6 +1457,14 @@
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
|
||||
<div class="hint">Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.</div>
|
||||
</section>
|
||||
<section class="panel" id="headsetPanel">
|
||||
<div class="shelf-head"><h2>Frame Control in VR</h2><span class="spacer"></span><button class="small" id="headsetRefresh">Refresh</button></div>
|
||||
<p class="sub">Open a Frame Control panel in SteamVR's dashboard. The panel runs on the Frame through a loopback server and an opt-in user service.</p>
|
||||
<div id="headsetState" class="sub">Checking…</div>
|
||||
<label class="popt"><input type="checkbox" id="headsetAck"> I want to install this local headset panel. Closing or removing it leaves SteamVR and my extensions running.</label>
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="headsetInstall" disabled>Install & open</button><button class="small" id="headsetUpdate" disabled>Update</button><button class="small" id="headsetOpen" disabled>Show in SteamVR</button><button class="small" id="headsetClose" disabled>Close</button><button class="small danger" id="headsetRemove" disabled>Remove</button></div>
|
||||
<p class="hint">Open SteamVR's dashboard to find Frame Control. Remove stops its user service and removes its app bundle; your extension folders and browser profile stay on the Frame.</p>
|
||||
</section>
|
||||
<section class="panel" id="wireless" hidden>
|
||||
<h2>Wireless Adapter doctor</h2>
|
||||
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
|
||||
@@ -1908,7 +1916,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>
|
||||
|
||||
@@ -2194,6 +2202,34 @@ for (const action of ['Install', 'Update', 'Remove']) {
|
||||
loadDecky();
|
||||
};
|
||||
}
|
||||
let headsetInfo = null;
|
||||
async function loadHeadsetPanel() {
|
||||
try {
|
||||
const d = headsetInfo = await api('/api/headset');
|
||||
$('headsetState').textContent = `${d.os || 'Unknown Frame OS'} · ${d.arch} · ${d.managed ? `installed ${d.version || ''}` : 'not installed'} · ${d.running ? d.overlayRegistered ? 'SteamVR panel registered' : 'service starting' : 'stopped'}`;
|
||||
} catch (error) { headsetInfo = null; $('headsetState').textContent = error.message; }
|
||||
headsetButtons();
|
||||
}
|
||||
function headsetButtons() {
|
||||
const d = headsetInfo, ready = !!d && d.arch === 'aarch64' && d.variant === 'vr';
|
||||
$('headsetInstall').disabled = !ready || !!d.managed || !$('headsetAck').checked;
|
||||
$('headsetUpdate').disabled = !ready || !d.managed || !$('headsetAck').checked;
|
||||
$('headsetOpen').disabled = !ready || !d.managed;
|
||||
$('headsetClose').disabled = !d?.running;
|
||||
$('headsetRemove').disabled = !d?.managed || !$('headsetAck').checked;
|
||||
}
|
||||
$('headsetAck').onchange = headsetButtons;
|
||||
$('headsetRefresh').onclick = loadHeadsetPanel;
|
||||
for (const action of ['Install', 'Update', 'Open', 'Close', 'Remove']) {
|
||||
$(`headset${action}`).onclick = async () => {
|
||||
if (!headsetInfo) return;
|
||||
const verb = action.toLowerCase();
|
||||
if (verb === 'remove' && !confirm('Remove the Frame Control headset user service and app bundle? Your extensions and browser profile will stay.')) return;
|
||||
await runJob(`${action} Frame Control in VR`, 'headset-panel',
|
||||
() => api('/api/headset', {action:verb, acknowledged:$('headsetAck').checked}));
|
||||
loadHeadsetPanel();
|
||||
};
|
||||
}
|
||||
window.addEventListener('message', event => {
|
||||
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
|
||||
const request = event.data;
|
||||
@@ -3944,7 +3980,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 +6215,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 +6327,7 @@ function showPage(e) {
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'extensions') loadExtensions();
|
||||
if (tool === 'decky') loadDecky();
|
||||
if (tool === 'headsetPanel') loadHeadsetPanel();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
|
||||
+81
-4
@@ -80,6 +80,7 @@ import frame_wired_doctor # noqa: E402
|
||||
import frame_compat_db # noqa: E402
|
||||
import frame_extensions # noqa: E402
|
||||
import frame_decky # noqa: E402
|
||||
import frame_headset # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -88,6 +89,7 @@ HERE = Path(__file__).resolve().parent
|
||||
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
|
||||
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
|
||||
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
|
||||
HEADSET_RUNTIME = LOCAL and os.environ.get("FRAME_HEADSET_RUNTIME") == "1"
|
||||
if LOCAL:
|
||||
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
|
||||
@@ -559,6 +561,17 @@ def decky(body):
|
||||
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
|
||||
|
||||
|
||||
def headset_action(body):
|
||||
if LOCAL:
|
||||
raise Failure("Manage the headset panel from Frame Control on your computer", 400)
|
||||
action = body.get("action")
|
||||
if action not in ("install", "update", "open", "close", "remove"):
|
||||
raise Failure("Choose install, update, open, close, or remove", 400)
|
||||
if action in ("install", "update", "remove") and body.get("acknowledged") is not True:
|
||||
raise Failure("Confirm the opt-in headset installation or removal", 400)
|
||||
return start_job("Headset panel " + action, lambda: frame_headset.change(ssh, action))
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
|
||||
@@ -827,6 +840,32 @@ recorder = frame_clips.Recorder(_record_channel, lambda cmd, timeout: ssh(cmd, t
|
||||
lambda fn: busy_thread(fn).start(), release=lambda proc: _live_tunnels.discard(proc))
|
||||
|
||||
|
||||
def _headset_record_run(command, timeout):
|
||||
result = subprocess.run(['bash', '-c', command], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise frame_clips.ClipError((result.stderr or result.stdout).strip()[-600:] or 'Recording command failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _headset_record_channel(command, stderr):
|
||||
return subprocess.Popen(['bash', '-c', command], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=stderr, start_new_session=True)
|
||||
|
||||
|
||||
def _headset_record_fetch(relative, target):
|
||||
source = Path.home() / relative
|
||||
if source.parent != Path.home() / '.cache/frame-control/headset-video' or not source.is_file() or source.is_symlink():
|
||||
raise frame_clips.ClipError('The headset recording file is missing')
|
||||
shutil.copyfile(source, target)
|
||||
|
||||
|
||||
headset_recorder = frame_clips.Recorder(
|
||||
_headset_record_channel, _headset_record_run, _headset_record_fetch,
|
||||
lambda fn: busy_thread(fn).start(), folder=Path.home() / 'Videos/FrameControl',
|
||||
work='.cache/frame-control/headset-video', destination_label='~/Videos/FrameControl') if HEADSET_RUNTIME else None
|
||||
|
||||
|
||||
def _clips_here():
|
||||
if LOCAL:
|
||||
raise Failure("Recordings are saved to a computer: use Frame Control on your computer", 400)
|
||||
@@ -834,16 +873,42 @@ def _clips_here():
|
||||
|
||||
def record(body):
|
||||
"""{"action": "start"|"stop"}: record what the headset shows, with its sound."""
|
||||
_clips_here()
|
||||
if not HEADSET_RUNTIME:
|
||||
_clips_here()
|
||||
action = body.get("action")
|
||||
if action not in ("start", "stop"):
|
||||
raise Failure("action must be start or stop", 400)
|
||||
try:
|
||||
return recorder.start() if action == "start" else recorder.stop()
|
||||
owned_recorder = headset_recorder if HEADSET_RUNTIME else recorder
|
||||
return owned_recorder.start() if action == "start" else owned_recorder.stop()
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
|
||||
def headset_quick(body):
|
||||
"""Narrow actions for the local SteamVR panel, never exposed to the phone server."""
|
||||
if not HEADSET_RUNTIME:
|
||||
raise Failure('Headset quick actions require the local VR panel', 400)
|
||||
action = body.get('action')
|
||||
if action == 'game-mode':
|
||||
try:
|
||||
frame_panels.focus('valve.steam.gamepadui.main')
|
||||
except frame_panels.PanelError as error:
|
||||
raise Failure(str(error), 502) from error
|
||||
return {'message':'Asked SteamVR to show the Game Mode dashboard.'}
|
||||
if action == 'screenshot':
|
||||
data = headset_view()
|
||||
folder = Path.home() / 'Pictures/FrameControl'
|
||||
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
name = time.strftime('frame-%Y%m%d-%H%M%S-') + secrets.token_hex(3) + '.png'
|
||||
path = folder / name
|
||||
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as file:
|
||||
file.write(data)
|
||||
return {'message': f'Saved ~/Pictures/FrameControl/{name}. A sleeping headset may capture a dark frame.',
|
||||
'file': str(path)}
|
||||
raise Failure('Choose Game Mode or screenshot', 400)
|
||||
|
||||
|
||||
def _clips_frame(args, timeout):
|
||||
return steam_frame(*args, timeout=timeout, script="frame_clips.py")
|
||||
|
||||
@@ -3332,6 +3397,8 @@ POST = {
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/decky": decky,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -3515,6 +3582,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/headset":
|
||||
# Dedicated, large-control UI for the opt-in SteamVR panel.
|
||||
self.send_bytes((HERE / "headset.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/store":
|
||||
# The Android store as a headset panel (scripts/store-on-frame.sh).
|
||||
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
|
||||
@@ -3534,6 +3604,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(frame_extensions.list_extensions())
|
||||
elif path == "/api/decky":
|
||||
self.send_json(frame_decky.status(ssh))
|
||||
elif path == "/api/headset":
|
||||
if LOCAL:
|
||||
raise Failure("Manage this panel from Frame Control on your computer", 400)
|
||||
self.send_json(frame_headset.status(ssh))
|
||||
elif path.startswith("/extension/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 4:
|
||||
@@ -3695,8 +3769,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/shots/image":
|
||||
self.send_bytes(*shot_image(url.query))
|
||||
elif path == "/api/record":
|
||||
_clips_here()
|
||||
self.send_json(recorder.status())
|
||||
if not HEADSET_RUNTIME:
|
||||
_clips_here()
|
||||
self.send_json((headset_recorder if HEADSET_RUNTIME else recorder).status())
|
||||
elif path == "/api/clips":
|
||||
self.send_json(list_clips())
|
||||
elif path == "/api/autosync":
|
||||
@@ -4024,6 +4099,8 @@ def main():
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
_power.shutdown()
|
||||
if HEADSET_RUNTIME:
|
||||
headset_recorder.shutdown(timeout=30)
|
||||
_stop_sync.set()
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
|
||||
Reference in new issue
Block a user