Compare commits

..
Author SHA1 Message Date
Alex SouthwellandSAPHID d79efb4730 Bump Frame Control to 0.4.30 (#207)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:39:30 +00:00
Alex SouthwellandSAPHID 95643b7ea0 Support restricted split APKs in Android backup import (#206)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:29:21 +00:00
Alex SouthwellandSAPHID 9dab523af9 docs: distinguish current Frame OS checks from September notes (#205)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:13:12 +00:00
Alex SouthwellandSAPHID a29fac2594 Bump Frame Control to 0.4.29 (#204)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:21:10 +00:00
Alex SouthwellandSAPHID 6d5cb20847 Document ProtonDB Badges test on Steam Frame (#202)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:11:01 +00:00
Alex Southwell 987c36c5f6 Merge pull request #201 from saphid/fleet/se9k61wn4f-headset-actions
Add headset quick actions and extension panels
2026-10-25 04:10:29 +00:00
Alex Southwell 649185237f Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui
Add removable Frame Control panel for SteamVR
2026-10-25 04:09:48 +00:00
Alex SouthwellandSAPHID 5b43170fda Bump Frame Control to 0.4.28 (#203)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:59:02 +00:00
Alex SouthwellandSAPHID 958743c79d Docs: guide SteamOS OBS host setup with test limits (#200)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:47:21 +00:00
Alex SouthwellandSAPHID 05308932e8 Docs: distinguish PC SteamVR stream failures from SSH (#199)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:46:57 +00:00
Alex SouthwellandSAPHID 4e4853e82b Docs: explain streaming lag and Japanese setup (#197)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:41:41 +00:00
Alex SouthwellandSAPHID 3438c248d9 Bump Frame Control to 0.4.27 (#196)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:24:52 +00:00
17 changed files with 350 additions and 51 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Import a base APK with supported native-library or asset splits from a local
Android backup or XAPK/APKS/APKM bundle. Check package and version before
install; refuse resource, code and feature splits and device-variant sets.
This route has local fixture coverage, not a real split-game headset test.
- Save Steam screenshots under a separate folder for each headset, Steam
account and game. Equal screenshot filenames no longer overwrite one another;
existing files in the old flat folder are left in place.
+9 -1
View File
@@ -148,7 +148,7 @@ Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
@@ -245,6 +245,14 @@ network. Guest Wi-Fi/client isolation can prevent discovery and connections.
check Developer Mode, the address and network isolation, then run setup again.
For connections away from home, see [Tailscale](docs/tailscale.md).
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
cable has not been tested for the Frame's USB network. See the
[connection FAQ and Japanese quick start](docs/connection-faq.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
games in `~/devkit-game`), and nothing
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.26",
"version": "0.4.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.26",
"version": "0.4.30",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.26",
"version": "0.4.30",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+10 -4
View File
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
@@ -37,9 +37,13 @@ are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
This flow handles one base APK, supported native-library or asset splits, and
standard OBB files for one game at a time. It checks the package and version in
each split manifest before touching the headset. The existing Quest merger
rejects resource, code and feature splits and sets with multiple device
variants. A merged APK is re-signed; that can affect a game's licence or login.
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
@@ -55,3 +59,5 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
The supported-split backup route was checked with generated local fixtures and
existing merger tests, not a real split game on the Frame.
+11 -9
View File
@@ -436,13 +436,13 @@ permissions remain unverified. They do not change APK install or launch behavior
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
one base APK, optional supported splits and matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
@@ -450,7 +450,7 @@ sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
@@ -463,12 +463,14 @@ data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
**Limits:** this backup importer requires exactly one base APK. For split sets,
each split must identify the same package and version, and pass the existing
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
feature splits and does not choose among device variants. Merging re-signs the
APK, which can affect licences. It does not restore `Android/data`, private app
data, saves or entitlements. These split imports have local fixture and merger
tests, but no real split game has been installed with this backup route on the
Frame.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
+37
View File
@@ -0,0 +1,37 @@
# Connection and streaming lag FAQ
## Does Frame Control lower my streaming lag?
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
game stream. Frame Control does not carry the game stream. It cannot lower your
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
game is not a Frame Control measurement.
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
route to the Frame. Use the route that lets the app connect. To improve game
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
The [wired connection doctor](wired-connection.md) checks only Frame Control's
SSH route. A successful doctor check does not prove that the game stream uses
the cable or has low lag.
## Can I use a USB-A-to-C cable?
A Mac reached the Frame over a USB-C connection on the
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
cable might show a USB network interface on your PC, but this is unknown on
the tested Frame OS and PC hosts. Check whether your PC shows that interface
and whether Frame Control's wired doctor can reach the Frame at
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
for Frame Control if the USB route does not work.
## 日本語: 5 ステップで接続
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
+6 -1
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.26**, 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 |
|---|---|---|---|---|
@@ -10,13 +10,18 @@ Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
| 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; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; ProtonDB Badges loaded in Decky backend and Steam shared UI registry | No | Decky and plugin UI in the VR shell, wearer-visible badges, controller input and other plugins remain unverified | 25 Oct, v0.4.29: [PR #195](https://github.com/saphid/frame-control/pull/195), [PR #202](https://github.com/saphid/frame-control/pull/202), [headset findings](decky-frame.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
+36 -4
View File
@@ -4,7 +4,8 @@
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
verified. Frame Control therefore labels its user-service manager experimental.
No individual Decky plugin has been tested on the headset.
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
VR user interface has not been seen or used.
## What was checked on the real headset
@@ -35,6 +36,33 @@ remained in Steam's running web helper until that helper was restarted. After a
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
temporary trial files and the test's empty managed data folder were removed.
## ProtonDB Badges compatibility test
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
was tested on the same Frame OS build. It is a popular, passive Decky plugin
(133 GitHub stars at test time) that adds badges to game pages. The published
archive SHA-256 was
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
Its six archive entries had no links or path traversal, and its manifest declared
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
no backend `main.py`; its source backend only manages its own settings.
The test copied the archive into Frame Control's managed Decky plugin folder,
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
overlay. The headset was in standby, so no in-headset plugin page or badge
interaction was verified. The backend's unauthenticated root URL returned
HTTP 403, as expected.
The test removed the managed Decky service and binary, the test plugin folder,
and its shared-device lock. The service was no longer running, the plugin list
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
already injected frontend may remain in the running Steam web helper until
Steam normally restarts; the test did not restart Steam or SteamVR.
## Why the official Decky installer is not used
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
@@ -94,6 +122,10 @@ Control refuses to install alongside an upstream system service.
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
Quick Access Menu after an opt-in install. If none appears, inspect whether
the VR shell exposes a different menu component and whether Decky's frontend
can target it without patching Steam globally. Then test one harmless plugin's
install, visible UI, restart persistence and removal. Until those checks pass,
Frame Control should not describe Decky plugins as supported on the Frame.
can target it without patching Steam globally. Confirm the plugin's visible
UI, controller interaction, and persistence across a normal Steam restart.
Until those checks pass,
Frame Control should not describe Decky plugins as usable in VR yet. A worn
headset check is needed for both the Decky menu and the Frame Control SteamVR
panel; only then can their controller interaction and plugin presentation be
confirmed.
@@ -0,0 +1,32 @@
# SteamOS PC OBS host feasibility check, 2026-10-25 UTC
The available x86_64 host, lxso2, runs Ubuntu 24.04.3. It has no `flatpak`
command, OBS installation, SteamOS 3.9.5 VM or physical Steam Machine. This
check therefore does **not** verify that Frame Control or Flatpak OBS runs on
a SteamOS desktop or a read-only-root PC host.
The downloaded v0.4.27 Linux x86_64 AppImage matched GitHub's SHA-256 digest
`42d774653c69b193c3d577743c32b8fe8dfeee7ea1f8cdf7369902962d647b83`.
With `APPIMAGE_EXTRACT_AND_RUN=1`, it launched under Xvfb using an isolated
profile and its bundled server returned HTTP 200. FUSE mounting, physical
display rendering and SteamOS-specific dependencies were not tested.
The v0.4.27 tagged OBS helper then ran against the real Frame under the shared
`/tmp/frame-test.lock`. Frame OS was 0.5.6 and SteamVR activity level was 3
(standby). A receiver bound to `127.0.0.1:15099` on lxso2 accepted 497
MPEG-TS packets, 93,436 bytes. Every datagram had complete 188-byte packets
with sync bytes. `ffprobe` found H.264 video and AAC audio. Closing the
helper's control pipe made it exit 0; no owned remote stream process remained.
Battery read 100% Full before and after, and the owned lock was released. The
temporary `.ts` file was removed. This proves the local UDP sender path on an
Ubuntu host; it does not prove Flatpak OBS could receive it on SteamOS.
The public
[OBS Flatpak manifest](https://github.com/flathub/com.obsproject.Studio/blob/master/com.obsproject.Studio.json)
lists `--share=network`, which permits use of the host network. We read that
manifest but did not install or run the Flatpak. A future check needs a real
SteamOS 3.9.5 desktop host or VM with Flatpak OBS: launch the AppImage without
root changes, start the source, record from the Media Source at
`udp://127.0.0.1:15099`, verify video and audible game audio, then stop and
confirm cleanup. The standby test cannot establish real game audio, A/V sync
or active passthrough in the mirror.
+36
View File
@@ -56,6 +56,42 @@ The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
measured packet continuity and a short MP4 recording on the current SteamOS
build. The headset was still in standby.
## SteamOS / Steam Machine PC host
Use **Desktop Mode** on the SteamOS PC. Download the Linux x86_64 AppImage to
your home folder. Make it executable and open it there; SteamOS has a
read-only system root, so these steps do not use `apt` or change `/usr`:
```sh
chmod +x ~/Downloads/Frame-Control-linux-x86_64.AppImage
~/Downloads/Frame-Control-linux-x86_64.AppImage
```
If AppImage mounting reports a FUSE error, try
`APPIMAGE_EXTRACT_AND_RUN=1 ~/Downloads/Frame-Control-linux-x86_64.AppImage`.
This extracts to writable temporary space and needs enough free disk space.
Set up the Frame's SSH connection in Frame Control, then use **Start OBS
source** before you add or activate OBS's Media Source. Install OBS as the
`com.obsproject.Studio` Flatpak through Discover or your normal user Flatpak
setup. In that OBS Media Source, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. Make a short
recording and check both picture and sound before a stream. Use **Stop OBS
source** in Frame Control when done.
The OBS Flatpak's published manifest includes `--share=network`, which is
needed for its local UDP receiver. Check your installed permissions with
`flatpak info --show-permissions com.obsproject.Studio` if OBS receives no
packets. Frame Control's helper sends to loopback on this PC; it does not
require an open LAN port or an OBS plugin. The PC does not need a system
`ffmpeg` package for the helper; encoding runs on the Frame.
**Test limit:** The v0.4.27 AppImage launched on an Ubuntu 24.04 x86_64 host
under Xvfb. Its helper sent H.264 and AAC from a real Frame to a local UDP
receiver on that host. No SteamOS 3.9.5 PC, Steam Machine, Flatpak OBS runtime,
read-only-root host, or physical display was available for this check. The
SteamOS steps and Flatpak receiver therefore remain **untested on SteamOS**.
See the [test record](evidence/obs-steamos-host-2026-10-25.md).
## Known issue: game audio in OBS
A Frame owner [reported](https://x.com/AzizahStellazio/status/2114147449468293171)
+30 -9
View File
@@ -1,10 +1,29 @@
# Open questions and on-device checks
Research as of 2026-09-25, eight days after the Frame's retail release
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
pages. Searches of Reddit and the Steam forums turned up **almost no
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
"not documented yet", not "doesn't work".
The September sections below record early checks after the Frame's retail
release (2026-09-18). They are a historical test log, not a current setup
guide. For current connection steps, see [SteamOS October 2026
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
[charge-limit.md](charge-limit.md).
## Current Frame OS 0.5.6 status (2026-10-25)
- `frame.local` no longer resolves on the checked 0.5.6 beta build
(`20261022.6153909`). Avahi was active but did not publish addresses. The
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
the same network after verifying the pinned SSH key, or a manual IP/SSH
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
- Read-only checks on that build found no SteamOS Manager
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
charging switches are root-owned and managed by Valve's charger service.
No charging control was written. An 80% limit is **not supported** by Frame
Control; see [charge-limit.md](charge-limit.md) and the
[headset check](evidence/round3-headset-2026-10-25.md).
- Active passthrough in the recording mirror remains **unknown**: the only
captured mirror frame was uniform while the headset was unworn in standby.
A worn, active passthrough check is still needed; see the
[headset check](evidence/round3-headset-2026-10-25.md).
## Verified on device (2026-09-25)
@@ -14,7 +33,9 @@ build 20260922.6101926, kernel 6.18, aarch64):
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
the hostname is `frame`.
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
- **3.** `frame.local` resolved from the Mac on the September build;
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
beta build; see the current status above.
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
@@ -148,9 +169,9 @@ check in the headset:
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
no backend in the Frame's device config, and the battery node is a
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
on a headset.
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
See [charge-limit.md](charge-limit.md).
## Unconfirmed claims made in these docs
+16
View File
@@ -25,3 +25,19 @@ uses the cable. The Wireless Adapter and Steam streaming can follow a different
path. No cable is attached to the Linux test host, so this exact UI has only
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
fresh wired readback on the current Frame OS, remain unverified.
## If SSH works but the game stream stops
If the stream fails on USB, Wi-Fi and the Wireless Adapter, but this doctor
reports a working SSH route, check SteamVR on the **PC**. Frame Control's
`frame.local` reconnect only restores its headset management connection. It
does not restart PC SteamVR or repair the game stream. On a Windows or Linux
PC, save your work and close the VR game. Exit SteamVR from Steam on that PC,
then start it again. This can show whether a PC SteamVR session is stuck; it
does not fix the cause of repeated drops. SteamVR on macOS is not a supported
PC streaming host.
Frame Control has no verified cross-platform command that safely stops only
the intended PC SteamVR session. A forced restart could close a running game
and lose unsaved progress. For that reason the app gives manual steps and does
not add a one-click restart button.
+55 -3
View File
@@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_android_import as backup
import server
from apk_fixtures import apk, manifest
from test_quest import apk_bytes
PKG = 'com.Armature.VR4'
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
@@ -66,6 +67,41 @@ class BackupTests(unittest.TestCase):
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
base = self.root / 'not-named-base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'arm64.apk'
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}))
plan = backup.inspect([str(split), str(base)])
self.assertEqual(plan['apk'], base)
self.assertEqual(plan['splits'], [split])
self.assertEqual(backup.public(plan)['splits'], ['arm64.apk'])
with patch.object(backup.quest, '_running', return_value=False), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META) as install:
backup.install(plan)
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
base = self.root / 'base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'split.apk'
with patch.object(backup.android, 'ssh') as ssh:
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
files={'lib/arm64-v8a/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
files={'res/drawable/icon.png': b'png'}))
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
files={'lib/x86_64/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
backup.inspect([str(base), str(split)])
ssh.assert_not_called()
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
with patch.object(backup.android, 'read_meta', return_value=None):
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
@@ -204,6 +240,21 @@ class BundleTests(unittest.TestCase):
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
source = self.bundle({
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}),
}, suffix='.apks')
with patch.object(backup.android, 'ssh') as ssh:
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
ssh.assert_not_called()
self.assertEqual(read['plan']['apk'], 'base.apk')
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
folders = list(server._android_backups[read['token']]['dirs'])
server.android_backup({'action': 'discard', 'token': read['token']})
self.assertTrue(all(not Path(p).exists() for p in folders))
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
source = self.bundle()
token = server.android_backup({'action': 'begin'})['token']
@@ -221,7 +272,7 @@ class BundleTests(unittest.TestCase):
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
cases = [
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
@@ -229,7 +280,7 @@ class BundleTests(unittest.TestCase):
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
({'manifest.json': b'{}'}, 'standalone APK'),
({'manifest.json': b'{}'}, 'base APK'),
]
for entries, message in cases:
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
@@ -277,7 +328,8 @@ class BundleTests(unittest.TestCase):
('x' * len('isSplitRequired')).encode('utf-16-le'))
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
with self.subTest(attrs=attrs):
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.inspect([str(self.apk)])
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
+58 -14
View File
@@ -131,10 +131,30 @@ def unpack_bundle(path, root):
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
paths = [str(local) for local, _, _, _ in entries]
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
raise android.FrameError('Android bundles must contain a base APK and optional supported splits or OBB files.')
return paths
def _apk_identity(path):
"""Use the manifest, not the filename, to identify a base or split APK."""
try:
with zipfile.ZipFile(path) as archive:
elements = frame_apk.manifest_elements(
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
attrs = next((attrs for tag, attrs in elements if tag == 'manifest'), {})
package = (attrs.get('package') or (None, None, None))[2]
split = (attrs.get('split') or (None, None, None))[2]
code = (attrs.get('versionCode') or (None, None, None))[1]
major = (attrs.get('versionCodeMajor') or (None, 0, None))[1]
if not package or not android.PKG_RE.fullmatch(package):
raise android.FrameError(f'{path.name}: no valid package in APK manifest')
return package, split, (major, code) if code is not None else None
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, UnicodeError,
ValueError, RuntimeError, EOFError, zlib.error, struct.error,
IndexError) as error:
raise android.FrameError(f'Could not read APK manifest in {path.name}: {error}') from error
def _check_standalone(path):
try:
with zipfile.ZipFile(path) as archive:
@@ -168,19 +188,41 @@ def inspect(paths, discover=False):
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
apks = [p for p in files if p.suffix.lower() == '.apk']
if len(apks) > 1:
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
apk = apks[0] if apks else None
apk, splits = None, []
if apks:
identities = [(p, *_apk_identity(p)) for p in apks]
bases = [p for p, _, split, _ in identities if not split]
if len(bases) != 1:
raise android.FrameError('Choose one game at a time: exactly one base APK is required.')
apk = bases[0]
package, version = next((pkg, code) for p, pkg, _, code in identities if p == apk)
split_names = set()
for path, pkg, split, code in identities:
if path == apk:
continue
if pkg != package or split in split_names:
raise android.FrameError('Split APKs must have the same package and distinct split names.')
if version is None or code is None or code != version:
raise android.FrameError('Split APK version cannot be verified against the base APK.')
split_names.add(split)
splits.append(path)
if apk:
_check_standalone(apk)
if not splits:
_check_standalone(apk)
info = android.apk_info(str(apk))
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
checked = quest.inspect(info['package'], str(apk))
if not splits:
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
if splits:
added = android.split_entries(str(apk), [str(p) for p in splits])
if any(name.startswith('lib/') and not name.startswith('lib/arm64-v8a/') for name in added):
raise android.FrameError('This split set includes native libraries for another device. '
'Choose only the arm64-v8a split for the Frame.')
checked = quest.inspect(info['package'], str(apk), [str(p) for p in splits])
pkg = checked['package']
if discover and len(files) == 1:
files.extend(p for p in sorted(apk.parent.iterdir())
@@ -213,13 +255,14 @@ def inspect(paths, discover=False):
if not path.is_file() or not path.stat().st_size or path.name in names:
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
names.add(path.name)
return {'apk': apk, 'obb': obb, 'checked': checked,
return {'apk': apk, 'splits': splits, 'obb': obb, 'checked': checked,
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
def public(plan):
checked = plan['checked']
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
'splits': [p.name for p in plan['splits']],
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
'size': sum(size for size, _ in plan['signatures'].values())}
@@ -243,7 +286,8 @@ def install(plan, display='auto', progress=None):
quest._check_downgrade(android.read_meta(pkg), checked, False)
report('Installing the game app', 15)
meta = android.install(str(plan['apk']), source=plan['apk'].name,
flatscreen=None if display == 'auto' else display == 'flat')
flatscreen=None if display == 'auto' else display == 'flat',
splits=[str(p) for p in plan['splits']])
else:
meta = android._meta_or_fail(pkg)
if not plan['obb']:
+4 -3
View File
@@ -1373,7 +1373,7 @@
<div class="store-owned" id="storeOwned">
<section class="panel" id="androidBackup">
<div class="shelf-head"><h2>Import Android game</h2></div>
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
<p class="sub">Have a backup? Choose the game's base APK, supported native-library or asset splits, and all its .obb files together, or one XAPK/APKS/APKM bundle. Resource, code and feature splits and Android/data restore are not supported here.
Frame Control installs the app and puts its data in the right place.</p>
<button class="action small" id="androidBackupChoose">Choose game files…</button>
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" multiple hidden>
@@ -1916,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>
@@ -3980,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("");
+2
View File
@@ -11,6 +11,8 @@
<section><div class="row"><label for="alias">Your configured headset SSH alias<input id="alias" value="frame" maxlength="64" autocomplete="off"></label>
<button id="check">Check cable connection</button></div><p id="message" role="status" aria-live="polite">No checks have run.</p></section>
<div id="results" aria-live="polite"></div>
<section><h2>SSH works, but the PC stream still fails?</h2>
<p>The doctor checks Frame Control's SSH route only. If the SteamVR stream stops on USB, Wi-Fi and the Wireless Adapter, the problem may be on the PC. On a Windows or Linux PC, first save your work and close the VR game. Exit SteamVR from Steam on the PC, then start SteamVR again. Frame Control reconnecting to <code>frame.local</code> will not restart PC SteamVR. This is a manual test, not a fix for the cause of repeated drops. Frame Control does not restart PC SteamVR automatically because it cannot safely know which game and session it would stop.</p></section>
<p class="sub">The observed USB address is 10.86.200.233 on Frame OS tested in 2026. A different OS build may use another subnet. An unknown result does not prove the cable is broken. The doctor never changes IP settings, accepts a new host key, or bypasses SSH warnings.</p>
</main><script>
'use strict';