mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Merge main into panel-workspaces: the panel switcher alongside media, analytics and the Mac view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
2bd86207c7
148 files changed
+34350
-254
No files matched your search
+185
@@ -0,0 +1,185 @@
|
||||
# Frame Control for AI agents
|
||||
|
||||
**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
|
||||
its loopback HTTP API. No API key, hosted service, model SDK or third-party
|
||||
helper app is needed. The assistant is our HTML/Python implementation hosted
|
||||
in the platform Chromium browser. Its optional LLM endpoint is user configuration.
|
||||
Installing other apps is an optional management action, never a prerequisite.
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
The default MCP command starts a private HTTP backend on a free loopback port,
|
||||
with a fresh local access key. It stops that backend when the MCP client closes
|
||||
stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does
|
||||
not close the desktop app's connection. No manually started server is needed.
|
||||
|
||||
Add this stdio server to your MCP client (use absolute paths):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"frame-control": {
|
||||
"command": "python3",
|
||||
"args": ["/absolute/path/frame-control/ui/frame_mcp.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Codex, the equivalent registration is:
|
||||
|
||||
```sh
|
||||
codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py
|
||||
```
|
||||
|
||||
New agent sessions load the entry. An already running session may need its MCP
|
||||
connections reloaded; registration does not retroactively add tools to its
|
||||
initial tool inventory. Keep the checkout at that path while it is registered.
|
||||
Use `codex mcp remove frame-control` to remove only this registration.
|
||||
|
||||
To reuse a running server instead, pass `--url http://127.0.0.1:47810`.
|
||||
The desktop app uses a random port; use that port with `--url`, or run the
|
||||
checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
|
||||
value in the MCP process environment. This is local access control, not an LLM
|
||||
API key. The adapter only accepts loopback HTTP servers, refuses redirects and
|
||||
ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
|
||||
It supports MCP initialization, ping, tool listing and tool calls; no sampling,
|
||||
resources, prompts or streaming transport.
|
||||
|
||||
| Tool | Arguments | Effect |
|
||||
|---|---|---|
|
||||
| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations |
|
||||
| `status` | none | Battery, services, installed games and Flatpaks |
|
||||
| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
|
||||
| `job` | `id` | Background install status; poll until `done`, inspect `error` |
|
||||
| `launch` | `appid` | Launch an installed Steam app |
|
||||
| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
|
||||
| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
|
||||
| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
|
||||
| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
|
||||
| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
|
||||
| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
|
||||
|
||||
Only install free software with its developer's consent. There is no purchase,
|
||||
entitlement bypass or arbitrary shell tool. `install` returns a background job
|
||||
ID; it does not claim the installation has finished. APK and sideloaded title
|
||||
installs remain in the main UI for now.
|
||||
|
||||
### Approval is a separate human action
|
||||
|
||||
Every mutation first returns an `approvalUrl`, exact action and `confirmation`
|
||||
token. Ask the user to open that URL and choose **Approve this action** or
|
||||
**Reject**. Then repeat the same tool and arguments with the token in
|
||||
`confirmation`. The server refuses execution before approval, changed arguments,
|
||||
expired tokens and reuse. A file approval binds the content hash as well as the
|
||||
path. Approvals last five minutes and disappear when the HTTP server restarts.
|
||||
A failed execution also consumes the approval; review a fresh request to retry.
|
||||
The panel does not execute an action merely because it was approved.
|
||||
|
||||
MCP has no approval tool. This is protection against accidental model tool
|
||||
calls, not a sandbox against a client with independent shell/HTTP access to your
|
||||
computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations
|
||||
are returned directly to that client, which may forward them to its configured
|
||||
model. The assistant's separate opt-in does not govern an external MCP client.
|
||||
|
||||
Power still requires the existing password prompt in a local terminal. MCP
|
||||
never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
|
||||
UI. The panel launcher and keep-awake adapter require zsh on the computer.
|
||||
|
||||
[PR #16](https://github.com/saphid/frame-control/pull/16) owns
|
||||
`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
|
||||
Until that script is present, the tool reports it unavailable. Keep-awake is
|
||||
never automatic: `on` changes the shared idle timers; explicitly approve `off`
|
||||
to restore them after work. It is not a per-agent lease; coordinate with other
|
||||
users. No changes are made to the analytics/update interfaces in
|
||||
[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
|
||||
replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
python3 scripts/assistant-on-frame.py --port 47810
|
||||
```
|
||||
|
||||
This starts an SSH reverse forward bound to Frame loopback (port 47812 by
|
||||
default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
|
||||
command running. Ctrl-C closes this browser profile and the tunnel; it leaves
|
||||
other Chromium windows and the existing HTTP server alone. A failed cleanup
|
||||
prints the temporary profile path so it can be removed when the Frame returns.
|
||||
Use `--frame-port` if the default is busy. Chromium must already be available as
|
||||
`org.chromium.Chromium`; the launcher never installs anything automatically.
|
||||
Place the panel with SteamVR's normal docking controls.
|
||||
|
||||
Enter your full **chat-completions endpoint**, model name and optional key.
|
||||
An OpenAI-compatible local server works without a key; no OpenAI account is
|
||||
required. HTTP is allowed only on loopback; other endpoints require HTTPS.
|
||||
Loopback refers to the computer running the HTTP server, even in the headset.
|
||||
Endpoints with embedded credentials, query strings or redirects are refused.
|
||||
|
||||
Check the message consent box and press **Send message**. Screenshot context is
|
||||
a separate unchecked box and sends one fresh capture with that request. Both
|
||||
boxes reset after sending, and changing endpoint/model revokes consent. Nothing
|
||||
is sent when opening the page or entering configuration. There is no model
|
||||
list fetch, saved history, automatic screenshot capture or assistant telemetry.
|
||||
Each send is independent: previous messages and replies are not included.
|
||||
|
||||
Configuration, credentials and chat remain in page memory; close/reload the page
|
||||
or choose **Clear everything** to clear them. A request already sent cannot be
|
||||
recalled. Only the chosen endpoint gets the request; proxy environment variables
|
||||
and redirects are disabled. Its privacy and retention policy still applies.
|
||||
Replies are plain text and cannot call tools or operate the Frame. A model must
|
||||
support image inputs to accept screenshot context.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
|
||||
status through an SSH reverse tunnel; platform Chromium created a separate
|
||||
SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
|
||||
a PNG. These checks preceded the UI implementation. No power or global settings
|
||||
were changed.
|
||||
|
||||
**Inferred:** visual comfort and controller keyboard usability while wearing
|
||||
the headset; panel creation in gamescope alone does not establish these.
|
||||
Windows/Linux launcher support, live third-party model endpoints, installs,
|
||||
uninstalls, power and keep-awake changes are not covered by that feasibility
|
||||
check. See the PR for the final unit and end-to-end results.
|
||||
|
||||
**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
|
||||
initialized, read status, retrieved a headset PNG, and transferred a test file
|
||||
only after approval through the Chromium page. Remote file bytes matched;
|
||||
reusing the confirmation was rejected. The actual headset Chromium page sent
|
||||
text and then separately opted-in image context to a local test endpoint and
|
||||
displayed its replies. Without consent there were zero endpoint requests.
|
||||
The test endpoint returned canned replies: model inference and a live external
|
||||
provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
|
||||
profiles, SSH tunnels and the test file were removed. No installs, removals,
|
||||
launches of user games, power operations or keep-awake changes were performed.
|
||||
|
||||
**Verified locally:** unit coverage includes the stdio subprocess, approval
|
||||
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
|
||||
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
|
||||
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
|
||||
because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
|
||||
on this branch (run 36421345682).
|
||||
|
||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||
browser profile and SSH tunnel and remove the profile and panel log.
|
||||
|
||||

|
||||
|
||||
## Computer-use coverage
|
||||
|
||||
MCP is the tool transport, not a limit on what an agent can do. A screenshot,
|
||||
accessibility snapshot, click or keystroke can all be MCP tools when we have a
|
||||
reliable underlying implementation. See [the investigation](computer-use.md)
|
||||
for the verified boundaries. `computer_state` adds observation, not an input
|
||||
channel: it cannot click an approval button or send keyboard/mouse events.
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
|
||||
by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
|
||||
12 tools, read live Frame status and returned X11 window state plus AT-SPI
|
||||
observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
|
||||
children, reported as `incomplete: true`; this is not a complete actionable UI.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Announcing changes
|
||||
|
||||
How we tell people about Frame Control features and fixes as they merge. The
|
||||
same few sentences feed the X post, the release notes and the website, so they
|
||||
are written once, in the pull request, while the change is fresh.
|
||||
|
||||
## What gets announced
|
||||
|
||||
| Kind | Announce? | Example |
|
||||
|---|---|---|
|
||||
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
|
||||
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
|
||||
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
|
||||
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
|
||||
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
|
||||
|
||||
If a change isn't worth a sentence to someone who owns a Frame, it isn't
|
||||
announced.
|
||||
|
||||
## The voice
|
||||
|
||||
Write it the way the README and release notes already read.
|
||||
|
||||
- **Lead with what the person can now do**, in their words: "Install older
|
||||
versions of an app when the newest won't run on the Frame", not "Add APK
|
||||
version fallback resolver".
|
||||
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
|
||||
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
|
||||
"excited to announce", "huge", or exclamation marks.
|
||||
- **Say where it works.** Platforms and what it was tested on, briefly:
|
||||
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
|
||||
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
|
||||
on one OS, say so in the same post.
|
||||
- **Sentence case**, full sentences, British spelling to match the docs.
|
||||
Contractions are fine.
|
||||
- **No emoji in the text.** One image, GIF or short clip carries the tone
|
||||
instead. The only symbol is the kind label below.
|
||||
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
|
||||
Frame" for the headset and "Frame Control" for the app.
|
||||
- **Credit people.** If a user reported the bug or suggested the feature and is
|
||||
happy to be named, thank them by handle.
|
||||
|
||||
## The formats
|
||||
|
||||
Every announceable PR ends with an `## Announcement` section holding these.
|
||||
The reviewer checks it like code.
|
||||
|
||||
### 1. The post (X, and any other social account)
|
||||
|
||||
```
|
||||
<Kind>: <what you can do now, one sentence>
|
||||
|
||||
<one or two sentences: how it works, the catch, or what it was tested on>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
- `<Kind>` is `New`, `Better` or `Fixed`.
|
||||
- 280 characters maximum including the link (X counts any link as 23).
|
||||
- One link: the release if it has shipped, otherwise the PR.
|
||||
- One visual when the change is visible: a screenshot from the app, a GIF, or a
|
||||
short clip from the headset. Alt text describes what it shows.
|
||||
- No hashtags, except `#SteamFrame` on releases and on posts about something
|
||||
new, because people search for it.
|
||||
|
||||
### 2. The release-note line
|
||||
|
||||
One bullet under **New in x.y.z**, same as the current release notes: the
|
||||
first half of the post's first sentence, no kind label, no link.
|
||||
|
||||
### 3. Release post
|
||||
|
||||
```
|
||||
Frame Control <version>: <the headline change>
|
||||
|
||||
<one sentence on the headline change>. Also: <two or three short items>.
|
||||
|
||||
Windows, macOS and Linux: <release link>
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
The release title on GitHub uses the same `Frame Control <version>: <headline>`
|
||||
line, as 0.3.0 and 0.3.1 already do.
|
||||
|
||||
### Roundups
|
||||
|
||||
Small fixes that don't earn their own post wait for a roundup, posted with the
|
||||
next release or when three or more have piled up:
|
||||
|
||||
```
|
||||
Fixed in Frame Control this week:
|
||||
- <fix>
|
||||
- <fix>
|
||||
- <fix>
|
||||
|
||||
<link>
|
||||
```
|
||||
|
||||
## Examples from what has already merged
|
||||
|
||||
**#11, older APK versions**
|
||||
|
||||
```
|
||||
New: when an Android app is too new for the Frame, Frame Control now offers
|
||||
older versions that will install.
|
||||
|
||||
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
|
||||
before it goes on the headset.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/11
|
||||
```
|
||||
|
||||
**#8, Mac mirror fixes**
|
||||
|
||||
```
|
||||
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
|
||||
the panel, asks for the right password, and shows the cursor.
|
||||
|
||||
Tested end to end on a real Frame from macOS 27.
|
||||
|
||||
https://github.com/saphid/steam-frame/pull/8
|
||||
```
|
||||
|
||||
**v0.3.1**
|
||||
|
||||
```
|
||||
Frame Control 0.3.1: install APKs without the Android SDK
|
||||
|
||||
Frame Control now reads APK files itself, so there's nothing extra to install.
|
||||
Also: Linux and Windows game sideloading, and one-click install links.
|
||||
|
||||
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
|
||||
#SteamFrame
|
||||
```
|
||||
|
||||
## Posting
|
||||
|
||||
Nothing is posted without a person approving it. The flow is:
|
||||
|
||||
1. The PR carries its `## Announcement` section.
|
||||
2. On merge, the post is drafted from that section (manually for now).
|
||||
3. Alex approves or edits it, then it's posted from the project account.
|
||||
4. Replies and questions that turn out to be bugs become GitHub issues labelled
|
||||
`feedback`, same as the website form.
|
||||
@@ -5,6 +5,12 @@ in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
|
||||
not general Android use
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
|
||||
|
||||
**VR streaming clients:** WiVRn 26.9 and ALVR 20.14.1 install, but both fail
|
||||
OpenXR instance creation on the checked Frame because its Android runtime lacks
|
||||
`XR_KHR_convert_timespec_time` (**verified** 2026-09-28, SteamOS 0.4.1,
|
||||
BUILD_ID 20260925.6191901). They are not Frame Control dependencies. See
|
||||
[the feasibility results and options](linux-vr-streaming.md).
|
||||
|
||||
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
|
||||
|
||||
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Computer use through Frame Control MCP
|
||||
|
||||
The MCP transport can carry semantic actions or visual computer-use actions.
|
||||
The limits are the Frame's underlying interfaces, permissions and whether an
|
||||
action can be targeted and verified. A stereoscopic headset screenshot alone
|
||||
is not a reliable coordinate system for clicking a particular app window.
|
||||
|
||||
## What exists, and the right route
|
||||
|
||||
| Surface | Evidence and route | Remaining work or boundary |
|
||||
|---|---|---|
|
||||
| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. |
|
||||
| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. |
|
||||
| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. |
|
||||
| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. |
|
||||
| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. |
|
||||
| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. |
|
||||
| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. |
|
||||
| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. |
|
||||
|
||||
## Reusing the existing computer-use work
|
||||
|
||||
**Documented:** the installed `cua-driver` skill has the right control pattern:
|
||||
observe an exact window, use a semantic target if available, fall back to pixels
|
||||
from that same snapshot, then read back the result. Its browser route requires
|
||||
an exact process/window/target binding and session-scoped element references.
|
||||
Those are useful design rules for Frame tools.
|
||||
|
||||
**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes
|
||||
host-local process/window IDs and macOS AX inspection. It does not establish an
|
||||
SSH Frame target. The installed skill's advertised Linux companion file is
|
||||
missing. A native ARM64 Frame backend, its dependencies and remote transport
|
||||
have not been verified. We therefore do not claim that the existing Mac driver
|
||||
can control the Frame by passing it a Frame PID or screenshot, and we do not
|
||||
make the feature depend on installing that application.
|
||||
|
||||
Frame Control's `computer_state` is our own Python implementation over installed
|
||||
platform libraries. It sends the probe over SSH stdin, writes no helper to disk,
|
||||
and exits after one observation. Missing displays/libraries return explicit
|
||||
errors; a 15-second process deadline prevents a stalled accessibility call from
|
||||
leaving a probe behind. Window names and accessibility text are untrusted app
|
||||
content, never instructions to an agent.
|
||||
|
||||
**Recommended next implementation:** an isolated Chromium session with typed
|
||||
snapshot/click/type/scroll tools and exact fresh target binding, then individually
|
||||
verified native app actions. Use the headset capture to judge appearance, not to
|
||||
invent a screen-to-window coordinate transform. Direct tool calls must retain
|
||||
approval rules; a generic computer-use tool must not become a route around the
|
||||
MCP approval panel, install confirmation or power confirmation.
|
||||
|
||||
## Isolated browser input proof
|
||||
|
||||
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary
|
||||
Frame Chromium profile loaded a local test page through an SSH reverse tunnel.
|
||||
CDP `Input.insertText` entered the test string in its own input. A CDP
|
||||
`Input.dispatchMouseEvent` press/release on its own button copied that string
|
||||
to the page's result; DOM readback matched exactly. The browser profile,
|
||||
loopback forwards and panel log were removed afterward. No user app was typed
|
||||
into, no global settings were changed and no third-party helper app was used.
|
||||
|
||||
AT-SPI did **not** expose the test page's controls in that same probe, even with
|
||||
Chromium's renderer-accessibility flag. It returned the partial Steam-client
|
||||
tree instead. The reason remains **unverified**; this is an evidence gap, not
|
||||
proof that Frame accessibility cannot work. For a first implementation,
|
||||
Chromium's proven page-specific CDP route is stronger than assuming complete
|
||||
AT-SPI coverage. This proof does not ship unrestricted click/type tools or
|
||||
establish input delivery to SteamVR's menus.
|
||||
@@ -0,0 +1,47 @@
|
||||
Frame client feasibility, 2026-09-28
|
||||
SteamOS VERSION_ID=0.4.1 BUILD_ID=20260925.6191901; uname -m=aarch64
|
||||
SteamVR: vrserver log reports 2.18.1; process 2256 remained alive across checks.
|
||||
Base: dcf9689f6459e576d35fc507eb702ca6b2bf4dad (main).
|
||||
Unmodified upstream release APKs; installed with ui/frame_android.py install APK --vr.
|
||||
No Linux host attached. No pairing, streamed video, input, audio or worn-headset checks.
|
||||
Selected logcat lines only; timestamps in Android logs are UTC.
|
||||
|
||||
WiVRn-release.apk
|
||||
https://github.com/WiVRn/WiVRn/releases/tag/v26.9
|
||||
sha256=1df6649ec77224fcc821af0ab4897222bdf3d7eb6ce6ad636461336724111331
|
||||
E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/WiVRn ( 1140): [2026-09-28 12:07:58.987] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/WiVRn ( 1140): [2026-09-28 12:07:59.034] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
E/WiVRn ( 1140): [2026-09-28 12:07:59.035] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
|
||||
alvr_client_android.apk
|
||||
https://github.com/alvr-org/ALVR/releases/tag/v20.14.1
|
||||
sha256=be68feeb02665e3d69f1cdbcabf38ea4d15c42868a7ec6b5e698dbefee4e4e36
|
||||
E/OpenXR-Loader( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
I/RustStdoutStderr( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
E/[ALVR NATIVE-RUST]( 1139): ALVR panicked: What happened:
|
||||
E/[ALVR NATIVE-RUST]( 1139): panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||
E/[ALVR NATIVE-RUST]( 1139): called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||
|
||||
Cleanup verified: both app directories, compatdata directories and Steam shortcuts absent.
|
||||
Both test containers stopped and removed. No Steam/SteamVR restart or global setting changes.
|
||||
|
||||
Valve release notes fetched from ISteamNews/GetNewsForApp/v2 (appid=250820).
|
||||
|
||||
SteamVR Beta Updated - 2.18.1
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787
|
||||
Added tethered Quest support over USB (must be used with Steam Link Beta)
|
||||
|
||||
Introducing SteamVR 2.17
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486
|
||||
Adds initial support for USB streaming. Note: Requires new Steam Client Beta.
|
||||
Fix crash using Steam Link on Linux when games submit invalid textures.
|
||||
Improve streaming recovery when using Steam Link on Linux.
|
||||
|
||||
SteamVR Beta Updated - 2.17.8
|
||||
https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598
|
||||
Fix crash using Steam Link on Linux when games submit invalid textures.
|
||||
Improve streaming recovery when using Steam Link on Linux.
|
||||
Adds initial support for USB streaming.
|
||||
USB streaming can be used without WiFi by opting into the Steam Client Beta.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Mod feasibility test, 2026-09-28
|
||||
|
||||
**Verified observations**, with limits below. Device: `aarch64`, SteamOS
|
||||
`VERSION_ID=0.4.1`, `BUILD_ID=20260925.6191901`; Proton version file:
|
||||
`1788505046 proton-11.0-2c-arm64`; `vrcmd --stats`: SteamVR `2.18.1`.
|
||||
Times below are the Frame's AEST clock.
|
||||
|
||||
## Inventory and allowed content
|
||||
|
||||
`ssh frame 'python3 - owned' < ui/frame_steam.py` returned 868 games before
|
||||
the test. Beat Saber (620980) and Skyrim VR (611670) were absent. Half-Life 2:
|
||||
VR Mod – Episode One (2177750) was installed. Hogwarts Legacy (990080),
|
||||
Horizon Zero Dawn (1151640) and Horizon Forbidden West (2420110) were listed
|
||||
but not installed. The full personal library is not committed.
|
||||
|
||||
**Documented, owner report after these tests:** Alex has Beat Saber on a
|
||||
Quest 2, which is charging. That copy was not accessed or inspected during
|
||||
this test. Its version, transfer path and Frame compatibility remain unknown;
|
||||
Steam ownership is still not established.
|
||||
|
||||
Gravitas's public Steam metadata reported `is_free: true`, developer Galaxy
|
||||
Shark Studios, Windows only. Frame Control's existing Steam helper requested
|
||||
its install. Steam first returned free-license state 3, then config state 7,
|
||||
then queued the download. The helper did not accept state 3; a later read
|
||||
found state 7. The source of that transition was not observed. A later
|
||||
`install 1067310` returned `state: installed`. No purchase occurred.
|
||||
|
||||
UEVR was downloaded from the author's [1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05),
|
||||
not a mirror. `UEVR.zip` was 7,399,455 bytes. Its SHA-256 matched the author's
|
||||
`UEVR.zip.sha256` (a UTF-16 text file):
|
||||
|
||||
```text
|
||||
af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c
|
||||
```
|
||||
|
||||
Microsoft's Windows x64 .NET runtime and Windows Desktop runtime 6.0.36 ZIPs
|
||||
were downloaded using URLs in the official release metadata. Both SHA-512
|
||||
hashes matched that metadata. They were extracted into a test-only user
|
||||
directory, not installed globally. No other mod manager was used.
|
||||
|
||||
## Half-Life 2 VR: visible setup, not gameplay
|
||||
|
||||
Launched the already-installed Episode One using
|
||||
`steam steam://rungameid/2177750`. The existing manifest recorded build
|
||||
25413453 and a shared base depot from app 658920, build 25413418.
|
||||
|
||||
Selected fresh Steam / SteamVR log lines:
|
||||
|
||||
```text
|
||||
22:07:50 proton waitforexitandrun .../Half-Life 2 VR/ep1vr.exe
|
||||
22:08:03 SetApplicationPid: Setting app steam.app.2177750 PID to 27990
|
||||
22:08:03 Successfully loaded binding file '.../hlvr/cfg/steamvr/bindings_frame.json' for app 'steam.app.2177750'.
|
||||
```
|
||||
|
||||
The game's `episodicvr/console.log` reached `Creating VR hand HUD...`,
|
||||
`Creating VR weapon HUD...` and `Calibrating VR base position`. It also
|
||||
contained missing material/weapon warnings. SteamVR logged a missing
|
||||
`frame_hmd` binding as well as the successful controller binding load;
|
||||
controller input was not exercised.
|
||||
|
||||
`ui/frame_vrshot.py` produced a stereo capture showing the mod's
|
||||
“First time setup” and “Dominant hand” dialog. This establishes visible
|
||||
startup, not a played level or comfortable performance. The capture includes
|
||||
room passthrough and is deliberately not published. The test's `hl2.exe`
|
||||
process was terminated; the pre-existing game installation was preserved.
|
||||
|
||||
## Gravitas and UEVR: injection unverified
|
||||
|
||||
The game was launched through Steam. The injector was then started in the
|
||||
same `steamapps/compatdata/1067310` prefix using the shipped
|
||||
`SteamLinuxRuntime_4-arm64/_v2-entry-point` and Proton 11 ARM64. The first
|
||||
attempt reported:
|
||||
|
||||
```text
|
||||
Application: UEVRInjector.exe
|
||||
Message: You must install .NET to run this application.
|
||||
Architecture: x64
|
||||
App host version: 6.0.35
|
||||
.NET location: Not found
|
||||
```
|
||||
|
||||
With `DOTNET_ROOT` and `DOTNET_ROOT_X64` pointing at the test-only Windows
|
||||
runtime directory, Proton loaded `Microsoft.NETCore.App/6.0.36` and
|
||||
`Microsoft.WindowsDesktop.App/6.0.36`. A 25-second launcher timeout was too
|
||||
short to establish whether the UI worked. A longer attempt produced a
|
||||
625×372 `UEVR` X11 window on display `:1`. This is window creation evidence,
|
||||
not a successful injection. That launcher returned exit 0; this was not
|
||||
treated as proof that a mod worked.
|
||||
|
||||
A combined launch set `PROTON_REMOTE_DEBUG_CMD` to `UEVRInjector.exe` and
|
||||
ran Gravitas's `Drop.exe` in the same runtime/prefix. The process
|
||||
`SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe` and a 1600×900 window titled
|
||||
`SkyArk (64-bit, PCD3D_SM5)` appeared. The launcher exited **1** before an
|
||||
injection or stereo game scene could be verified. Output included:
|
||||
|
||||
```text
|
||||
Proton: Error while copying to ".../windows/system32/amdxcffx64.dll": No such file or directory
|
||||
Error [GENERAL | xrCreateInstance | OpenXR-Loader] : xrCreateInstance failed
|
||||
X Error of failed request: BadWindow (invalid Window parameter)
|
||||
Major opcode of failed request: 10 (X_UnmapWindow)
|
||||
X Error of failed request: XI_BadDevice (invalid Device parameter)
|
||||
Minor opcode of failed request: 28 (X_GetDeviceButtonMapping)
|
||||
```
|
||||
|
||||
These errors do **not** establish an ARM64/FEX incompatibility. Other work
|
||||
was launching apps on the shared Frame. SteamVR's PIDs changed during the
|
||||
experiment; `ps` recorded the replacement `vrserver` and `vrcompositor`
|
||||
starting at **22:13:03**, corroborated by `steamvr.service` journal startup
|
||||
lines. This test did not request a Steam/SteamVR restart. The combined
|
||||
attempt ran at 22:14:17–22:14:27, after that restart. A stable, coordinated
|
||||
session is needed to distinguish launcher/environment problems from game or
|
||||
mod incompatibility.
|
||||
|
||||
## Cleanup and remaining checks
|
||||
|
||||
- No game executable or OpenVR DLL was replaced. No global runtime or power
|
||||
setting was changed by this test. No R.E.A.L., OpenComposite or Beat Saber
|
||||
payload was installed.
|
||||
- Removed the test-only UEVR/.NET directory and downloaded ZIPs from the
|
||||
Frame. Final process checks found no `UEVRInjector.exe`,
|
||||
`Drop-Win64-Shipping.exe`, `ep1vr.exe` or `hl2.exe`. SteamVR was running.
|
||||
- Gravitas and its Steam-created prefix remain installed for a repeat test;
|
||||
the pre-existing HL2 VR install remains. Steam may retain normal shader
|
||||
caches, logs and prefix temporary files.
|
||||
- Still unverified: UEVR injection and removal, R.E.A.L. releases and
|
||||
permissions, OpenComposite, Beat Saber playback on either build, and our
|
||||
own manager's end-to-end install/uninstall. No installer UI is justified
|
||||
by these results. See the [support table and next checks](../mods.md).
|
||||
+23
-7
@@ -56,9 +56,11 @@ counts them while they run.
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. They aren't uploaded anywhere: the shared database is maintainer-only
|
||||
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
|
||||
`adb`, or yours if you have one.
|
||||
you see. With **Share compatibility results** on (Privacy & updates), they also
|
||||
go to the shared database ([privacy.md](privacy.md),
|
||||
[compat-db/README.md](../compat-db/README.md)). A failed install records
|
||||
itself when the APK was the problem, and after an install the app offers a
|
||||
20-second test. Uses the app's bundled `adb`, or yours if you have one.
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
@@ -90,8 +92,11 @@ app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` a
|
||||
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
|
||||
and `adb` from Google's platform-tools, so there's nothing else to install. It
|
||||
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
|
||||
trusts root certificates already in the Windows store.
|
||||
`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
|
||||
trusts root certificates already in the Windows store. And it bundles KDE
|
||||
Connect for the Frame (Valve's arm64 build and five libraries, 3.6 MB,
|
||||
[`frame/kdeconnect`](../frame/kdeconnect/NOTICE.md)), which it copies to the
|
||||
Frame for the keyboard and trackpad.
|
||||
`app/build/fetch-deps.js` downloads all of it, pinned by SHA-256.
|
||||
|
||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
@@ -152,5 +157,16 @@ npm run dist:win # Windows: installer and .zip
|
||||
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||
```
|
||||
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
|
||||
release (`.github/workflows/release.yml`).
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
|
||||
draft release (`.github/workflows/release.yml`). Running copies are offered it
|
||||
once you publish it: see [releasing.md](releasing.md).
|
||||
|
||||
## AI agents and assistant
|
||||
|
||||
**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
|
||||
Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
covers putting it in a Chromium panel on the Frame.
|
||||
@@ -38,6 +38,11 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
|
||||
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
|
||||
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
|
||||
| **SteamOS updates arrive on their own.** The Frame went from 0.3.0 (build 20260922.6101926) to **0.4.1, build 20260925.6191901**, between 2026-09-27 and 2026-09-28 with no action from us; `~` (keys, user Flatpaks, `~/.local/share`) survived. **Verified 2026-09-28.** | Keep changes in `~` |
|
||||
| **Valve's package repository has more than the image.** `pacman -Si` / `pacman -Sp` work as `steamos` without root and list Valve's own builds, such as `kdeconnect` 24.02.2 and `python-evdev` 1.7.0 in `extra`. Unpacking those packages into `~` runs them without touching the read-only root. The repository URLs say not to share them, so never write them down; Valve also publishes each build's source package there (`sources/packages/`), which is how Frame Control got the complete source for the KDE Connect it ships. **Verified 2026-09-28**, SteamOS 0.4.1. | [streaming.md](streaming.md#input-type-and-point-in-the-frame-from-the-mac-or-iphone) |
|
||||
| **gamescope has its own input injection.** An EIS socket at `/run/user/1000/gamescope-0-ei` (libei 1.4.1 is on the image) offers "Gamescope Virtual Input": relative and absolute pointer, buttons, scroll, keyboard. It drives the panel that has focus in the headset, on either X display. Focus moves only with the controller's laser (or to a new panel when none has it); `gamescopectl focus_info` prints the focus state to the journal. **Verified 2026-09-29.** | [streaming.md](streaming.md#live-view-and-control-watch-a-panel-and-tap-on-it) |
|
||||
| **A panel's own pixels:** `ffmpeg -f x11grab -window_id <window> -i :<display>` captures one window (x11grab of the root is black under gamescope). Panels live on `:0` (Steam's UI, windows tagged by `panel-on-frame.sh`) or `:1` (apps Steam starts). **Verified 2026-09-29.** | Frame Control's Desktop view |
|
||||
| **gamescope runs two Xwayland displays.** `:0` holds Steam's VR bar and menus (`valve.steam.gamepadui.*`) and ignores XTest pointer motion; `:1` holds apps such as Chromium and takes it. There's also a libei socket, `/run/user/1000/gamescope-0-ei`. **Verified 2026-09-28**, SteamOS 0.4.1. | Keyboard and trackpad |
|
||||
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
|
||||
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
|
||||
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
|
||||
@@ -58,6 +63,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
|
||||
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
|
||||
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
|
||||
| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work |
|
||||
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
|
||||
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
|
||||
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 901 KiB |
+2
-1
@@ -12,7 +12,8 @@ An iPhone can't run Python or `ssh`, but the Frame can. So the app:
|
||||
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
|
||||
Swift SSH library), with its own ed25519 key from the Keychain;
|
||||
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
|
||||
under 1 MB) to `~/.cache/frame-control/<version>` on the Frame, once per version;
|
||||
4.6 MB, 3.6 MB of it the KDE Connect the keyboard and trackpad use) to
|
||||
`~/.cache/frame-control/<version>` on the Frame, once per version;
|
||||
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
|
||||
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
|
||||
4. tunnels to it through the SSH session and shows the same page as the desktop
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# PC VR streaming from Linux
|
||||
|
||||
**Recommendation, 2026-09-28:** test Valve's current SteamVR/Steam Link path
|
||||
on a Linux gaming PC before building another streamer. Valve now documents
|
||||
Linux streaming fixes and USB support. We have no Linux host attached, so
|
||||
Linux-to-Frame VR streaming remains **unverified here**.
|
||||
|
||||
This is the feasibility and options report for
|
||||
[#24](https://github.com/saphid/frame-control/issues/24), not a shipped streaming
|
||||
feature. Frame Control's features must use our own implementation or standard
|
||||
platform components. WiVRn and ALVR are research comparisons, not dependencies.
|
||||
An optional install shortcut is the most we would offer for a third-party app.
|
||||
Our own streamer requires Alex's choice before implementation.
|
||||
|
||||
## What was checked on the Frame
|
||||
|
||||
**Verified** on 2026-09-28: aarch64, SteamOS **0.4.1**, BUILD_ID
|
||||
`20260925.6191901`, SteamVR **2.18.1**. Version and build are recorded separately;
|
||||
earlier docs associate this build with other SteamOS version labels.
|
||||
|
||||
| Client | Installation | Runtime result |
|
||||
|---|---|---|
|
||||
| WiVRn **26.9**, upstream `WiVRn-release.apk` | API 29, arm64-v8a; installed in its own immersive Lepton instance | OpenXR instance creation fails: missing `XR_KHR_convert_timespec_time`. Both 1.1.58 and 1.0.58 attempts return `XR_ERROR_EXTENSION_NOT_PRESENT` |
|
||||
| ALVR **20.14.1**, upstream `alvr_client_android.apk` | API 26, arm64-v8a; installed in its own immersive Lepton instance | Same missing extension. Client panics at `client_openxr/src/lib.rs:220` with `ERROR_EXTENSION_NOT_PRESENT` |
|
||||
|
||||
The [evidence excerpt](evidence/linux-vr/2026-09-28.txt) includes APK SHA-256s,
|
||||
upstream release links, loader errors and cleanup results. These are failures
|
||||
before an OpenXR session, not successful VR clients. WiVRn was launched twice;
|
||||
ALVR's container remained up despite its client panic. Container liveness alone
|
||||
does not establish VR compatibility.
|
||||
|
||||
Both APKs already declare `MAIN` and `LAUNCHER`. They were installed unmodified
|
||||
using this branch's existing `python3 ui/frame_android.py install APK --vr`,
|
||||
then launched through their Steam shortcuts. Logs came from the instance's
|
||||
`podman exec … /system/bin/logcat`; the user journal also retained WiVRn's errors
|
||||
after its container exited. No headset was worn and no host was connected.
|
||||
|
||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||
SteamVR's original process remained running. No global settings changed.
|
||||
|
||||
### Relation to the VR APK branch
|
||||
|
||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||
was read, not edited (branch inspected at
|
||||
[`038dcd4`](https://github.com/saphid/frame-control/commit/038dcd48cd75336f6a86c63c7878bfc9c52deec9)).
|
||||
Its compatibility layer handles OpenXR version negotiation, some controller
|
||||
profiles and refresh-rate requests. It does **not** implement
|
||||
`XR_KHR_convert_timespec_time`. Its launcher fix is unnecessary for these APKs.
|
||||
This report has **no unmerged code dependency** on that PR, and neither APK was
|
||||
tested with its layer injected.
|
||||
|
||||
**Documented from upstream source:** WiVRn requests the extension in
|
||||
[`application.cpp`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/application.cpp#L1286)
|
||||
and uses it to convert `CLOCK_MONOTONIC` into `XrTime` in
|
||||
[`instance::now()`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/xr/instance.cpp#L335).
|
||||
ALVR also [requests it unconditionally](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_openxr/src/lib.rs#L188).
|
||||
Simply deleting the extension request or returning made-up timestamps would
|
||||
not prove correct tracking or timing. A real fix needs a valid clock mapping
|
||||
and further runtime tests. No such patch was made.
|
||||
|
||||
### Native SteamOS aarch64 clients
|
||||
|
||||
**Verified:** the Frame has a native OpenXR runtime manifest at
|
||||
`~/.config/openxr/1/active_runtime.json`, pointing to SteamVR's
|
||||
`bin/linuxarm64/vrclient.so`.
|
||||
|
||||
**Documented:** WiVRn's [26.9 README](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/README.md)
|
||||
describes its Linux client as debugging-only, without audio or hardware decode.
|
||||
ALVR 20.14.1's [non-Android decoder](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_core/src/video_decoder/mod.rs)
|
||||
returns no decoded frames. The inspected releases ship Android clients, not a
|
||||
ready-to-run native Frame client.
|
||||
|
||||
**Inferred:** a native port is possible research, but neither release offers a
|
||||
demonstrated native alternative to the blocked APKs. Native builds, native
|
||||
extension enumeration, hardware decoding and audio were **not tested**. The
|
||||
Android extension failure does not establish that the native runtime lacks it.
|
||||
|
||||
## (a) Valve's own path — recommended first
|
||||
|
||||
**Documented**, from Valve's release notes rather than launch-window reports:
|
||||
|
||||
- [SteamVR 2.17.8 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598)
|
||||
says “Fix crash using Steam Link on Linux when games submit invalid textures”
|
||||
and “Improve streaming recovery when using Steam Link on Linux.” It also
|
||||
adds initial USB streaming, with Steam Client Beta required to use USB
|
||||
without Wi-Fi.
|
||||
- [SteamVR 2.17 release](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486)
|
||||
repeats Linux streaming fixes and initial USB support. USB is no longer
|
||||
solely a claim about an old beta, but version/channel requirements still
|
||||
need checking on the actual host.
|
||||
- [SteamVR 2.18.1 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787)
|
||||
adds USB-tethered **Quest** support with Steam Link Beta. That entry is not
|
||||
proof of a Frame/Linux combination.
|
||||
- The [Steam Link page](https://store.steampowered.com/app/353380/Steam_Link/)
|
||||
lists Linux desktop clients, while its Quest VR requirements still say
|
||||
Windows 10 or newer. Desktop Steam Link support is not equivalent to VR host
|
||||
support, and the Quest requirements are not a Frame support matrix.
|
||||
|
||||
**Inferred:** Valve has a Linux VR streaming path worth testing. The old blanket
|
||||
claim “Linux cannot stream VR” is no longer justified by the evidence. These
|
||||
release notes do not establish which Linux GPU/driver/Frame combinations work.
|
||||
USB changes the transport; it does not by itself prove host encoder support.
|
||||
|
||||
**Not verified:** Linux host discovery, pairing, wireless or USB streaming,
|
||||
stereo rendering, controllers, haptics, audio, latency, or a game. Frame-only
|
||||
inspection cannot establish any of these. Flat Remote Play and a desktop shown
|
||||
on a panel are not substitutes for this test.
|
||||
|
||||
Next test, once a Linux gaming PC is available: record distro, GPU/driver,
|
||||
Steam client channel/version and SteamVR version; use the Frame's built-in
|
||||
Steam connection flow, first wirelessly and then over a data-capable USB cable.
|
||||
Launch a free OpenXR sample or developer-consented VR game. Verify stereo,
|
||||
head/controller tracking, haptics and audio while worn; retain both ends' logs
|
||||
and measure latency and recovery after a link interruption. Restore any test
|
||||
channel changes. Do not change the shared headset's channel just for this report.
|
||||
|
||||
If that works, Frame Control can provide our own host checks, setup guidance
|
||||
and session controls around Valve's existing platform. First establish which
|
||||
controls have a usable interface; no stable automated pairing API has been
|
||||
verified. **Estimate (inferred):** 2–5 engineer-days for the hardware feasibility
|
||||
pass; another 1–2 weeks for a small integration if those interfaces exist.
|
||||
|
||||
## (b) Our own streaming — proposal only
|
||||
|
||||
This is a new VR transport and device integration, not a desktop capture feature.
|
||||
A plausible first target is **one Linux GPU family, one host, one Frame**, using
|
||||
SteamVR on both ends. Our host driver would expose a remote HMD/controllers,
|
||||
receive poses and inputs, and obtain stereo textures for hardware encoding.
|
||||
Our Frame OpenXR app would decode, submit the correct eye views and render poses
|
||||
at predicted display times, and return tracking/input. SteamVR/OpenXR, bundled
|
||||
codec/transport libraries and platform GPU APIs fit the ownership rule; a
|
||||
WiVRn/ALVR/Monado server dependency would not.
|
||||
|
||||
**Inferred design risks:** Linux SteamVR texture-sharing/driver interfaces and
|
||||
Frame decode-to-GPU interoperability need a spike before committing to this
|
||||
architecture. Sending an already-composited desktop mirror loses the stereo,
|
||||
pose and timing information we need. Late reprojection, clock conversion,
|
||||
backpressure, controller bindings, audio sync and reconnects are substantial
|
||||
work. A runtime shim must not assume `XrTime` equals monotonic nanoseconds.
|
||||
|
||||
### Reuse from `mac-in-headset`
|
||||
|
||||
**Documented from our code**, read-only at
|
||||
[`1b90c64`](https://github.com/saphid/frame-control/commit/1b90c64b73bace54c63a3aae154c5d29a9448d72):
|
||||
|
||||
- Reuse the ideas for low-latency encoding without B-frames, dropping work
|
||||
before encoding, bounded queues, keyframe recovery, adaptive bitrate,
|
||||
per-frame timing and authenticated session setup.
|
||||
- Its VideoToolbox encoder and ScreenCaptureKit capture are macOS-specific.
|
||||
Linux needs a new GPU encoder path (for example VA-API or NVENC via bundled
|
||||
libraries) and VR texture capture, not a port of window capture.
|
||||
- Its WebSocket over SSH is useful for a first controlled transport experiment
|
||||
and control messages. Reliable TCP can stall behind lost packets; a VR media
|
||||
path needs measured deadline behaviour, likely datagrams with loss recovery
|
||||
using an ordinary bundled transport library. Do not invent cryptography.
|
||||
- Its Chromium/WebCodecs panel viewer is not a VR client. That branch reports
|
||||
software H.264 decoding and occasional long Wi-Fi stalls on the Frame.
|
||||
Its desktop latency measurements are not motion-to-photon measurements or
|
||||
evidence that a 90/120 Hz stereo stream will work.
|
||||
|
||||
**Size/effort estimate (inferred, one experienced full-time engineer, hardware
|
||||
available):**
|
||||
|
||||
| Phase | Deliverable / stop condition | Effort |
|
||||
|---|---|---|
|
||||
| Feasibility | Linux driver texture access, Frame hardware decode into OpenXR, pose/clock loop; stop if any cannot meet frame deadlines | 2–4 weeks |
|
||||
| First end-to-end prototype | One GPU/codec, stereo sample over a controlled LAN, head/controllers, logs and teardown | 4–8 additional weeks |
|
||||
| Usable limited beta | Audio/haptics, pairing, recovery, bitrate/loss handling, installer, worn testing and latency work | 6–12 additional weeks |
|
||||
| Wider support | Multiple GPU vendors/distros, USB and Wi-Fi variation, long-session stability | 2–4 additional months |
|
||||
|
||||
Planning range: **12–24 engineer-weeks for a limited beta**, roughly
|
||||
**10–25k lines of our code plus tests/tooling**, excluding bundled libraries.
|
||||
This is a low-confidence scope estimate, not a delivery promise; an unsupported
|
||||
driver or decode interface could block it entirely. Foveated streaming,
|
||||
eye tracking and parity with Valve are excluded. A Linux gaming PC and repeatable
|
||||
worn-headset testing are prerequisites. **Do not build this until Alex chooses.**
|
||||
|
||||
## (c) Optional “install WiVRn” shortcut only
|
||||
|
||||
Allowed as a clearly optional convenience, never a prerequisite for a Frame
|
||||
Control feature. **Documented:** WiVRn's server Flatpak ID is
|
||||
`io.github.wivrn.wivrn`; its client/server versions must match, and its Flatpak
|
||||
includes xrizer/OpenComposite. Those are properties of an independently
|
||||
installed third-party stack, not components of our implementation.
|
||||
|
||||
**Recommendation:** defer the shortcut while the current client fails before
|
||||
session creation. If offered later, label that compatibility result and let
|
||||
the user choose the install; do not present “install” as “streaming works.”
|
||||
**Estimate (inferred):** 1–2 engineer-days for an optional host-side shortcut
|
||||
with package/version detection and honest status, excluding third-party fixes.
|
||||
No shortcut, host install, pairing automation or streaming UI was built here.
|
||||
|
||||
Choose **(a)** for the next hardware test. Keep **(b)** as a separately approved
|
||||
project if Valve's path fails or lacks a required capability. **(c)** does not
|
||||
solve the verified client blocker and should not be the product's foundation.
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
managers must not be dependencies. Steam, Proton and SteamVR remain platform
|
||||
dependencies. No game purchases, entitlement bypasses, withdrawn builds or
|
||||
unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
**Documented:** the [BSMG PC guide](https://bsmg.wiki/pc-modding.html)
|
||||
describes extracting each map into its own directory below
|
||||
`Beat Saber/Beat Saber_Data/CustomLevels`. Basic custom songs do not require
|
||||
SongCore; maps that require mod features need their matching dependencies.
|
||||
This is a candidate for our own file manager, not a verified Frame feature.
|
||||
|
||||
Alex's Quest 2 copy is a separate Android candidate. Its ownership does not
|
||||
make the PC `CustomLevels` layout applicable. Until the actual build is
|
||||
inspected, neither a direct song-copy recipe nor APK patching is justified.
|
||||
|
||||
Only maps whose music and chart are permitted for distribution may be used
|
||||
as test fixtures or bundled content. A public download alone does not establish
|
||||
those rights. Start with an original or explicitly licensed basic map.
|
||||
|
||||
**Documented:** [ModsBeforeFriday](https://github.com/Lauriethefish/ModsBeforeFriday)
|
||||
targets Quest Beat Saber over WebUSB/ADB. It is not a generic native ARM64
|
||||
modding protocol. [BSManager](https://github.com/Zagrios/bs-manager/releases/tag/v1.6.0)
|
||||
publishes an aarch64 Flatpak, but its architecture says nothing about Beat
|
||||
Saber or its plugins running on Frame. Neither app is an installation step
|
||||
or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
or app manifest alone is not proof. Keep downloading, installed and playable
|
||||
as separate states.
|
||||
2. Download a pinned mod version from the author's official release. Record
|
||||
the URL, version, license and digest. Verify the published digest when
|
||||
available; an upstream SHA-256 detects corruption but is not a signature.
|
||||
Do not treat “free to download” as permission to redistribute.
|
||||
3. Stage and validate archives before writing into the game or prefix. Reject
|
||||
path traversal, links escaping the destination, archive bombs and unexpected
|
||||
executable content in song packs. Check song metadata and its referenced
|
||||
files, not just the `.zip` suffix.
|
||||
4. Refuse changes while the game is running. Back up originals and journal
|
||||
every managed file and digest. Apply changes atomically where possible and
|
||||
roll back partial failures. Keep runtime prerequisites scoped to this game.
|
||||
5. Uninstall only files still matching our receipt; restore originals without
|
||||
overwriting later user edits. Preserve saves, unrelated mods and songs.
|
||||
Song removal must target one managed map, never the whole CustomLevels tree.
|
||||
6. Expose one-click actions beside the game only after real-Frame install,
|
||||
playback and uninstall pass. Test filesystem and download failure handling
|
||||
with fake-Frame fixtures; those cannot prove FEX injection or VR rendering.
|
||||
|
||||
## Sources
|
||||
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
(HTTP 403 from this environment; contents not verified).
|
||||
- [Half-Life 2 VR official site](https://halflife2vr.com/) and
|
||||
[Episode One on Steam](https://store.steampowered.com/app/2177750/).
|
||||
- [Beat Saber store metadata](https://store.steampowered.com/api/appdetails?appids=620980).
|
||||
@@ -102,10 +102,11 @@ Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
||||
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
||||
install it instead of 1.17.2.
|
||||
|
||||
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
|
||||
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
|
||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
||||
21. **Owned media player:** worn-headset comfort, audio quality/lip sync, long
|
||||
movies and 4K/8K decoding remain to check. Native spatial-photo container
|
||||
extraction, large/immersive splats and existing-panel theatre docking need
|
||||
further implementation. Remote eye isolation, short hardware decode and
|
||||
owned-overlay cleanup are verified; see [vr-video.md](vr-video.md).
|
||||
|
||||
## Verified 2026-09-27
|
||||
|
||||
|
||||
@@ -275,3 +275,12 @@ implementation. We have not established a keyboard detector or a calibrated
|
||||
camera-to-panel mapping. Built-in full-room passthrough is not proof of a
|
||||
public, selectively masked camera stream. No keyboard cutout is offered, and
|
||||
no third-party camera/overlay app is substituted for it.
|
||||
|
||||
## Frame Control's media theatre
|
||||
|
||||
[The owned media player](vr-video.md) can show its video or stereo image on a
|
||||
larger, head-relative screen with its own dark surround. **Verified remotely
|
||||
2026-09-28**, SteamOS 0.4.1 / BUILD_ID 20260925.6191901: screen, eye isolation,
|
||||
surround and cleanup. It does not alter panel docking or global settings.
|
||||
Its `Overlay` RGBA rendering hook is available to stream producers; applying
|
||||
SteamVR theatre docking to existing Mac/PC panels is still unverified here.
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Privacy and analytics
|
||||
|
||||
Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
|
||||
(US cloud) so the maintainer can see how many people use it, which features
|
||||
matter and where installs fail. You choose how much in **Privacy & updates**,
|
||||
the last panel on the page. `ui/frame_telemetry.py` is the whole
|
||||
implementation.
|
||||
|
||||
## The three levels
|
||||
|
||||
| Level | Default | What it sends |
|
||||
|---|---|---|
|
||||
| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
|
||||
| Share compatibility results | Off | Your Android compatibility reports and tests |
|
||||
| Send error details | Off | Scrubbed error messages and tracebacks |
|
||||
|
||||
Nothing is sent until the first-run notice has been shown. The notice's
|
||||
**Share more to help fix problems** button turns on the second and third
|
||||
levels together. Either can be turned off later. Turning a level off
|
||||
deletes that level's events that haven't been sent yet.
|
||||
|
||||
**Show what's been sent** in the panel lists the last 50 events that left your
|
||||
computer, exactly as they were sent.
|
||||
|
||||
## Anonymous
|
||||
|
||||
- Events carry a random id, made when Frame Control first runs and kept in
|
||||
its data folder (`telemetry/settings.json`). It isn't derived from your
|
||||
computer, account or network. To get a new one, delete that file.
|
||||
- Events are sent without person profiles (`$process_person_profile: false`)
|
||||
and without location lookup (`$geoip_disable: true`). Each carries a
|
||||
placeholder address (`$ip: 0.0.0.0`), so PostHog stores that instead of
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
error becomes one category from a fixed list (for example `apk_wrong_abi` or
|
||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
||||
is one. It names what was installed only when that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
- Flathub apps: `flatpak_id`
|
||||
- Steam games: `steam_appid`
|
||||
- A sideloaded title: only its runtime (Proton or Linux)
|
||||
|
||||
Any other APK is sent as `catalog: false`, with no name.
|
||||
|
||||
## Compatibility results (opt-in)
|
||||
|
||||
Each report becomes a `compat_report` event with the fields the Report dialog
|
||||
shows: package, version, result or rating, your notes, how it was run, and the
|
||||
SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
|
||||
## Error details (opt-in)
|
||||
|
||||
`$exception` events carry an error message, the Frame Control file, line and
|
||||
function it came from, and the request that failed (for example
|
||||
`POST /api/android install`). Before anything is sent, the message is
|
||||
scrubbed:
|
||||
|
||||
- your home folder becomes `~`, and any user name becomes `<user>`
|
||||
- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
|
||||
names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
|
||||
replaced
|
||||
- URLs are cut down to their scheme and a public host name, or `<url>`. User
|
||||
names, passwords, ports, paths and queries are dropped
|
||||
- `token=`, `key=`, `password=` and similar values are replaced
|
||||
|
||||
The same error is sent at most once every 10 minutes.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
the person sends it deliberately. The report has the kind, title and text you
|
||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
- the app version and whether it's a built app
|
||||
- the OS, its release and CPU, and the Python version
|
||||
- the Frame's SteamOS build, if it has connected since the app started
|
||||
- which analytics levels are on
|
||||
|
||||
**Also include recent activity and the server log** is off by default,
|
||||
because those lines can name files and apps. When ticked, it adds the newest
|
||||
Activity lines and server log lines, without the request lines.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
exactly what's included** shows the snapshot that will be sent, and later
|
||||
activity isn't added to it. If PostHog can't be reached, **Copy report** puts
|
||||
the whole report on the clipboard.
|
||||
|
||||
The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||
the environment that starts Frame Control. A copy run from a source checkout
|
||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
|
||||
## Update checks
|
||||
|
||||
The desktop app asks GitHub for the latest release shortly after starting,
|
||||
then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
@@ -0,0 +1,59 @@
|
||||
# Releasing and updates
|
||||
|
||||
Frame Control checks for updates itself. The desktop app offers a new version
|
||||
only once it's GitHub's **latest release**, and drafts and pre-releases never
|
||||
count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Bump `version` in `app/package.json`, commit, and push a tag:
|
||||
|
||||
```sh
|
||||
git tag v0.4.0 && git push origin v0.4.0
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
To pull a bad release, mark the previous one as latest
|
||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||
Copies that already updated stay on it. Nothing downgrades them.
|
||||
|
||||
## How a copy updates itself
|
||||
|
||||
`app/updater.js` reads `update.json` from
|
||||
`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
|
||||
REST API only when a release has no manifest, because the API allows just 60
|
||||
unauthenticated requests an hour per IP address, shared by a whole household.
|
||||
Then it downloads the installer for its platform and checks it
|
||||
against the SHA-256 digest GitHub publishes for the asset. It refuses if the
|
||||
digest is missing or doesn't match. Then:
|
||||
|
||||
| Installed from | Update |
|
||||
|---|---|
|
||||
| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
|
||||
| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
|
||||
| Linux AppImage | The new AppImage replaces the old file and is started. |
|
||||
| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
|
||||
|
||||
Version 0.3.1 and earlier have no updater, so people on them have to download
|
||||
the new version once by hand.
|
||||
+1
-1
@@ -99,7 +99,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
||||
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
||||
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
|
||||
| `scripts/push-vr-video.sh` | Computer → Frame | Upload movies, stereo PNG/JPEG or small `.splat` files to `~/Videos/FrameControl`; `--launch` starts our OpenVR player, `--theatre` adds a larger screen and dark surround. No separate viewer. **Verified remotely**: decode, stereo output and cleanup; worn-headset checks remain. See [docs/vr-video.md](vr-video.md) |
|
||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
||||
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
||||
|
||||
@@ -4,6 +4,10 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
|
||||
## How it works
|
||||
|
||||
The Frame's Steam client runs with `-cef-enable-debugging`. So its UI, a
|
||||
|
||||
+152
-14
@@ -5,7 +5,10 @@ This covers three directions, plus input:
|
||||
- **A. Frame → Mac**: see and control the headset from the Mac.
|
||||
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
|
||||
- **C. iPhone → Frame**: mirror the phone inside the headset.
|
||||
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
|
||||
including Valve's streaming and USB support. No Linux host tested yet.
|
||||
- **Input**: type and point in the Frame from the Mac or iPhone.
|
||||
- **Live view and Control**: watch a panel flat and tap on it to use it.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
@@ -24,11 +27,13 @@ Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
## B. Show the Mac's desktop inside the Frame
|
||||
|
||||
The Frame's streaming features are built around a **Windows PC running
|
||||
SteamVR** plus the USB Wi-Fi 6E dongle. Even Linux hosts had VR-streaming
|
||||
problems at launch
|
||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||
VR-streaming problems at launch
|
||||
([Steam discussion](https://steamcommunity.com/app/4165890/discussions/0/528765047224280796/),
|
||||
[gbl08ma](https://gbl08ma.com/posts/steam-frame-a-linux-machine-doesnt-support-linux/)).
|
||||
Valve's later 2.17.8 notes explicitly describe Steam Link fixes on Linux and
|
||||
initial USB streaming support (**documented**, not tested from a Linux host
|
||||
here). See [the current comparison](linux-vr-streaming.md#a-valves-own-path--recommended-first).
|
||||
**macOS isn't a supported SteamVR host**, so for the Mac we're only looking at
|
||||
flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
||||
|
||||
@@ -41,8 +46,10 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
||||
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
||||
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
|
||||
|
||||
For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
|
||||
them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
|
||||
For **local movies and stereo photos**, Frame Control's own OpenVR player
|
||||
runs on the Frame; see [vr-video.md](vr-video.md). It currently renders a flat
|
||||
stereo screen. VR180/360 projection is not implemented; the same page records
|
||||
DeoVR only as an optional, independently installed alternative.
|
||||
|
||||
### Pre-seeding the Remmina profile (no typing in the headset)
|
||||
|
||||
@@ -109,18 +116,149 @@ capture it.
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
|
||||
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
|
||||
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
|
||||
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
|
||||
while the desktop panel is open in the headset.
|
||||
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
|
||||
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
|
||||
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
|
||||
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
types on the Frame. On a computer, clicking the pad passes your mouse and
|
||||
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
|
||||
|
||||
| Option | Mac | iPhone | Notes |
|
||||
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
|
||||
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
|
||||
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
||||
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
|
||||
|
||||
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
- KDE Connect isn't installed on the Frame, so **Frame Control ships it**:
|
||||
Valve's own build for the Frame (`kdeconnect` 24.02.2-1 from its `extra`
|
||||
repository) plus the five libraries it links that the Frame lacks
|
||||
(`kcontacts`, `kpeople`, `modemmanager-qt`, `pulseaudio-qt`, `libfakekey`),
|
||||
pinned by SHA-256 in [`frame/kdeconnect/packages.json`](../frame/kdeconnect/packages.json).
|
||||
The builds download them from the
|
||||
[kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1)
|
||||
(`app/build/fetch-deps.js`, `frame/kdeconnect/fetch.py`). The address of
|
||||
Valve's repository for the Frame isn't to be shared, and Valve's public
|
||||
aarch64 preview repository has KDE Connect 25.08, built against newer KDE
|
||||
libraries than the Frame has.
|
||||
- On first use, the computer copies them to the Frame over the SSH connection
|
||||
it already has. The iPhone app's bundle, already copied to the Frame, has
|
||||
them too. The agent checks each SHA-256 and unpacks them into
|
||||
`~/.local/share/frame-control/kdeconnect` (3.6 MB copied, 18 MB unpacked,
|
||||
about 2 s). There's no internet download on the Frame, no root, and nothing
|
||||
on the read-only system, so SteamOS updates leave it alone. A stamp there
|
||||
(`root/.frame-control-packages`) records which build it is; a newer Frame
|
||||
Control replaces it.
|
||||
- `pacman -Sp kdeconnect …` also pulls in ModemManager, libqmi, libmbim,
|
||||
libqrtr-glib and ppp (packaging dependencies). `kdeconnectd` and its plugins
|
||||
don't link any of them (checked with `ldd` against the six packages alone),
|
||||
so Frame Control leaves them out.
|
||||
- Licences: the packages are GPL and LGPL; Frame Control stays MIT because it
|
||||
only starts `kdeconnectd` and speaks its protocol. The notice, licence texts
|
||||
and complete source are in [`frame/kdeconnect`](../frame/kdeconnect/NOTICE.md),
|
||||
[`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md) and the app's
|
||||
**About and licences** (Tools).
|
||||
- It pairs by itself: the agent asks to pair and accepts on KDE Connect's side
|
||||
over D-Bus (`qdbus6 … acceptPairing`). It keeps its identity in
|
||||
`…/kdeconnect/bridge`, so later connections are already paired. (A pair
|
||||
request to a device that's already paired makes KDE Connect unpair it, so
|
||||
the agent only asks when it isn't paired.)
|
||||
- Protocol version 7: whoever opens the TCP connection sends its identity line
|
||||
in plain text, then acts as the **TLS server** (KDE Connect's
|
||||
`lanlinkprovider.cpp`). Remote input is `kdeconnect.mousepad.request` with
|
||||
`dx`/`dy`, `singleclick`, `rightclick`, `singlehold`/`singlerelease`,
|
||||
`scroll`, `key` (any text) or `specialKey` (1 Backspace … 14 Escape,
|
||||
21–32 F1–F12) and modifier flags.
|
||||
- **KDE Connect runs only while something uses the keyboard and trackpad.**
|
||||
Each device gets its own KDE Connect identity (KDE Connect keeps one
|
||||
connection per device, so a shared one would make a phone and a computer
|
||||
knock each other off). The last one to disconnect stops KDE Connect, so it
|
||||
isn't left running, or discoverable on your network, afterwards.
|
||||
- KDE Connect 24.02 **hangs or crashes when asked to unpair a device that's
|
||||
offline** (seen twice: once spinning at 100% CPU with D-Bus unresponsive,
|
||||
once exiting). Frame Control never unpairs. If its copy stops answering, the
|
||||
agent restarts it once (tested by freezing it with `kill -STOP`).
|
||||
- Moves from the iPhone app (Simulator) and the Mac's server moved the Frame's
|
||||
X pointer by exactly the amount sent, including with the bundled packages
|
||||
copied over SSH (2026-09-28).
|
||||
- gamescope runs **two Xwayland displays**. `:0` holds Steam's VR bar and menus
|
||||
and ignores injected pointer motion; `:1` holds apps such as Chromium and
|
||||
takes it. KDE Connect runs on `:1`, so it reaches apps, not Steam's own menus.
|
||||
There's also a `gamescope-0-ei` (libei) socket.
|
||||
- Typing through KDE Connect lands in a Chromium panel on `:1` (seen in the
|
||||
panel's own capture, 2026-09-29). It **can't reach panels on `:0`** (Frame
|
||||
Control's own panels, Steam's UI) and, since XTest positions are clamped to
|
||||
`:1`'s 1280×720 root, can't reach beyond that in a bigger window. Control on
|
||||
the live view (below) has neither limit.
|
||||
- **Not yet tested:** whether it reaches the KDE desktop panel (Plasma is its
|
||||
own session).
|
||||
- **Known limit:** keys and clicks typed while the link is reconnecting wait
|
||||
and are sent once it's back, but anything sent in the moment the Wi-Fi
|
||||
drops, before SSH notices, can be lost. Confirming every event would add a
|
||||
round trip to each pointer move.
|
||||
|
||||
## Live view and Control: watch a panel and tap on it
|
||||
|
||||
**Built: Home → Desktop / Headset view → Control.** The live view has two
|
||||
sources:
|
||||
|
||||
- **Headset view**: what the lenses show (SteamVR's mirror, `/dev/video99`). It
|
||||
moves with the wearer's head, so Control makes the view a trackpad: drag to
|
||||
move the pointer, tap to click, press and hold to right-click, two fingers to
|
||||
scroll. With a mouse, moving over the view moves the pointer.
|
||||
- **Desktop**: the app panel in use in the headset, from its own window, so it
|
||||
stays still however the wearer looks around. Control makes taps and clicks
|
||||
land exactly where you put them. Dragging is a mouse drag, press and hold is a
|
||||
right-click, two fingers scroll, and on a computer the mouse, wheel and
|
||||
keyboard work directly on it (⌘ is sent as Ctrl on a Mac). A picker shows any
|
||||
other panel, view only.
|
||||
|
||||
Below the view, a text field and key buttons type on the Frame from a phone.
|
||||
|
||||
How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
- **Input goes through gamescope's own injection.** gamescope serves an EIS
|
||||
socket (`/run/user/1000/gamescope-0-ei`; Steam feeds Remote Play input through
|
||||
it), and `libei` 1.4.1 is on the image. [`ui/frame_touch.py`](../ui/frame_touch.py)
|
||||
talks to it with `ctypes`: nothing to install. gamescope offers one device,
|
||||
"Gamescope Virtual Input", with relative and absolute pointer, buttons,
|
||||
scroll and keyboard (Linux key codes; no text capability, so the text field
|
||||
types printable ASCII on a US layout). Its absolute region is unbounded; the
|
||||
pointer uses the focused panel's display coordinates, and gamescope fits each
|
||||
window to its display, so a 1920×1080 window on the 1280×720 `:1` takes
|
||||
positions at two thirds scale. Taps on a 1280×720 page landed on the exact
|
||||
pixel.
|
||||
- **It reaches the panel that has focus** (`GAMESCOPE_FOCUSED_WINDOW` on `:0`'s
|
||||
root), on either X display. In the OpenVR backend focus moves only on SteamVR
|
||||
overlay events (the controller's laser entering or clicking a panel), or to a
|
||||
new panel when none holds it (read from gamescope's `OpenVRBackend.cpp`, seen
|
||||
with `gamescopectl focus_info`, which writes to the journal). Neither
|
||||
`GAMESCOPECTRL_BASELAYER_WINDOW`/`_APPID` nor X focus moves it, and no
|
||||
gamescope command does. So Control follows the wearer: whatever they last
|
||||
used is what your taps reach. A window without a Steam app id (`STEAM_GAME`)
|
||||
gets a connector of its own and doesn't hold focus.
|
||||
- **Keys in a burst can arrive out of order**, so the helper paces them (8 ms
|
||||
apart).
|
||||
- **Known limit:** if focus moves to another panel in the middle of a drag, the
|
||||
release goes to the panel that has focus then. Whether gamescope hands it to
|
||||
the window that got the press isn't known yet. When the session ends, the
|
||||
helper lets go of every button and key it still holds.
|
||||
- **The Desktop picture is the window's own pixels**: `ffmpeg -f x11grab
|
||||
-window_id <window> -i :<display>` works on gamescope's redirected windows,
|
||||
while grabbing the root gives black. It streams as H.264 like the headset view
|
||||
(about 30 fps at 720p).
|
||||
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
|
||||
text box in the panel and the text field typed into it; a trackpad move went
|
||||
exactly (+40, +25).
|
||||
|
||||
Our own `uinput` keyboard and mouse would also work (`steamos` is in the
|
||||
`input` group and `/dev/uinput` is group-writable, verified 2026-09-27), and
|
||||
remains the fallback if the bundled KDE Connect ever stops working on a new SteamOS.
|
||||
|
||||
| Other option | Mac | iPhone | Why not |
|
||||
|---|---|---|---|
|
||||
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
|
||||
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||
| **Bluetooth keyboard and mouse** | – | – | Needs real hardware, paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
|
||||
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
|
||||
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
|
||||
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
|
||||
|
||||
Other ways to get text in:
|
||||
|
||||
@@ -163,6 +163,28 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
hardware-decoder command construction, malformed splats, stereo parallax and
|
||||
fake-Frame library/process ownership. `tests/e2e/test_media_transfer.py` checks the real
|
||||
HTTP/SSH upload and library listing without pretending the fake renders VR.
|
||||
Real decode timings and captured stereo output are recorded in
|
||||
[vr-video.md](vr-video.md). Generated media only; no external player required.
|
||||
|
||||
**Verified 2026-09-28**, real Frame BUILD_ID 20260925.6191901: `~/.local`
|
||||
and `~/.local/share` are `steamos:steamos`, mode 0755. The fake supervisor
|
||||
sets those parent owners too; previously its root-created Steam manifests
|
||||
left the parents root-owned and incorrectly prevented user runtime installs.
|
||||
|
||||
## Agent interfaces
|
||||
|
||||
`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
|
||||
assistant against an in-process HTTP endpoint with canned responses (no keys or
|
||||
external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
|
||||
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
|
||||
rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
|
||||
|
||||
## Panel switcher
|
||||
|
||||
`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# VR APKs and Quest games in Lepton
|
||||
|
||||
What it takes to run an immersive (OpenXR) Android app, including Meta Quest
|
||||
builds, on the Frame. Checked on SteamOS BUILD_ID 20260925.6191901, Lepton
|
||||
v2.8.14 (rootfs v2.8.11), SteamVR 2.18.1, on 2026-09-28, unless marked
|
||||
**inferred**.
|
||||
|
||||
## How a VR APK reaches SteamVR (verified)
|
||||
|
||||
- Lepton ships a standard Khronos system runtime manifest,
|
||||
`/vendor/etc/openxr/1/active_runtime.json`, pointing at SteamVR's Android
|
||||
client, `/data/steamvr/runtime/bin/androidarm64/vrclient.so`. That is the
|
||||
host's `/opt/steamvr/bin/androidarm64/`, bind-mounted in.
|
||||
- An APK's own Khronos-style `libopenxr_loader.so` tries the runtime brokers
|
||||
(`org.khronos.openxr.runtime_broker`, `…system_runtime_broker`), finds
|
||||
neither, then falls back to that manifest. Nothing in the APK has to change
|
||||
for discovery.
|
||||
- Install the APK without the flatscreen marker
|
||||
(`python3 ui/frame_android.py install app.apk --vr`). The marker only
|
||||
controls Lepton's 2D Android surface; the app itself has to start an
|
||||
OpenXR session.
|
||||
- Lepton also loads Valve's `XR_APILAYER_VALVE_fdm_injection` layer from
|
||||
`/vendor/etc/openxr/1/api_layers/implicit.d/`. Only layers in Valve's own
|
||||
directories are picked up (`liblepton/vulkan_layers.sh`), so a third-party
|
||||
layer has to ship inside the APK.
|
||||
|
||||
**Open Brush 2.32.29, the Quest APK from its GitHub release (Unity OpenXR,
|
||||
Vulkan), works unmodified.** Its manifest already has `LAUNCHER` next to
|
||||
`com.oculus.intent.category.VR`. Unity asked for OpenXR 1.1, got
|
||||
`XR_ERROR_API_VERSION_UNSUPPORTED`, retried with 1.0 and succeeded. SteamVR
|
||||
took it as the scene app, created Touch, simple-controller and Frame-controller
|
||||
bindings, and the session reached `XR_SESSION_STATE_FOCUSED`. A headset capture
|
||||
(`ui/frame_vrshot.py`, after waking the compositor and closing the dashboard)
|
||||
showed a dark sky over a mountain horizon; nobody wore the headset to confirm
|
||||
it was Open Brush's scene or to try drawing.
|
||||
|
||||
**Khronos `hello_xr` (Vulkan, 1.1.63 release APK) works unmodified:**
|
||||
`Instance RuntimeName=SteamVR/OpenXR RuntimeVersion=2.18.1`, 1728×1728
|
||||
swapchains per eye, session `IDLE → READY → SYNCHRONIZED` (the headset was
|
||||
not being worn, so it did not reach `FOCUSED`).
|
||||
|
||||
## What SteamVR's Android runtime supports (verified, from `vrclient.so`)
|
||||
|
||||
- **OpenXR 1.0 only.** An app requesting `XR_API_VERSION_1_0` works; one
|
||||
requesting 1.1 (`XR_CURRENT_API_VERSION` in a 1.1 SDK) gets
|
||||
`XR_ERROR_API_VERSION_UNSUPPORTED` from the runtime.
|
||||
- Extensions include `XR_KHR_opengl_es_enable`, `XR_KHR_vulkan_enable{,2}`,
|
||||
`XR_KHR_composition_layer_depth`, `XR_KHR_locate_spaces`,
|
||||
`XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`,
|
||||
`XR_EXT_hand_tracking`, `XR_EXT_eye_gaze_interaction`, and these Meta ones:
|
||||
`XR_FB_display_refresh_rate`, `XR_FB_foveation{,_configuration,_vulkan}`,
|
||||
`XR_FB_space_warp`, `XR_FB_swapchain_update_state`,
|
||||
`XR_META_foveation_eye_tracked`, `XR_META_recommended_layer_resolution`,
|
||||
`XR_META_vulkan_swapchain_create_info`, `XR_META_performance_metrics`.
|
||||
- Not present: `XR_FB_passthrough`, `XR_FB_hand_tracking_*`,
|
||||
`XR_FB_spatial_entity*`, `XR_FB_color_space`,
|
||||
`XR_KHR_android_thread_settings`, `XR_OCULUS_*`.
|
||||
- Interaction profiles include `oculus/touch_controller`, `khr/simple_controller`,
|
||||
`valve/frame_controller` and the usual PC controllers. Valve documents Touch
|
||||
bindings as a working fallback on the Frame controllers.
|
||||
|
||||
## What stops a Quest APK (verified with Wolvic 1.9, `oculusvr` build)
|
||||
|
||||
1. **Lepton won't start it.** Lepton's `apk-info-extractor` only accepts an
|
||||
activity whose intent filter has `android.intent.action.MAIN` and
|
||||
`android.intent.category.LAUNCHER`. Quest apps use
|
||||
`com.oculus.intent.category.VR` instead, so Lepton logs `APP_ACTIVITY is
|
||||
empty` and exits. There is no override. **Fix:** add the `LAUNCHER`
|
||||
category to that intent filter and re-sign. After that, Wolvic started.
|
||||
2. **OpenXR 1.1.** Wolvic's Quest build then requested OpenXR 1.1 and aborted
|
||||
on `XR_ERROR_API_VERSION_UNSUPPORTED`. Unity's OpenXR plugin retries with
|
||||
1.0 (Open Brush, above), so this mostly bites native and non-Unity apps. **Fix (inferred):** an API layer
|
||||
inside the APK that asks the runtime for 1.0 and maps the 1.1 core
|
||||
functions to the extensions the runtime does have (`XR_KHR_locate_spaces`,
|
||||
`XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`).
|
||||
3. **Lepton's missing clipboard service** still applies to VR apps. The Godot
|
||||
XR Tools demo's Quest build (itch.io) dies in `Godot.<init>` casting the
|
||||
null clipboard service to `ClipboardManager`, before any OpenXR call. See
|
||||
the clipboard table in [apks.md](apks.md).
|
||||
4. **Not yet reached:** required Meta-only extensions (each app differs),
|
||||
swapchain formats (the Lynx Wolvic build needed `GL_SRGB8_ALPHA8`), and
|
||||
Meta platform services.
|
||||
|
||||
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||
Khronos-style loader and found SteamVR through `/vendor`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||
services. Frame Control won't work around that.
|
||||
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||
not a patch.
|
||||
|
||||
## Frame Control does this for you
|
||||
|
||||
APK uploads and `python3 ui/frame_android.py install app.apk` detect VR
|
||||
manifest categories, Samsung's `vr_only` flag and the arm64 OpenXR loader.
|
||||
VR apps default to immersive mode without the flatscreen marker. The upload
|
||||
selector or CLI `--flat` / `--vr` overrides that choice. Compatibility notes
|
||||
identify legacy VrApi, Meta platform SDK and OpenXR libraries.
|
||||
|
||||
Lepton only starts an `<activity>` whose MAIN intent filter has LAUNCHER; it
|
||||
ignores `<activity-alias>`, which is where Godot 4 exports put LAUNCHER. When no
|
||||
real activity qualifies, Frame Control adds LAUNCHER to the VR activity's MAIN
|
||||
filter, or to the activity the launcher alias targets, then repacks and v2-signs
|
||||
the APK locally before copying it; `meta.json` records
|
||||
`"patched": ["launcher"]`. Unchanged ZIP members retain their compressed
|
||||
bytes; stored libraries are aligned to 16 KiB. The RSA signing identity lives
|
||||
in Frame Control's per-user app-data directory as `apk-signing-key.json`
|
||||
(mode 0600). Keep this key to preserve the signer on subsequent patched
|
||||
updates. A re-signed APK cannot update an installation signed by its original
|
||||
publisher; Android also treats it as a different signer for signature checks.
|
||||
|
||||
VR apps with an arm64 OpenXR loader also get the OpenXR compatibility layer
|
||||
([frame/openxr-compat](../frame/openxr-compat/README.md)): an implicit API
|
||||
layer in the APK's `assets/openxr/1/api_layers/implicit.d/`, which the app's
|
||||
own loader picks up next to Valve's layer. It asks SteamVR for OpenXR 1.0 when
|
||||
the app wants 1.1 and enables the extensions that became 1.1 core; maps
|
||||
`xrLocateSpaces` to `xrLocateSpacesKHR` and `grip_surface` to `palm_ext`; drops
|
||||
1.1 controller profiles SteamVR doesn't know; stubs
|
||||
`XR_KHR_android_thread_settings` and `XR_OCULUS_android_session_state_enable`;
|
||||
and keeps the current refresh rate when SteamVR refuses a requested one.
|
||||
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
||||
|
||||
Verified on the headset (2026-09-28):
|
||||
|
||||
- **Wolvic 1.9, Quest build**, installed as downloaded: Frame Control added
|
||||
`LAUNCHER` and the layer. The layer turned OpenXR 1.1.48 into 1.0.63, the
|
||||
instance and session were created, and a 144 Hz refresh request that SteamVR
|
||||
refused was kept at the current rate. The session reached `SYNCHRONIZED`;
|
||||
then Wolvic's Gecko engine crashed (null SIGSEGV on its Gecko thread, the
|
||||
same crash its Lynx build has), which is Wolvic's, not OpenXR's.
|
||||
- **Open Brush, Quest build**, with the layer: 1.1.54 → 1.0.63, the thread
|
||||
settings stub in use, `bytedance/pico4_controller` bindings dropped, and the
|
||||
session reached `FOCUSED`, the same as without the layer.
|
||||
|
||||
Inspect or prepare an APK without contacting the headset:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py info app.apk
|
||||
python3 ui/frame_android.py patch app.apk patched.apk
|
||||
python3 ui/frame_android.py patch app.apk patched.apk --add assets/openxr/1/api_layers/implicit.d/X.json=X.json --add lib/arm64-v8a/libX.so=libX.so
|
||||
```
|
||||
|
||||
The patch fixes Lepton's launch-category requirement. It does not supply an
|
||||
OpenXR 1.1 translation layer, Meta services or a VrApi implementation.
|
||||
+139
-72
@@ -1,81 +1,148 @@
|
||||
# Watching VR video (180°/360°) on the Frame
|
||||
# Movies, stereo photos and splats
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
|
||||
was checked on SteamOS 0.3.0, build 20260922.6101926.
|
||||
Frame Control has its own Frame-side OpenVR player. It uses SteamOS's ffmpeg
|
||||
and V4L2 hardware decoder, Python and SteamVR. **No separate player or viewer
|
||||
is required.** Chromium and immersive WebXR are not in this playback path.
|
||||
|
||||
## The short version
|
||||
## Use it
|
||||
|
||||
1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
|
||||
in the headset. That creates its Proton prefix.
|
||||
2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
|
||||
3. In the headset, open DeoVR's local file browser → **Videos → VR** and
|
||||
pick the file.
|
||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||
**Stop** removes both. Refresh reads the library and the player's state.
|
||||
The screen follows your head; it isn't a saved world-space panel.
|
||||
|
||||
## Why DeoVR
|
||||
|
||||
The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
|
||||
([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
|
||||
runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
|
||||
it found the Steam Frame headset and controller over OpenVR, and decoded
|
||||
7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
|
||||
hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
|
||||
|
||||
Known quirks (verified):
|
||||
|
||||
- The first launch takes about 45 s while it compiles shaders.
|
||||
- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
|
||||
previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
|
||||
and isn't affected.
|
||||
- The in-app store and web content are separate from local files. You don't
|
||||
need an account to play your own files.
|
||||
|
||||
## Getting files onto the headset
|
||||
|
||||
`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
|
||||
interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
|
||||
has about 860 GB free). The script also links that folder into DeoVR's prefix
|
||||
as `C:\users\steamuser\Videos\VR`. It's reachable at
|
||||
`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
|
||||
work. **Not yet checked** whether DeoVR's file browser lands there
|
||||
(open question 21).
|
||||
|
||||
Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
|
||||
one sample). At that rate an 8K file of several GB takes tens of minutes, so
|
||||
start big uploads before you put the headset on.
|
||||
|
||||
## Naming files so they play correctly
|
||||
|
||||
DeoVR guesses the projection from the file name. Its binary contains the tags
|
||||
`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
|
||||
(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
|
||||
(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
|
||||
(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
|
||||
player menu.
|
||||
|
||||
Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
|
||||
|
||||
Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
|
||||
files haven't been played yet. The test clips below cover that.
|
||||
|
||||
## Test clips
|
||||
|
||||
The script doesn't include these. To check a setup, make two 20 s clips:
|
||||
3840×1920, 180° side by side, with the left eye tinted red and the right eye
|
||||
cyan. In the headset each eye should see only its own colour. A single mixed
|
||||
colour means the stereo split is wrong.
|
||||
The command-line route uses the same player:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
|
||||
-filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
|
||||
-c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
|
||||
scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
|
||||
scripts/push-vr-video.sh --launch --theatre ~/Movies/film_SBS.mp4
|
||||
scripts/push-vr-video.sh --launch --layout ou ~/Pictures/stereo.png
|
||||
scripts/push-vr-video.sh --launch capture.splat
|
||||
scripts/push-vr-video.sh --list
|
||||
scripts/push-vr-video.sh --stop
|
||||
```
|
||||
|
||||
## Streaming from the Mac instead of copying (untested)
|
||||
Uploads live in `~/Videos/FrameControl/<id>/` on the Frame. Each upload gets
|
||||
its own directory, so sending another file with the same name doesn't replace
|
||||
it. The UI's existing upload limit is 8 GiB. Transfers use the app's rsync/scp
|
||||
path; resumable large uploads are not implemented here yet. Existing
|
||||
`~/Videos/VR` files and Proton prefixes are left alone. The script no longer
|
||||
launches DeoVR, links a Proton prefix, or accepts directories.
|
||||
|
||||
DeoVR has a DLNA browser (the binary contains `Searching for DLNA
|
||||
devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
|
||||
should therefore appear in DeoVR without copying anything, for example
|
||||
`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
|
||||
tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
|
||||
sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
|
||||
| Media | Supported preview |
|
||||
|---|---|
|
||||
| Movies | H.264 / H.265, mono or left-first SBS / top-first OU; audio through the Frame's PulseAudio-compatible server |
|
||||
| Stereo photos | PNG / JPEG containing both eyes, SBS or OU |
|
||||
| Gaussian splats | Common 32-byte `.splat` records, 1–20,000 Gaussians; a stationary stereo preview |
|
||||
|
||||
Auto layout reads delimited filename tags: `_SBS`, `_HSBS`, `_LR`, `_OU`,
|
||||
`_TB`, `_HOU`, `_HTB`, `_FSBS`, `_FOU`, `_FTB`. SBS/OU without `F` means
|
||||
half-resolution packing. Full packing preserves each eye's original aspect.
|
||||
It also accepts FFmpeg's `stereo_mode` metadata (`left_right`, `top_bottom`,
|
||||
`mono`); left/right and top/bottom metadata are treated as full packing.
|
||||
Choose an explicit layout if that assumption doesn't match the file.
|
||||
Unknown or conflicting tags ask for a choice, rather than silently flattening
|
||||
stereo. The explicit selector always wins. Layout is not guessed from resolution.
|
||||
|
||||
## What was verified
|
||||
|
||||
**Verified remotely, 2026-09-28:** SteamOS 0.4.1, BUILD_ID
|
||||
`20260925.6191901`, SteamVR 2.18.1. Nobody wore the headset for these checks.
|
||||
All test media were generated by us. No paid content or DRM was involved.
|
||||
|
||||
- Stock `ffmpeg` `h264_v4l2m2m` decoded 150 frames of our 1920×1080 H.264
|
||||
test in 0.128 s (decode only); converting all frames to RGBA took 0.242 s.
|
||||
- Our ffmpeg → Python → OpenVR path submitted all 150 frames and exited 0.
|
||||
The 1280×720 prototype took 4.775 s; the full 1920×1080 player took
|
||||
4.618 s. These are wall times, not in-headset frame-rate measurements.
|
||||
Finishing a 5 s clip early showed those probes weren't paced, so the final
|
||||
player paces output at 30 fps: all 150 frames then completed in **5.025 s**.
|
||||
- The H.265 hardware decoder also completed the generated 1080p clip
|
||||
(60 frames, exit 0); this is a short compatibility check, not a 4K/8K benchmark.
|
||||
- Our actual player, launched through Frame Control's media API, displayed
|
||||
SBS and OU photos with red only in the left-eye capture and cyan only in
|
||||
the right. OpenVR's `SideBySide_Parallel` flag separates the eyes; we
|
||||
rearrange OU rows ourselves.
|
||||
- A generated 48-Gaussian `.splat` rendered in our own CPU renderer and appeared
|
||||
in both eyes with separate perspective projections.
|
||||
- Theatre's owned dark surround and screen appeared in captures. Steam's
|
||||
dashboard remained available over them. Stop removed the owned overlays
|
||||
and ended the dedicated user service. No global SteamVR setting was changed.
|
||||
- A generated H.264/AAC clip reached the Pulse audio output and completed
|
||||
all 100 video frames with exit 0. This proves the output path, not audible
|
||||
quality or lip sync.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||
The ffmpeg path above completed instead.
|
||||
|
||||
## Limits and blockers
|
||||
|
||||
- **Not verified:** worn-headset comfort, sound quality/lip sync, long movies,
|
||||
4K/8K decode, HDR, controller interaction or behaviour on other OS builds.
|
||||
Output is bounded to 1920×1080 packed pixels before OU rearrangement.
|
||||
- **Not implemented:** pause, seeking, subtitles, playlists, right-eye-first
|
||||
layouts, non-square pixel correction, VR180/360 projection and fisheye.
|
||||
This preview is a flat stereo screen, not a dome player.
|
||||
- **Native spatial-photo blocker:** HEIC/HEIF/AVIF/MPO stereo-container
|
||||
extraction isn't implemented in our viewer. We reject these rather than
|
||||
displaying one image and calling it spatial. Export both eyes to PNG/JPEG
|
||||
first. This is a current implementation gap, not a claim that the Frame
|
||||
cannot support these containers.
|
||||
- **Large/immersive splat blocker:** the owned CPU rasterizer projects 3D
|
||||
covariance into each eye and alpha-composites Gaussians, but renders a
|
||||
fixed view at 320×240 per eye. It caps each Gaussian footprint at 32 pixels
|
||||
and normalizes the scene to a two-metre box. Large scenes, PLY/SPZ, live
|
||||
head-position parallax and navigation need a GPU scene renderer; this
|
||||
version rejects files above 20,000 records. It is a stereo preview, not
|
||||
an immersive walk-through.
|
||||
- A single player can run at a time. It stops at EOF, on **Stop**, or after
|
||||
four hours in any case, so longer movies are cut off. Still images remain
|
||||
until Stop or that timeout. There is no delete action yet: remove old
|
||||
uploads from `~/Videos/FrameControl/` over SSH. The log is
|
||||
`~/.local/share/frame-control/media/player.log` on the Frame.
|
||||
- **Panel/stream theatre remains outside this media slice:** SteamVR's
|
||||
`vrcmd --dock-overlay` accepts `theater`, but docking an existing panel
|
||||
and its dimming behaviour were not verified here. The shared headset had
|
||||
workspace and stream tests active. We did not reposition their panels.
|
||||
Integration with [#22](https://github.com/saphid/frame-control/issues/22)
|
||||
and [#31](https://github.com/saphid/frame-control/issues/31) can use the
|
||||
owned rendering hook below; no second stream/window manager is introduced.
|
||||
|
||||
## Integration hook
|
||||
|
||||
`POST /api/upload` with `X-Mode: media` and `X-Filename` sends a file and
|
||||
returns its `id`. `POST /api/media` accepts:
|
||||
|
||||
```json
|
||||
{"action":"play","id":"<32 hex characters>/film_SBS.mp4","layout":"auto","theatre":true}
|
||||
```
|
||||
|
||||
Other actions are `list`, `status`, `stop`. These use the normal `X-Frame-UI`
|
||||
guard. Play reports **starting**, not a claim that frames reached the headset;
|
||||
read status for `playing`, `ended`, `stopped` or `error`.
|
||||
The own-code files are copied to `~/.local/share/frame-control/media/` and
|
||||
run in the `frame-control-media.service` systemd user unit. Stop affects only
|
||||
that unit, including its decoder child.
|
||||
|
||||
For a Frame-side stream producer, `frame_media_player.Overlay` exposes
|
||||
`create(key, width, distance, stereo=False, aspect=1, order=1)`,
|
||||
`pixels(handle, rgba, width, height)` and `close()`. Use an owned unique key,
|
||||
pass interleaved RGBA bytes (left/right halves for stereo) and always close in
|
||||
`finally`. `create` uses a head-relative transform; it does not move another
|
||||
app's panel. The player demonstrates a separate black surround overlay.
|
||||
`frame_media.stereo_pixels` converts OU to SBS. This is the narrow rendering
|
||||
hook for stream/workspace work; it doesn't capture or manage a Mac/PC stream.
|
||||
|
||||
## Optional separate app
|
||||
|
||||
You can independently install **DeoVR Video Player** (free Steam app 837380)
|
||||
if you prefer its VR180/360 features. Earlier tests on SteamOS 0.3.0,
|
||||
build `20260922.6101926` (2026-09-25), verified its OpenVR initialization and
|
||||
8K H.265 streamed VR180 decoding under Proton. Frame Control's media features
|
||||
neither install nor launch it, and do not depend on it. Its behaviour and
|
||||
file-naming conventions are not evidence about our player.
|
||||
Reference in new issue
Block a user