Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 03eaae2d39 Docs: join the list item
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:04:00 +10:00
saphidandClaude Opus 5.5 fe132e3e69 Docs: what the Frame spends power and heat on while idle, the controls, and a plan to leave it on cheaply
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:03:50 +10:00
saphidandClaude Opus 5.5 cce3d94030 Frame doctor: placeholder for the tailnet name, as docs/tailscale.md does; fix a section reference
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:41:53 +10:00
saphidandClaude Opus 5.5 e12464c8a6 Record the remote-wake tests and a Frame doctor runbook
Wake-on-WLAN arms without sudo, but the Frame never woke from a magic
packet in deep or s2idle: the WCN7850 comes back in MHI RESET and Wi-Fi
stays dead until a clean reboot. Unloading ath12k to recover oopsed the
kernel, which left truncated Steam files and broken displays for a boot.

docs/frame-doctor.md collects checks and fixes for that incident and every
other crash and failure found in the Frame's journal, coredumps and these
docs, as the spec for a future scripts/frame-doctor.sh. Reviewed over three
rounds by GPT-6 Astra (xhigh).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:11 +10:00
441 changed files with 1735 additions and 121618 deletions

No files matched your search

+2 -7
View File
@@ -22,13 +22,14 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
| iPhone/iPad app (server runs on the Frame, `FRAME_LOCAL=1`) | `docs/iphone.md` | `ios/`, `ui/local-bin/ssh` |
| Frame unreachable, Wi-Fi dead, Steam won't start (doctor runbook) | `docs/frame-doctor.md` | — |
| Power draw, heat, fan, battery wear, quiet-mode plan | `docs/power-and-heat.md` | — |
| Recovery images, factory reset, boot loops | `docs/recovery-and-images.md`, `docs/how-the-frame-works.md` | `~/Downloads/steam-frame-recovery/` |
| Test without the headset (the Frame OS image's own sshd) | `tests/frame-container/README.md` | `tests/frame-container/frame-image.sh` |
| What's still unverified | `docs/open-questions.md` | — |
@@ -49,9 +50,3 @@ running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
steps to the user (open Terminal) and keep automation to non-sudo commands.
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
- **First-party first.** For any new capability, investigate the first-party
way before anything else: Valve (SteamOS, Steam, Steam Link), Apple (the Mac
and iPhone), and KDE (the Frame's desktop is Plasma). It's usually the best
answer. If it isn't, write down why not. If it is, find what Frame Control
can do to make it easier to set up (install over SSH, pre-seed settings,
pair automatically, tell the user the one setting to turn on).
-5
View File
@@ -6,8 +6,3 @@
*.json text eol=lf
*.md text eol=lf
*.bat text eol=crlf
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
tests/fixtures/** -text
*.apk binary
*.jar binary
*.obb binary
+3 -14
View File
@@ -5,11 +5,6 @@ on:
branches: [main]
pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
runs-on: ubuntu-latest
@@ -22,9 +17,7 @@ jobs:
with:
node-version: "24"
- name: Install zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
sh -n ui/local-bin/ssh
@@ -42,9 +35,7 @@ jobs:
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: App tests
run: node --test app/test/*.test.js
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
@@ -91,8 +82,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
-53
View File
@@ -1,53 +0,0 @@
name: PC host libraries
# PC in the headset (Experimental). The Windows job builds the native adapter
# and runs the real-encoder protocol tests; the installer's own bundle is
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
# in the app, but shares capture.c and controller.c, so it's still built here.
# These builds add the GPL x264 encoder for headless tests only: they're never
# uploaded or shipped.
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py --with-x264
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
FRAME_PC_REQUIRE_X264: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py --with-x264
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
+5 -95
View File
@@ -2,60 +2,19 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that change how the installers are built (the Electron app, the
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
# Pull requests that touch the app build the same installers as artifacts.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
needs: draft
# Still runs for pull requests and manual runs, where the draft job is skipped.
if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -75,71 +34,22 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "24"
# PC in the headset (Experimental, Windows only): our capture adapter and
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
- uses: msys2/setup-msys2@v2
if: runner.os == 'Windows'
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
if: runner.os == 'Windows'
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Windows PC streaming libraries
if: runner.os == 'Windows'
shell: msys2 {0}
run: python desktop/build.py --out app/build/desktop/win-x64
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
- name: Check the Windows installer installs every file
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# From the installed app, so this also proves the installer carries the
# libraries. After the check above, and without writing __pycache__,
# so it can't change what that check compares.
- name: Test the installed app's Python with the Windows PC libraries
if: runner.os == 'Windows'
shell: bash
env:
PYTHONDONTWRITEBYTECODE: "1"
run: |
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
python="$resources/python/python.exe"
native="$(cygpath -w "$resources/desktop/bundle")"
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with:
name: frame-control-${{ matrix.os }}
path: |
+1 -15
View File
@@ -1,21 +1,7 @@
.DS_Store
__pycache__/
apk-catalog/data/cache/
apk-catalog/data/index-v2*.json*
apk-catalog/data/index-v2.json*
compat-db/.env.lakebed.server
compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
-45
View File
@@ -1,45 +0,0 @@
# Changelog
## Unreleased
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
VM streamed a real desktop to the Frame; physical input remains unverified.
- Add read-only battery health in Tools: charging state, temperature, cycle count,
and available capacity with explicit unknown states. The parser was checked
on a real Frame; the connected desktop UI was tested with synthetic readings.
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
library/game install-size breakdown, with unknown sizes, partial inventories
and permission/missing-drive states. Includes instructions for Steam's own
Move UI; Frame Control does not move or delete game files. The read-only
parser was checked on a real Frame; the connected UI and game moves were not.
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
with verified SSH identity, private summaries and actionable next steps
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
manual; restart-loop evidence stops deeper diagnostics. The real Frame
verified the SSH connection and safety stop; deeper checks were skipped.
- Fixed headset smoke cleanup reporting success while a smoke-title process
could remain running after its files were removed. Cleanup now stops and
rechecks processes tied to exact reserved test-title paths, uses PID handles
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
+16 -50
View File
@@ -35,8 +35,8 @@ See what the headset sees, install games and Android apps, move files and text a
<tr>
<td width="50%" valign="top">
**👓 Headset view and Desktop**<br>
Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
**👓 Headset view**<br>
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
</td>
<td width="50%" valign="top">
@@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
</td>
<td valign="top">
@@ -63,17 +63,14 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
<tr>
<td valign="top">
**📁 Files and games**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
</td>
<td valign="top">
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
**⌨️ Type on the headset**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
</td>
</tr>
@@ -89,27 +86,13 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
**⚡ One-click tools**<br>
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</td>
</tr>
<tr>
<td colspan="2" valign="top">
**💾 Backup and recovery**<br>
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
</td>
</tr>
</table>
The optional [Family and comfort](docs/family-comfort.md) card adds session
limits, breaks, local alerts and one-click casting. A session copies a small
Frame Control worker into your headset user account.
For the other features, nothing is installed on the Frame: the app uses what SteamOS
Nothing is installed on the Frame for any of this: the app uses what SteamOS
already ships (sideloading a game copies Valve's own devkit scripts to
`~/devkit-utils`, as Valve's Devkit Client does). The optional
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
## Install
@@ -124,10 +107,6 @@ already ships (sideloading a game copies Valve's own devkit scripts to
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
From 0.4 it updates itself: when a new version is published, a banner offers
**Update and restart**. It sends anonymous usage statistics, which you can turn
off. Sharing compatibility results and error details is opt-in. See
[docs/privacy.md](docs/privacy.md).
Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours.
@@ -196,11 +175,9 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
## Feedback
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
Windows and Linux. The quickest way is the
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
needed, and it opens an issue here. Please include:
- what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System)
@@ -221,19 +198,13 @@ Frame's software fits together, all checked against a real headset and labelled
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
@@ -266,14 +237,9 @@ cd app && npm install && npm start # run the app from the checkout
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
into a draft release, which reaches users once published. See
[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
into the release. See [building](docs/frame-control.md#building).
## License
[MIT](LICENSE). The apps also ship other people's software under its own
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
-17
View File
@@ -1,17 +0,0 @@
# Third-party software in Frame Control
Frame Control's own code is under the [MIT licence](LICENSE). The apps also
ship other people's software, unchanged, each under its own licence:
| What | Where | Licence | Details |
|---|---|---|---|
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
Frame Control starts KDE Connect and talks to it over its network protocol;
it doesn't link to it or include its code. On Windows, Frame Control's own
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
-14
View File
@@ -6,25 +6,11 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
-79
View File
@@ -1,79 +0,0 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+1 -21
View File
@@ -1,9 +1,7 @@
// Downloads what the app bundles so users install nothing else: a standalone
// Python (python-build-standalone), adb (Android platform-tools) and a CA
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
// packages for every build), into ../frame/kdeconnect/packages as listed in
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
// copies into the app's resources. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
@@ -129,26 +127,8 @@ async function fetch(os, arch) {
console.log(`${key}: ${version} -> ${out}`);
}
// As frame/kdeconnect/fetch.py does for the iPhone app's bundle.
async function fetchKdeConnect() {
const dir = path.join(__dirname, "..", "..", "frame", "kdeconnect");
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "packages.json"), "utf8"));
const out = path.join(dir, "packages");
fs.mkdirSync(out, { recursive: true });
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
for (const p of manifest.packages) {
const file = path.join(out, p.file);
if (fs.existsSync(file) && sha(file) === p.sha256) continue;
await download(manifest.release + p.file, p.sha256, file);
}
const wanted = new Set(manifest.packages.map((p) => p.file));
for (const f of fs.readdirSync(out)) if (!wanted.has(f)) fs.rmSync(path.join(out, f), { recursive: true, force: true });
console.log(`KDE Connect for the Frame: ${manifest.packages.length} packages -> ${out}`);
}
(async () => {
const [os, ...archs] = process.argv.slice(2);
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
for (const arch of archs) await fetch(os, arch);
await fetchKdeConnect();
})().catch((e) => { console.error(e.message); process.exit(1); });
+25 -276
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -10,9 +10,6 @@ const net = require("net");
const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { preferenceStore } = require("./preferences");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -29,7 +26,6 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
let server = null;
let url = null;
@@ -118,12 +114,7 @@ function ping(target) {
}
async function startServer() {
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
// which sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
@@ -153,7 +144,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -163,76 +154,51 @@ async function startServer() {
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
// Resolves once it has exited (or after 20 s), so a replacement can take the server
// lock: server.py allows one per user.
function endServer(child) {
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
: new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message, title = "Frame Control couldn't start") {
function errorPage(message) {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
// Restarts that overlap share one: two could each start a server, and the one
// that lost the lock would leave the app pointing at nothing.
let restarting = null;
function restartServer() {
if (!restarting) {
restarting = (async () => {
const old = server;
server = null;
url = null;
if (old) await endServer(old);
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
await load();
})().finally(() => { restarting = null; });
}
return restarting;
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
if (!url) await startServer();
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
@@ -276,129 +242,8 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// Text from the headset's clipboard onto this computer's.
ipcMain.handle("clipboard:write", (e, text) => {
if (!fromUi(e) || typeof text !== "string") return false;
clipboard.writeText(text);
return true;
});
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
let devices = [];
ipcMain.on("devices:changed", (e, list) => {
if (!fromUi(e) || !Array.isArray(list)) return;
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
// SSH through the server, so it goes to the headset and address the app is using
// (with its own pinned identity), and refuses when there's none.
function openSsh() {
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
const body = JSON.stringify({ what: "terminal" });
const req = http.request(new URL("/api/open", url), {
method: "POST", timeout: 15000,
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
}, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => {
if (res.statusCode === 200) return;
let why = `HTTP ${res.statusCode}`;
try { why = JSON.parse(data).error || why; } catch {}
dialog.showErrorBox("Couldn't open SSH", why);
});
});
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(body);
}
function showDevices() {
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
}
ipcMain.handle("comfort:notify", (e, message) => {
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
return new Promise((resolve, reject) => {
const notification = new Notification({title: "Frame Control", body: message});
const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
notification.once("show", () => { clearTimeout(timer); resolve(true); });
notification.once("failed", (_event, error) => {
clearTimeout(timer);
reject(new Error("Notification delivery failed. Check system notification settings: " + error));
});
notification.show();
});
});
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
// The token goes from the sign-in window's own session straight to the server's
// connect path; the page asks for it and only hears the server's answer.
function postApi(apiPath, body) {
return new Promise((resolve, reject) => {
if (!url) return reject(new Error("Frame Control's server isn't running."));
const data = JSON.stringify(body);
const req = http.request(new URL(apiPath, url), {
method: "POST", timeout: 60000,
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
"Content-Length": Buffer.byteLength(data) },
}, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (c) => { text += c; });
res.on("end", () => {
let parsed = {};
try { parsed = JSON.parse(text); } catch {}
resolve({ status: res.statusCode, data: parsed });
});
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(data);
});
}
let metaSignIn = null;
const meta = () => (metaSignIn ||= createMetaSignIn({
BrowserWindow, session, shell, parent: () => win,
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
}));
ipcMain.handle("meta:signin", (e, acknowledged) => {
if (!fromUi(e)) return null;
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
return meta().signIn();
});
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
@@ -431,81 +276,6 @@ ipcMain.on("install-link:ready", (e) => {
deliverLinks();
});
// ---- updates (app/updater.js, docs/releasing.md) ----
// Checked shortly after launch and every few hours; the page shows a banner and
// the Update button calls installUpdate.
const UPDATE_EVERY = 6 * 3600 * 1000;
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
function publicUpdate() {
const r = update.latest;
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
latest: r && { version: r.version, notes: r.notes, page: r.page },
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
}
function setUpdate(fields) {
Object.assign(update, fields);
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
}
async function checkForUpdate({ manual = false } = {}) {
if (["checking", "downloading", "ready"].includes(update.status)) return;
setUpdate({ status: "checking", error: null });
try {
const latest = await updater.latestRelease();
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
execPath: process.execPath, env: process.env,
exists: fs.existsSync, writable: updater.writable });
if (updater.isNewer(latest.version, update.current)) {
setUpdate({ status: "available", latest, how });
if (manual) offerUpdateDialog();
} else {
setUpdate({ status: "none", latest, how });
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
detail: `You have ${update.current}, the newest version.` });
}
} catch (e) {
// A failed check: nothing to install, and never an older release kept from before.
setUpdate({ status: "check-failed", error: e.message, latest: null });
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
}
}
async function offerUpdateDialog() {
const r = update.latest;
const { response } = await dialog.showMessageBox(win, {
type: "info", message: `Frame Control ${r.version} is available`,
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
});
if (response === 0) installUpdate();
}
async function installUpdate() {
// "error" here only ever means an install failed, so trying again is safe.
if (update.status !== "available" && update.status !== "error") return;
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
setUpdate({ status: "downloading", progress: 0, error: null });
try {
const start = await updater.prepare(update.latest, update.how,
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
setUpdate({ status: "ready", progress: 1 });
start();
quitting = true;
app.quit();
} catch (e) {
setUpdate({ status: "error", error: e.message });
}
}
function scheduleUpdateChecks() {
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
setTimeout(checkForUpdate, 8000);
setInterval(checkForUpdate, UPDATE_EVERY).unref();
}
function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.)
@@ -522,7 +292,7 @@ function createWindow() {
title: "Frame Control", backgroundColor: BG, show: false,
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
@@ -556,37 +326,25 @@ async function runInTerminal(argv) {
}
}
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
async function setUpConnection(name = activeAlias()) {
if (!ALIAS_RE.test(name)) return;
const alias = `FRAME_ALIAS=${name}`;
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
}
function buildMenu() {
const template = [
...(IS_MAC ? [{ label: app.name, submenu: [
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
{ type: "separator" }, { role: "services" }, { type: "separator" },
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
] }] : []),
...(IS_MAC ? [{ role: "appMenu" }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: () => setUpConnection() },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
label: "Headset",
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
}] : []),
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ label: "Set Up Connection…", click: setUpConnection },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
@@ -606,15 +364,7 @@ function buildMenu() {
...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
submenu: [
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
{ label: "Report a Problem…", click: () => {
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
} },
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
],
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
@@ -637,7 +387,6 @@ if (!app.requestSingleInstanceLock()) {
registerScheme();
buildMenu();
createWindow();
scheduleUpdateChecks();
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
-252
View File
@@ -1,252 +0,0 @@
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
// access token cookie (oc_ac_at) that page sets is handed to the local server's
// existing connect path (format check, "who am I" with Meta, 0600 file).
//
// The window has its own persistent session (PARTITION), so the cookie is read only
// from there: never from Frame Control's main window, never from any browser. It has
// no preload and no Node, and it only navigates within Meta's login sites; anything
// else opens in the system browser. Nothing here logs the token, and the page only
// ever gets back the server's answer (connected, display name), never the token.
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
const crypto = require("crypto");
const PARTITION = "persist:meta-signin";
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
const LOGIN_URL = "https://secure.oculus.com";
const COOKIE = "oc_ac_at";
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
const TIMEOUT = 10 * 60 * 1000; // as BSManager
const underDomain = (host, domains) => {
host = String(host || "").replace(/^\./, "").toLowerCase();
return domains.some((d) => host === d || host.endsWith("." + d));
};
// Plain HTTPS on the default port, to one of the login sites.
function allowedUrl(raw) {
let u;
try { u = new URL(String(raw)); } catch { return false; }
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
return underDomain(u.hostname, ALLOWED);
}
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
function allowedSubframeUrl(raw) {
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
let u;
try { u = new URL(String(raw)); } catch { return false; }
return u.protocol === "https:" && !u.username && !u.password;
}
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
// check_token (ui/frame_meta.py) has the final word.
function looksLikeUserToken(v) {
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
}
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
// Anything token-shaped out of a message, in case a server error ever carried one.
function scrub(text, token) {
let s = String(text == null ? "" : text);
if (token) s = s.split(token).join("<redacted>");
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
}
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
let active = null; // { win, promise, finish }
const pending = new Set(); // cancels still to reach the server, from flows already finished
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
const openOutside = (target) => {
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
};
function signIn() {
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
if (active) { active.win.focus(); return active.promise; }
const ses = session.fromPartition(PARTITION);
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
ses.setPermissionCheckHandler(() => false);
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
if (ses.getUserAgent && ses.setUserAgent) {
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
}
const p = parent();
const win = new BrowserWindow({
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
...(p ? { parent: p } : {}),
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
});
const wc = win.webContents;
let resolve;
const promise = new Promise((r) => { resolve = r; });
let done = false, busy = false, again = false, lastError = null, blocked = null;
const tried = new Set(); // fingerprints of values already sent, so each goes once
// The server refuses to save a connect from an attempt this window gave up on.
const attemptId = crypto.randomUUID();
const onCookie = (_e, cookie, _cause, removed) => {
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
};
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
if (timer.unref) timer.unref();
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
// then has the server drop this attempt: only once the connect under way has settled,
// so the server always sees the cancel after the connect (and deletes the token if
// that connect saved it). The returned promise settles when that is done.
let finished = null;
let inflight = null; // the last connect sent
function finish(result) {
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
done = true;
ses.cookies.removeListener("changed", onCookie);
clearTimeout(timer);
if (active && active.win === win) active = null;
if (!win.isDestroyed()) win.destroy();
resolve(result);
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
pending.add(finished);
finished.then(() => pending.delete(finished));
return finished;
}
async function attempt() {
if (done) return;
if (busy) { again = true; return; }
busy = true;
try {
do {
again = false;
// Only this window's own session, and only Meta's Oculus cookie.
const cookies = await ses.cookies.get({ name: COOKIE });
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
if (!c || done) continue;
const key = fingerprint(c.value);
if (tried.has(key)) continue;
tried.add(key);
let r;
try {
inflight = connect(c.value, attemptId);
r = await inflight;
} catch {
lastError = "Frame Control's local server didn't answer.";
continue;
}
if (done) return;
const d = (r && r.data) || {};
if (r && r.status === 200 && d.connected) {
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
return;
}
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
} while (again && !done);
} catch {
// The session went away (the window closed); nothing to do.
} finally {
busy = false;
}
}
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
// Every frame: the window itself stays on the list; frames inside it may load any
// HTTPS page (captchas), and one going anywhere else is just stopped.
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
// A stopped page change in the window itself says where it went (the host only), so
// closing the stuck window explains why instead of "closed before you signed in".
const stopped = (to) => {
let host = "";
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
};
wc.on("will-frame-navigate", (e) => {
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
e.preventDefault();
if (e.isMainFrame) stopped(e.url);
});
// Redirects fire for frames too: the same rule per frame (details on the event in
// current Electron, positional arguments in older ones; unknown counts as the window).
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
const to = e && e.url !== undefined ? e.url : target;
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
e.preventDefault();
if (main) stopped(to);
});
// A page's beforeunload never keeps the window open.
wc.on("will-prevent-unload", (e) => e.preventDefault());
wc.setWindowOpenHandler(({ url: target }) => {
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
else openOutside(target);
return { action: "deny" };
});
// Signed in: the cookie changes, or the window lands back on Meta's store site.
ses.cookies.on("changed", onCookie);
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
win.on("closed", () => finish({ ok: false, cancelled: true,
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
: "The sign-in window was closed before you signed in." }));
active = { win, promise, finish };
wc.loadURL(LOGIN_URL).catch(() => {});
return promise;
}
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
// refuse to save it), destroys the window, then clears everything
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
// It goes round until no window is open and no connect or cancel is left, so nothing
// started before it can set a token after it (or after the Disconnect that follows).
function signOut() {
if (!signingOut) {
signingOut = (async () => {
for (;;) {
if (active) {
const { win, finish } = active;
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
// Clear only once nothing can write to the session again.
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
} else if (pending.size) {
// A connect under way settles and is cancelled before the caller (Disconnect)
// deletes the account, so it can't be saved afterwards.
await Promise.all([...pending]);
} else {
break;
}
}
const ses = session.fromPartition(PARTITION);
await ses.clearStorageData();
await ses.clearCache();
if (ses.clearAuthCache) await ses.clearAuthCache();
return true;
})().finally(() => { signingOut = null; });
}
return signingOut;
}
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
}
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.12",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.12",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+7 -68
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.12",
"version": "0.3.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,8 +9,8 @@
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
@@ -29,7 +29,6 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -38,9 +37,6 @@
"main.js",
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"meta-signin.js",
"package.json",
"build/icon.png"
],
@@ -50,18 +46,7 @@
"to": "ui",
"filter": [
"*.py",
"*.html",
"*.js",
"*.css",
"telemetry.json"
]
},
{
"from": "../ui/apk_sources",
"to": "ui/apk_sources",
"filter": [
"*.py",
"*.json"
"*.html"
]
},
{
@@ -76,16 +61,7 @@
"to": "frame/android",
"filter": [
"*.sh",
"*.py",
"*.js"
]
},
{
"from": "../frame/openxr-compat",
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so.gz"
"*.py"
]
},
{
@@ -96,16 +72,6 @@
"!**/__pycache__/**"
]
},
{
"from": "../frame/kdeconnect",
"to": "frame/kdeconnect",
"filter": [
"packages.json",
"NOTICE.md",
"LICENSES/**/*",
"packages/*.pkg.tar.zst"
]
},
{
"from": "../apk-catalog",
"to": "apk-catalog",
@@ -128,14 +94,6 @@
"filter": [
"**/*"
]
},
{
"from": "../THIRD_PARTY_NOTICES.md",
"to": "THIRD_PARTY_NOTICES.md"
},
{
"from": "../LICENSE",
"to": "LICENSE"
}
],
"mac": {
@@ -147,19 +105,9 @@
"dmg",
"zip"
],
"extraResources": [
{
"from": "../mac/bin",
"to": "mac/bin",
"filter": [
"frame-mac-view"
]
}
],
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network.",
"NSScreenCaptureUsageDescription": "Frame Control shows your Mac's windows and screens inside the Steam Frame when you ask it to."
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
},
"artifactName": "Frame-Control-mac-${arch}.${ext}"
},
@@ -198,16 +146,7 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
"artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
-27
View File
@@ -1,27 +0,0 @@
// Client choices live outside the local server's changing URL/origin.
const fs = require("node:fs");
const path = require("node:path");
const KEYS = ["mvSeparate", "padUsed", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
function read() {
try {
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
} catch { return {}; }
}
function write(key, value) {
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
const saved = { ...read(), [key]: value };
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
fs.renameSync(tmp, file);
return true;
}
return { read, write };
}
module.exports = { preferenceStore };
+3 -43
View File
@@ -1,56 +1,16 @@
// Lets the page read and write this computer's clipboard through Electron, so sending
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It can open Set Up Connection when the headset can't be reached.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js. It opens the Sign in
// with Meta window and signs out of it (app/meta-signin.js).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
preferences: {
read: () => ipcRenderer.invoke("preferences:read"),
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
},
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
ipcRenderer.removeAllListeners("use-device");
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
},
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
// Updates (app/updater.js): the page shows a banner and an Update button.
update: {
get: () => ipcRenderer.invoke("update:get"),
check: () => ipcRenderer.invoke("update:check"),
install: () => ipcRenderer.invoke("update:install"),
onState: (cb) => {
ipcRenderer.removeAllListeners("update:state");
ipcRenderer.on("update:state", (_e, s) => cb(s));
},
},
// Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
onReportProblem: (cb) => {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
// answer is the server's (connected, display name), never the token.
meta: {
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
signOut: () => ipcRenderer.invoke("meta:signout"),
},
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
-578
View File
@@ -1,578 +0,0 @@
// Run: node --test app/test/
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
const nodeTest = require("node:test");
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
const assert = require("node:assert");
const { EventEmitter } = require("node:events");
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
class FakeCookies extends EventEmitter {
constructor() { super(); this.store = []; this.reads = 0; }
async get(filter) {
this.reads++;
return this.store.filter((c) => !filter.name || c.name === filter.name);
}
set(cookie) { // as the page would: store it, then tell listeners
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
this.emit("changed", {}, cookie, "explicit", false);
}
}
class FakeSession {
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
setPermissionRequestHandler(fn) { this.permission = fn; }
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
setUserAgent(ua) { this.ua = ua; }
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
async clearCache() { this.cleared.push(["cache"]); }
async clearAuthCache() { this.cleared.push(["auth"]); }
}
function fakes({ timeout } = {}) {
const log = []; // the order things happened in
const sessions = new Map();
const defaultSession = new FakeSession("default", log);
const session = {
defaultSession,
fromPartition(name) {
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
return sessions.get(name);
},
};
const windows = [];
class BrowserWindow extends EventEmitter {
constructor(opts) {
super();
this.opts = opts;
this.destroyed = false;
this.veto = false; // a page whose beforeunload keeps the window open on close()
this.focused = 0;
const wc = new EventEmitter();
wc.loaded = [];
wc.loadURL = async (u) => { wc.loaded.push(u); };
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
// Deliberately a different session: the code must read the partition's, not guess.
wc.session = defaultSession;
this.webContents = wc;
windows.push(this);
}
isDestroyed() { return this.destroyed; }
focus() { this.focused++; }
close() { if (this.destroyed || this.veto) return; this.destroy(); }
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
}
const opened = [];
const shell = { openExternal: (u) => { opened.push(u); } };
const calls = [], attempts = [], cancels = [];
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
const cancel = async (attempt) => {
cancels.push(attempt);
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
};
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
meta: () => session.fromPartition(PARTITION) };
}
const tick = () => new Promise((r) => setImmediate(r));
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
function navigate(wc, event, target) {
let prevented = false;
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
return prevented;
}
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
"https://www.instagram.com/accounts/login/"]) {
assert.ok(allowedUrl(ok), ok);
}
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
"data:text/html,hi", "not a url"]) {
assert.ok(!allowedUrl(bad), bad);
}
});
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
const f = fakes();
f.flow.signIn();
const [w] = f.windows;
const wp = w.opts.webPreferences;
assert.strictEqual(wp.partition, PARTITION);
assert.strictEqual(PARTITION, "persist:meta-signin");
assert.strictEqual(wp.contextIsolation, true);
assert.strictEqual(wp.nodeIntegration, false);
assert.strictEqual(wp.sandbox, true);
assert.strictEqual(wp.preload, undefined);
assert.strictEqual(wp.devTools, false);
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
// Permission prompts (camera, notifications...) are refused.
let granted = null;
f.meta().permission(null, "media", (v) => { granted = v; });
assert.strictEqual(granted, false);
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
w.close();
});
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
await settle();
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
f.windows[0].close();
});
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
const f = fakes();
// The same cookie elsewhere: the default session and another partition. Never read.
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const done = f.flow.signIn();
const w = f.windows[0];
// A cookie with the right name on another site, and a signed-out value: ignored.
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
await settle();
assert.deepStrictEqual(f.calls, []);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.strictEqual(r.ok, true);
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
});
test("landing on the logged-in page picks up a cookie set earlier", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
await settle();
assert.deepStrictEqual(f.calls, []);
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
assert.strictEqual((await done).ok, true);
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.ok(f.windows[0].isDestroyed());
assert.ok(!f.flow.isOpen());
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
assert.ok(!JSON.stringify(r).includes(TOKEN));
// The cookie listener is gone: a later change sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
const f = fakes();
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
: { status: 200, data: { connected: true, user: null } });
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.ok(!f.windows[0].isDestroyed());
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
assert.strictEqual(r.ok, true);
assert.strictEqual(r.user, null);
});
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
const seen = [];
const names = ["log", "info", "warn", "error", "debug", "trace"];
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
const writes = [process.stdout.write, process.stderr.write];
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
let r;
try {
const f = fakes();
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // the user gives up
r = await done;
} finally {
for (const n of names) console[n] = saved[n];
[process.stdout.write, process.stderr.write] = writes;
}
assert.strictEqual(r.ok, false);
assert.strictEqual(r.cancelled, true);
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
assert.match(r.error, /<redacted>/);
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
});
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
const f = fakes();
const a = f.flow.signIn();
const b = f.flow.signIn();
assert.strictEqual(a, b);
assert.strictEqual(f.windows.length, 1);
assert.strictEqual(f.windows[0].focused, 1);
f.windows[0].close();
const r = await a;
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
assert.deepStrictEqual(f.calls, []);
});
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
const f = fakes();
f.session.fromPartition("persist:other");
const pending = f.flow.signIn();
await f.flow.signOut();
const r = await pending;
assert.strictEqual(r.ok, false);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
// The next sign-in starts with a new window.
f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.windows[1].close();
});
test("the first look at a cookie value matches the server's token rules", () => {
assert.ok(looksLikeUserToken(TOKEN));
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
assert.ok(!looksLikeUserToken(bad), String(bad));
}
});
function frameNav(wc, url, isMainFrame) {
let prevented = false;
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
return prevented;
}
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
assert.strictEqual(frameNav(wc, ok, false), false, ok);
}
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
"chrome://settings", "not a url"]) {
assert.strictEqual(frameNav(wc, bad, false), true, bad);
}
// The window itself: Meta's sites only.
assert.strictEqual(frameNav(wc, "about:blank", true), true);
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
f.windows[0].destroy();
});
function redirect(wc, url, isMainFrame, style) {
let prevented = false;
const e = { preventDefault: () => { prevented = true; } };
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
else wc.emit("will-redirect", e, url, false, isMainFrame);
return prevented;
}
test("redirects follow the same rule per frame", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
for (const style of ["details", "positional"]) {
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
// The window: Meta's sites only.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
}
// Without frame information it is treated as the window.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
assert.deepStrictEqual(f.opened, []);
f.windows[0].destroy();
});
test("a redirect the window stops is named when the window is closed", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
f.windows[0].close();
const r = await p;
assert.strictEqual(r.ok, false);
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
assert.doesNotMatch(r.error, /SECRET|code=/);
});
test("a stopped redirect is forgotten once the window loads a page again", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
f.windows[0].close();
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
});
test("a page's beforeunload can't keep the window open", async () => {
const f = fakes();
const done = f.flow.signIn();
const w = f.windows[0];
let prevented = false;
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
assert.ok(prevented);
// Even if close() would be vetoed, finishing destroys the window.
w.veto = true;
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
assert.strictEqual((await done).ok, true);
assert.ok(w.isDestroyed());
});
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
for (const outcome of ["saved", "refused", "failed"]) {
const f = fakes();
let release, fail;
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
f.windows[0].close(); // the user gives up while the server and Meta are answering
const r = await done; // the page hears at once
assert.strictEqual(r.ok, false);
await settle();
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
if (outcome === "saved") release({ status: 200, data: { connected: true } });
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
else fail(new Error("socket hang up"));
await settle();
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
assert.deepStrictEqual(f.calls, [TOKEN]);
}
});
test("closing before any connect needs no cancel", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
await settle();
f.windows[0].close();
assert.strictEqual((await done).ok, false);
await f.flow.signOut();
await settle();
assert.deepStrictEqual(f.calls, []);
assert.deepStrictEqual(f.cancels, []);
});
test("a timeout during a connect cancels after it settles", async () => {
const f = fakes({ timeout: 50 });
let release;
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.match(r.error, /timed out/);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.cancels, []);
release({ status: 400, data: { error: "no" } });
await settle();
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.log.length = 0;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
assert.strictEqual((await done).ok, false);
await settle();
assert.strictEqual(signedOut, false); // still waiting for the connect
assert.deepStrictEqual(f.cancels, []);
release({ status: 200, data: { connected: true } }); // it saved, just now
await out;
// Only now would the page post its disconnect: the cancel (which deletes what that
// connect saved) has reached the server, and the session is cleared.
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("a later disconnect still waits for a closed window's connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await done;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; });
await settle();
assert.strictEqual(signedOut, false);
release({ status: 400, data: { error: "no" } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
const f = fakes();
// Nothing sent: closing cancels nothing.
const a = f.flow.signIn();
f.windows[0].close();
await a;
assert.deepStrictEqual(f.cancels, []);
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[1].veto = true; // and its page tries to stay open
f.log.length = 0;
await f.flow.signOut();
assert.strictEqual((await b).ok, false);
assert.ok(f.windows[1].isDestroyed());
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
// After sign-out a new cookie in the partition sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("each sign-in has its own attempt id", async () => {
const f = fakes();
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await a;
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
f.windows[1].close();
await b;
await settle();
assert.strictEqual(f.attempts.length, 2);
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
assert.deepStrictEqual(f.cancels, f.attempts);
});
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const first = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
assert.strictEqual((await first).ok, false);
assert.ok(f.flow.isSigningOut());
// The page answered, so the user tries again at once: refused, no window, nothing sent.
const second = await f.flow.signIn();
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
assert.strictEqual(f.windows.length, 1);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
// A second sign-out joins the first.
assert.strictEqual(f.flow.signOut(), out);
release({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
assert.ok(!f.flow.isSigningOut());
// Once it is done, signing in works again.
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
const third = f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
assert.strictEqual((await third).ok, true);
});
test("sign-out goes round until no window and no connect or cancel is left", async () => {
const f = fakes();
let releaseA;
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // window A closed; its cancel waits on its connect
await a;
// Window B is open, with its own connect under way, when sign-out starts.
let releaseB;
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
let finished = false;
const out = f.flow.signOut().then(() => { finished = true; });
await b;
releaseB({ status: 400, data: { error: "no" } });
await settle();
assert.strictEqual(finished, false); // A's connect is still under way
releaseA({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
assert.strictEqual(f.attempts.length, 2);
assert.strictEqual(f.log[f.log.length - 1], "clear");
});
-68
View File
@@ -1,68 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { preferenceStore } = require("../preferences");
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return path.join(dir, "client-preferences.json");
}
test("desktop packaging includes every local application module dependency", () => {
const root = path.resolve(__dirname, "..");
const packaged = new Set(require("../package.json").build.files);
for (const file of ["main.js", "preload.js"]) {
const source = fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
const target = match[1].slice(2) + ".js";
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
}
}
});
test("client choices survive a new native session and local server origin", t => {
const file = fixture(t);
const first = preferenceStore(file);
assert.deepEqual(first.read(), {});
first.write("mvSeparate", "0");
first.write("padUsed", "1");
// A new process/session reads the same client store, regardless of its URL.
const second = preferenceStore(file);
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
second.write("mvSeparate", "1");
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the non-sensitive binary choices can enter the native store", t => {
const file = fixture(t), store = preferenceStore(file);
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
assert.throws(() => store.write(key, value), /Invalid preference/);
}
assert.equal(fs.existsSync(file), false);
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
assert.deepEqual(store.read(), { mvSeparate: "0" });
store.write("padUsed", "1");
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
});
test("a missing or damaged preference file retains usable defaults", t => {
const file = fixture(t), store = preferenceStore(file);
assert.deepEqual(store.read(), {});
for (const bad of ["{broken", "null", "[]", '"text"']) {
fs.writeFileSync(file, bad);
assert.deepEqual(store.read(), {});
}
store.write("padUsed", "1");
assert.deepEqual(store.read(), { padUsed: "1" });
});
test("the PC in the headset opt-in is remembered and off unless saved", t => {
const file = fixture(t), store = preferenceStore(file);
assert.equal(store.read().pcView, undefined);
store.write("pcView", "1");
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
});
-80
View File
@@ -1,80 +0,0 @@
// Run: node --test app/test/
const test = require("node:test");
const assert = require("node:assert");
const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
test("versions compare numerically, and a release beats its pre-releases", () => {
assert.ok(isNewer("0.3.10", "0.3.9"));
assert.ok(isNewer("v1.0.0", "0.9.9"));
assert.ok(!isNewer("0.3.1", "0.3.1"));
assert.ok(!isNewer("0.3.0", "0.3.1"));
assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
assert.ok(!isNewer("garbage", "0.1.0"));
});
test("asset names match what electron-builder publishes", () => {
assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
});
const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
test("macOS updates in place only from a writable, non-translocated location", () => {
const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
{ method: "mac-zip", bundle: "/Applications/Frame Control.app" });
const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
});
test("Windows needs the installer's copy; Linux needs an AppImage", () => {
const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
"appimage");
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
});
test("update.json assets always download from this repository's release", () => {
const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
{ name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
});
-263
View File
@@ -1,263 +0,0 @@
// Update checks and self-update for the desktop app (docs/releasing.md).
//
// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
// and pre-releases never count, so a build reaches people only when the
// maintainer publishes it after testing (scripts/publish-release.sh). That
// script attaches update.json (version, notes, each asset's SHA-256), read
// through github.com's latest/download link: the REST API allows only 60
// unauthenticated requests an hour per IP address, shared by everyone behind
// the same router, so it's only the fallback.
//
// Every download is checked against the SHA-256 digest GitHub records for the
// asset before anything is replaced. How the update is applied:
// macOS the .zip: unpacked next to the running app, swapped in by a small
// script once the app has quit, then reopened.
// Windows the NSIS installer, run silently over the current install; it
// reopens the app. A copy unpacked from the .zip is updated by hand.
// Linux the AppImage replaces itself; .deb installs are updated by hand.
// When the app can't update itself it opens the release page instead.
const { execFile, spawn } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const os = require("os");
const path = require("path");
const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
const RELEASES = `https://github.com/${REPO}/releases`;
// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
function parseVersion(v) {
const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
}
function isNewer(candidate, current) {
const a = parseVersion(candidate), b = parseVersion(current);
if (!a || !b) return false;
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
if (a.pre === b.pre) return false;
if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
if (!b.pre) return false;
return a.pre > b.pre;
}
// The asset this copy of the app updates from, by the names electron-builder gives them.
function assetName(platform, arch, method) {
if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
return null;
}
// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
if (platform === "darwin") {
const bundle = macBundle(execPath);
if (!bundle) return { method: "manual", why: "can't find the app bundle" };
if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
return { method: "manual", why: "move Frame Control to Applications first" };
}
if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
return { method: "mac-zip", bundle };
}
if (platform === "win32") {
// electron-builder's NSIS install puts its uninstaller next to the app.
const dir = path.dirname(execPath);
if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
return { method: "manual", why: "not installed with the installer" };
}
if (platform === "linux" && env.APPIMAGE) {
if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
return { method: "appimage", appImage: env.APPIMAGE };
}
return { method: "manual", why: "installed from a package" };
}
function macBundle(execPath) {
const i = execPath.indexOf(".app/Contents/MacOS/");
return i < 0 ? null : execPath.slice(0, i + 4);
}
function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
res.resume();
const next = new URL(res.headers.location, url);
if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
}
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
resolve(res);
});
req.on("timeout", () => req.destroy(new Error("timed out")));
req.on("error", reject);
});
}
async function getJson(url, headers) {
const res = await get(url, { headers });
let body = "";
for await (const chunk of res) body += chunk;
return JSON.parse(body);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
}
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
async function latestRelease() {
try {
return fromManifest(await getJson(MANIFEST));
} catch (e) {
if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
}
return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
}
async function download(asset, dest, onProgress) {
const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
const res = await get(asset.url, { timeout: 60000 });
const total = +res.headers["content-length"] || asset.size || 0;
const hash = crypto.createHash("sha256");
// "wx": a new file only, never through an existing file or symlink at that path.
const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
let done = 0;
await new Promise((resolve, reject) => {
res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
res.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
res.pipe(out);
});
if (hash.digest("hex") !== m[1]) {
fs.rmSync(dest, { force: true });
throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
}
}
const run = (cmd, args) => new Promise((resolve, reject) =>
execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
// Waits for this process to exit, swaps the new bundle in (putting the old one
// back if that fails), and reopens the app.
const MAC_SWAP = `set -u
pid="$1"; app="$2"; new="$3"; stage="$4"
while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
old="$stage/old.app"
if mv "$app" "$old"; then
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
fi
xattr -dr com.apple.quarantine "$app" 2>/dev/null
rm -rf "$stage"
open "$app"
`;
async function applyMac(release, bundle, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
// Staged beside the app, so the final move stays on one volume.
const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
try {
const zip = path.join(stage, asset.name);
await download(asset, zip, onProgress);
await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
fs.rmSync(zip, { force: true });
const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
if (!name) throw new Error("the download has no app in it");
const fresh = path.join(stage, name);
const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
path.join(fresh, "Contents", "Info.plist")])).trim();
if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
const script = path.join(stage, "swap.sh");
fs.writeFileSync(script, MAC_SWAP);
return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
{ detached: true, stdio: "ignore" }).unref();
} catch (e) {
fs.rmSync(stage, { recursive: true, force: true });
throw e;
}
}
async function applyNsis(release, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
const exe = path.join(dir, asset.name);
await download(asset, exe, onProgress);
// /S: silent, into the existing install. --force-run: open the app afterwards.
return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
}
async function applyAppImage(release, appImage, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
// A private folder beside the AppImage, so the final rename stays on one filesystem.
const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
try {
const next = path.join(stage, asset.name);
await download(asset, next, onProgress);
fs.chmodSync(next, 0o755);
fs.renameSync(next, appImage); // the running copy keeps its open file
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
// Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
// on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
// Started only once this process has exited, or the new copy would lose the single-instance lock.
return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
"sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
}
// Downloads and prepares the update; returns a function that starts the swap,
// to be called just before the app quits.
async function prepare(release, how, onProgress, current) {
if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
if (how.method === "nsis") return applyNsis(release, onProgress);
if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
throw new Error(how.why || "this copy can't update itself");
}
function writable(dir) {
try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
}
module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
fromManifest, fromApi,
download, prepare, writable };
-24
View File
@@ -1,24 +0,0 @@
<!doctype html>
<!-- Control experiment, run on the Frame itself: how often does Chromium on
gamescope put a canvas animation on screen, with no network or decoding
involved? Draws every animation frame for ?s= seconds and writes the
frame-gap histogram per second into the title, where xprop can read it. -->
<html><head><meta charset="utf-8"><title>fmv-present</title></head>
<body style="margin:0;background:#000"><canvas id="c" width="1280" height="720" style="width:100%;height:100%"></canvas>
<script>
const q = new URLSearchParams(location.search), secs = +q.get("s") || 15, tag = q.get("tag") || "";
const ctx = document.getElementById("c").getContext("2d", { alpha: false, desynchronized: true });
const perSec = []; let t0 = 0, last = 0, n = 0, gaps = [];
function frame(t) {
if (!t0) t0 = last = t;
ctx.fillStyle = "#123"; ctx.fillRect(0, 0, 1280, 720);
ctx.fillStyle = "#fff"; ctx.fillRect((n * 21) % 1280, 0, 20, 720); n++;
const s = Math.floor((t - t0) / 1000);
(perSec[s] = perSec[s] || []).push(t - last); last = t;
if (t - t0 < secs * 1000) return requestAnimationFrame(frame);
const out = perSec.map(g => g.length).join(",");
document.title = `[${tag}] done fps/s=${out}`;
}
document.title = `[${tag}] running`;
requestAnimationFrame(frame);
</script></body></html>
-32
View File
@@ -1,32 +0,0 @@
<!doctype html>
<!-- Benchmark content: a long page of text that scrolls itself at a steady
speed, so every frame changes the way reading a real page does. -->
<html lang="en"><head><meta charset="utf-8"><title>fmv-bench scroll</title>
<style>
body { font: 17px/1.55 -apple-system, "Helvetica Neue", sans-serif; margin: 0 auto; max-width: 900px; padding: 24px; color: #1d1d1f; background: #fff; }
h2 { font-size: 22px; margin: 28px 0 8px; } code { background: #f2f2f5; padding: 1px 4px; border-radius: 3px; }
</style></head><body><div id="doc"></div>
<script>
const words = "latency frame panel stream encoder decoder network display pixel window capture budget quality motion text sharp adaptive controller queue socket clock".split(" ");
let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
let html = "";
for (let s = 0; s < 120; s++) {
html += `<h2>Section ${s + 1}: ${words[s % words.length]} and ${words[(s * 7) % words.length]}</h2>`;
for (let p = 0; p < 4; p++) {
let para = [];
for (let w = 0; w < 70; w++) para.push(rnd() < 0.05 ? `<code>${words[Math.floor(rnd() * words.length)]}()</code>` : words[Math.floor(rnd() * words.length)]);
html += `<p>${para.join(" ")}.</p>`;
}
}
document.getElementById("doc").innerHTML = html;
// 240 points a second, whatever the display's refresh rate.
// The title carries the page's own frame rate, so the source's rate is known.
let last = performance.now(), frames = 0, since = last;
function step(now) {
const dy = (now - last) * 0.24; last = now;
if (++frames && now - since >= 1000) { document.title = `fmv-bench scroll ${frames} fps`; frames = 0; since = now; }
if (window.scrollY + innerHeight >= document.body.scrollHeight - 2) window.scrollTo(0, 0); else window.scrollBy(0, dy);
requestAnimationFrame(step);
}
requestAnimationFrame(step);
</script></body></html>
-9
View File
@@ -1,9 +0,0 @@
<!doctype html>
<!-- Benchmark content: a plain text editor, focused, for typing tests. -->
<html lang="en"><head><meta charset="utf-8"><title>fmv-bench type</title>
<style>
html, body { margin: 0; height: 100%; background: #fff; }
textarea { box-sizing: border-box; width: 100%; height: 100%; border: 0; padding: 20px; outline: none; resize: none;
font: 20px/1.5 ui-monospace, Menlo, monospace; color: #111; caret-color: transparent; } /* a blinking caret would pass for a reply to a key */
</style></head><body><textarea id="t" autofocus spellcheck="false"></textarea>
<script>document.getElementById("t").focus();</script></body></html>
-677
View File
@@ -1,677 +0,0 @@
{
"label": "usb1",
"date": "2026-09-28T21:16:03",
"commit": "1d05f57",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "10.86.200.233",
"ssh_opts": [],
"encoder": "",
"duration_s": 15.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"headset": "",
"scenarios": [
{
"scenario": "test",
"frames_sent": 911,
"frames_drawn": 911,
"frames_shown": 610,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 911
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 911
},
"encode": {
"p50": 4.4,
"p95": 5.0,
"p99": 5.2,
"n": 911
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 911
},
"network": {
"p50": 1.1,
"p95": 1.6,
"p99": 2.1,
"n": 911
},
"decode": {
"p50": 1.2,
"p95": 2.6,
"p99": 3.9,
"n": 911
},
"draw": {
"p50": 0.4,
"p95": 0.9,
"p99": 1.5,
"n": 911
},
"present": {
"p50": 5.8,
"p95": 13.6,
"p99": 16.7,
"n": 610
},
"content": {
"p50": 7.3,
"p95": 8.8,
"p99": 10.0,
"n": 911
},
"content_shown": {
"p50": 13.6,
"p95": 20.4,
"p99": 24.9,
"n": 610
}
},
"fps": 59.9,
"fps_shown": 40.1,
"late_pct": 0.22,
"stall_max": 32.3,
"stalls_over_100ms": 0,
"mbps": 0.48,
"keyframes": 1,
"size": "1280x720",
"captured": 912,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 16.6,
"p95": 24.3,
"p99": 24.5,
"n": 30
},
"input_shown_ms": {
"p50": 20.8,
"p95": 31.8,
"p99": 33.8,
"n": 22
},
"input_parts_ms": {
"uplink": {
"p50": 0.7,
"p95": 1.0,
"p99": 1.0,
"n": 30
},
"mac": {
"p50": 9.1,
"p95": 15.0,
"p99": 15.2,
"n": 30
},
"back": {
"p50": 7.2,
"p95": 8.5,
"p99": 12.7,
"n": 30
}
},
"inputs": {
"asked": 30,
"sent": 30,
"seen": 30
},
"timeline": [
{
"t": 0,
"fps": 60,
"mbps": 0.5,
"content_p50": 7.3,
"content_p95": 8.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 60,
"mbps": 0.5,
"content_p50": 7.1,
"content_p95": 8.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 59,
"mbps": 0.48,
"content_p50": 7.2,
"content_p95": 8.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 60,
"mbps": 0.49,
"content_p50": 7.1,
"content_p95": 9.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 60,
"mbps": 0.49,
"content_p50": 6.9,
"content_p95": 8.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 60,
"mbps": 0.58,
"content_p50": 7.3,
"content_p95": 8.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 60,
"mbps": 0.47,
"content_p50": 7.4,
"content_p95": 8.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 60,
"mbps": 0.46,
"content_p50": 7.4,
"content_p95": 8.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 60,
"mbps": 0.47,
"content_p50": 7.3,
"content_p95": 8.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 60,
"mbps": 0.46,
"content_p50": 7.3,
"content_p95": 8.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 60,
"mbps": 0.45,
"content_p50": 7.3,
"content_p95": 8.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 11,
"fps": 60,
"mbps": 0.45,
"content_p50": 7.4,
"content_p95": 9.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 12,
"fps": 60,
"mbps": 0.46,
"content_p50": 7.4,
"content_p95": 8.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 13,
"fps": 60,
"mbps": 0.46,
"content_p50": 7.6,
"content_p95": 9.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 14,
"fps": 60,
"mbps": 0.46,
"content_p50": 7.6,
"content_p95": 8.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 15,
"fps": 12,
"mbps": 0.09,
"content_p50": 7.5,
"content_p95": 9.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 7.3,
"content_p95": 8.8,
"input_p50": 16.6,
"input_p95": 24.3,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "local",
"late_pct": "local",
"stall_max": "local"
},
"controller": {
"adapt": true,
"baseRtt": 0.917,
"ceiling": 5529600,
"fps": 60,
"inFlight": 1,
"scale": 1,
"slack": 41.666,
"target": 5529600,
"tier": 0
},
"events": [],
"source_fps": 60.0,
"cpu": {
"mac_agent_pct": 16.7,
"frame_viewer_pct": 48.5,
"frame_tailscaled_pct": 0.5,
"frame_sshd_pct": 1.1,
"frame_gamescope_pct": 12.0,
"frame_total_pct": 39.3
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
"show_s": 1.15,
"panel": "valve.steam.desktopgame.2001639889",
"src": "test"
},
{
"scenario": "scroll",
"frames_sent": 848,
"frames_drawn": 848,
"frames_shown": 617,
"duration_s": 15.1,
"stages_ms": {
"capture": {
"p50": -3.9,
"p95": 4.2,
"p99": 7.3,
"n": 848
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 848
},
"encode": {
"p50": 6.7,
"p95": 9.7,
"p99": 17.7,
"n": 848
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 848
},
"network": {
"p50": 1.7,
"p95": 2.7,
"p99": 5.7,
"n": 848
},
"decode": {
"p50": 5.9,
"p95": 10.3,
"p99": 13.1,
"n": 848
},
"draw": {
"p50": 0.7,
"p95": 1.6,
"p99": 2.2,
"n": 848
},
"present": {
"p50": 4.4,
"p95": 15.8,
"p99": 20.6,
"n": 617
},
"content": {
"p50": 15.7,
"p95": 23.0,
"p99": 39.3,
"n": 848
},
"content_shown": {
"p50": 21.3,
"p95": 34.1,
"p99": 45.6,
"n": 617
}
},
"fps": 56.1,
"fps_shown": 40.7,
"late_pct": 3.3,
"stall_max": 238.5,
"stalls_over_100ms": 1,
"mbps": 9.01,
"keyframes": 1,
"size": "1920x1290",
"captured": 857,
"viewer_never_drawn": 0,
"input_ms": {
"n": 0
},
"input_shown_ms": {
"n": 0
},
"input_parts_ms": {
"uplink": {
"n": 0
},
"mac": {
"n": 0
},
"back": {
"n": 0
}
},
"inputs": {
"asked": 0,
"sent": 0,
"seen": 0
},
"timeline": [
{
"t": 0,
"fps": 44,
"mbps": 8.13,
"content_p50": 15.7,
"content_p95": 35.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 1,
"fps": 56,
"mbps": 7.04,
"content_p50": 16.3,
"content_p95": 47.3,
"bitrate": 7430400,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 2,
"fps": 57,
"mbps": 8.58,
"content_p50": 16.0,
"content_p95": 22.6,
"bitrate": 10055362,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 3,
"fps": 56,
"mbps": 9.29,
"content_p50": 16.4,
"content_p95": 21.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 4,
"fps": 58,
"mbps": 9.01,
"content_p50": 15.6,
"content_p95": 21.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 5,
"fps": 57,
"mbps": 9.13,
"content_p50": 15.9,
"content_p95": 21.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 6,
"fps": 56,
"mbps": 11.63,
"content_p50": 16.1,
"content_p95": 22.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 7,
"fps": 57,
"mbps": 8.7,
"content_p50": 16.3,
"content_p95": 22.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 8,
"fps": 57,
"mbps": 9.24,
"content_p50": 15.7,
"content_p95": 21.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 9,
"fps": 57,
"mbps": 9.21,
"content_p50": 15.0,
"content_p95": 20.5,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 10,
"fps": 56,
"mbps": 8.55,
"content_p50": 15.7,
"content_p95": 20.6,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 11,
"fps": 58,
"mbps": 9.51,
"content_p50": 14.7,
"content_p95": 19.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 12,
"fps": 57,
"mbps": 8.75,
"content_p50": 16.0,
"content_p95": 20.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 13,
"fps": 58,
"mbps": 9.25,
"content_p50": 15.0,
"content_p95": 20.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 14,
"fps": 57,
"mbps": 9.13,
"content_p50": 15.3,
"content_p95": 21.0,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 15,
"fps": 7,
"mbps": 1.17,
"content_p50": 14.2,
"content_p95": 15.3,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
}
],
"adapt": [],
"content_p50": 15.7,
"content_p95": 23.0,
"input_p50": null,
"input_p95": null,
"grades": {
"content_p50": "local",
"content_p95": "local",
"fps": "acceptable",
"late_pct": "acceptable",
"stall_max": "acceptable"
},
"controller": {
"adapt": true,
"baseRtt": 1.25,
"ceiling": 14860800,
"fps": 60,
"inFlight": 0,
"scale": 1,
"slack": 41.666,
"target": 14860800,
"tier": 0
},
"events": [
{
"t": 1.06,
"e": "down to 7430 kbit/s: queue 35 ms, held 4, oldest 32 ms, base 1 ms, sent 6282 got 4914 wants 11892"
}
],
"source_fps": 56.8,
"cpu": {
"mac_agent_pct": 8.8,
"frame_viewer_pct": 74.8,
"frame_tailscaled_pct": 0.3,
"frame_sshd_pct": 1.2,
"frame_gamescope_pct": 10.5,
"frame_total_pct": 39.0
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
"show_s": 1.16,
"panel": "valve.steam.desktopgame.2001968301",
"src": "separate:7914"
}
]
}
-690
View File
@@ -1,690 +0,0 @@
{
"label": "usb2",
"date": "2026-09-28T21:17:48",
"commit": "1d05f57",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "10.86.200.233",
"ssh_opts": [],
"encoder": "",
"duration_s": 15.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"headset": "",
"scenarios": [
{
"scenario": "test",
"frames_sent": 913,
"frames_drawn": 913,
"frames_shown": 856,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 913
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 913
},
"encode": {
"p50": 4.3,
"p95": 4.9,
"p99": 5.2,
"n": 913
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 913
},
"network": {
"p50": 0.9,
"p95": 1.2,
"p99": 1.7,
"n": 913
},
"decode": {
"p50": 1.1,
"p95": 2.5,
"p99": 3.8,
"n": 913
},
"draw": {
"p50": 0.3,
"p95": 0.8,
"p99": 1.2,
"n": 913
},
"present": {
"p50": 2.6,
"p95": 8.6,
"p99": 15.9,
"n": 856
},
"content": {
"p50": 6.9,
"p95": 8.4,
"p99": 9.6,
"n": 913
},
"content_shown": {
"p50": 9.7,
"p95": 15.8,
"p99": 23.1,
"n": 856
}
},
"fps": 60.0,
"fps_shown": 56.3,
"late_pct": 0.0,
"stall_max": 22.0,
"stalls_over_100ms": 0,
"mbps": 0.57,
"keyframes": 1,
"size": "1280x720",
"captured": 913,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 16.9,
"p95": 24.1,
"p99": 26.1,
"n": 33
},
"input_shown_ms": {
"p50": 19.7,
"p95": 26.6,
"p99": 29.2,
"n": 31
},
"input_parts_ms": {
"uplink": {
"p50": 0.7,
"p95": 2.5,
"p99": 5.7,
"n": 33
},
"mac": {
"p50": 8.1,
"p95": 15.4,
"p99": 17.2,
"n": 33
},
"back": {
"p50": 7.3,
"p95": 8.3,
"p99": 8.9,
"n": 33
}
},
"inputs": {
"asked": 30,
"sent": 33,
"seen": 33
},
"timeline": [
{
"t": 0,
"fps": 60,
"mbps": 0.49,
"content_p50": 7.1,
"content_p95": 8.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 60,
"mbps": 0.47,
"content_p50": 7.0,
"content_p95": 8.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 60,
"mbps": 0.48,
"content_p50": 6.9,
"content_p95": 8.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 60,
"mbps": 0.48,
"content_p50": 7.0,
"content_p95": 9.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 60,
"mbps": 0.5,
"content_p50": 6.8,
"content_p95": 8.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 60,
"mbps": 0.67,
"content_p50": 7.0,
"content_p95": 8.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 60,
"mbps": 0.59,
"content_p50": 7.0,
"content_p95": 8.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 60,
"mbps": 0.63,
"content_p50": 6.7,
"content_p95": 7.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 60,
"mbps": 0.62,
"content_p50": 6.9,
"content_p95": 8.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 60,
"mbps": 0.64,
"content_p50": 6.8,
"content_p95": 8.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 60,
"mbps": 0.67,
"content_p50": 6.8,
"content_p95": 8.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 11,
"fps": 60,
"mbps": 0.62,
"content_p50": 6.7,
"content_p95": 7.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 12,
"fps": 60,
"mbps": 0.59,
"content_p50": 6.8,
"content_p95": 7.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 13,
"fps": 60,
"mbps": 0.57,
"content_p50": 6.7,
"content_p95": 8.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 14,
"fps": 60,
"mbps": 0.58,
"content_p50": 7.0,
"content_p95": 8.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 15,
"fps": 13,
"mbps": 0.13,
"content_p50": 6.4,
"content_p95": 7.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 6.9,
"content_p95": 8.4,
"input_p50": 16.9,
"input_p95": 24.1,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "local",
"late_pct": "local",
"stall_max": "local"
},
"controller": {
"adapt": true,
"baseRtt": 0.709,
"ceiling": 5529600,
"fps": 60,
"inFlight": 0,
"scale": 1,
"slack": 41.666,
"target": 5529600,
"tier": 0
},
"events": [],
"source_fps": 60.1,
"cpu": {
"mac_agent_pct": 17.9,
"frame_viewer_pct": 51.9,
"frame_tailscaled_pct": 0.2,
"frame_sshd_pct": 1.1,
"frame_gamescope_pct": 9.6,
"frame_total_pct": 27.9
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 134 Hz",
"show_s": 1.15,
"panel": "valve.steam.desktopgame.2001639889",
"src": "test"
},
{
"scenario": "scroll",
"frames_sent": 868,
"frames_drawn": 868,
"frames_shown": 868,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": -4.2,
"p95": 0.5,
"p99": 6.7,
"n": 868
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.2,
"n": 868
},
"encode": {
"p50": 6.8,
"p95": 10.2,
"p99": 17.9,
"n": 868
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 868
},
"network": {
"p50": 1.5,
"p95": 2.3,
"p99": 7.6,
"n": 868
},
"decode": {
"p50": 5.9,
"p95": 10.2,
"p99": 20.3,
"n": 868
},
"draw": {
"p50": 0.7,
"p95": 1.4,
"p99": 2.1,
"n": 868
},
"present": {
"p50": 0.9,
"p95": 6.1,
"p99": 6.8,
"n": 868
},
"content": {
"p50": 15.3,
"p95": 23.5,
"p99": 53.1,
"n": 868
},
"content_shown": {
"p50": 17.7,
"p95": 25.6,
"p99": 57.5,
"n": 868
}
},
"fps": 57.2,
"fps_shown": 57.2,
"late_pct": 3.23,
"stall_max": 108.0,
"stalls_over_100ms": 1,
"mbps": 9.84,
"keyframes": 1,
"size": "1920x1290",
"captured": 870,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 24.2,
"p95": 36.3,
"p99": 36.3,
"n": 6
},
"input_shown_ms": {
"p50": 24.8,
"p95": 38.5,
"p99": 38.5,
"n": 6
},
"input_parts_ms": {
"uplink": {
"p50": 2.0,
"p95": 5.4,
"p99": 5.4,
"n": 6
},
"mac": {
"p50": 7.5,
"p95": 15.8,
"p99": 15.8,
"n": 6
},
"back": {
"p50": 15.3,
"p95": 16.1,
"p99": 16.1,
"n": 6
}
},
"inputs": {
"asked": 0,
"sent": 6,
"seen": 6
},
"timeline": [
{
"t": 0,
"fps": 53,
"mbps": 9.21,
"content_p50": 16.1,
"content_p95": 71.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 1,
"fps": 57,
"mbps": 9.64,
"content_p50": 15.9,
"content_p95": 46.8,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 2,
"fps": 58,
"mbps": 8.99,
"content_p50": 14.8,
"content_p95": 19.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 3,
"fps": 57,
"mbps": 9.72,
"content_p50": 15.2,
"content_p95": 22.3,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 4,
"fps": 58,
"mbps": 9.64,
"content_p50": 16.1,
"content_p95": 23.5,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 5,
"fps": 57,
"mbps": 11.27,
"content_p50": 15.6,
"content_p95": 20.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 6,
"fps": 58,
"mbps": 10.44,
"content_p50": 15.1,
"content_p95": 19.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 7,
"fps": 57,
"mbps": 9.31,
"content_p50": 15.0,
"content_p95": 19.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 8,
"fps": 58,
"mbps": 11.69,
"content_p50": 15.9,
"content_p95": 22.0,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 9,
"fps": 58,
"mbps": 10.31,
"content_p50": 14.5,
"content_p95": 20.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 10,
"fps": 57,
"mbps": 10.47,
"content_p50": 15.2,
"content_p95": 20.6,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 11,
"fps": 58,
"mbps": 9.44,
"content_p50": 15.3,
"content_p95": 20.5,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 12,
"fps": 57,
"mbps": 9.17,
"content_p50": 15.9,
"content_p95": 18.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 13,
"fps": 58,
"mbps": 8.77,
"content_p50": 15.1,
"content_p95": 18.6,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 14,
"fps": 58,
"mbps": 9.72,
"content_p50": 15.2,
"content_p95": 19.8,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 15,
"fps": 9,
"mbps": 1.33,
"content_p50": 14.8,
"content_p95": 17.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
}
],
"adapt": [],
"content_p50": 15.3,
"content_p95": 23.5,
"input_p50": 24.2,
"input_p95": 36.3,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "acceptable",
"late_pct": "acceptable",
"stall_max": "acceptable"
},
"controller": {
"adapt": true,
"baseRtt": 1.375,
"ceiling": 14860800,
"fps": 60,
"inFlight": 1,
"scale": 1,
"slack": 41.666,
"target": 14860800,
"tier": 0
},
"events": [],
"source_fps": 57.2,
"cpu": {
"mac_agent_pct": 11.4,
"frame_viewer_pct": 83.5,
"frame_tailscaled_pct": 0.3,
"frame_sshd_pct": 1.5,
"frame_gamescope_pct": 9.7,
"frame_total_pct": 32.5
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
"show_s": 1.15,
"panel": "valve.steam.desktopgame.2001786096",
"src": "separate:8187"
}
]
}
-682
View File
@@ -1,682 +0,0 @@
{
"label": "wifi1",
"date": "2026-09-28T21:16:56",
"commit": "1d05f57",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"ssh_opts": [],
"encoder": "",
"duration_s": 15.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"headset": "",
"scenarios": [
{
"scenario": "test",
"frames_sent": 912,
"frames_drawn": 912,
"frames_shown": 611,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 912
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 912
},
"encode": {
"p50": 4.3,
"p95": 4.9,
"p99": 5.1,
"n": 912
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 912
},
"network": {
"p50": 4.0,
"p95": 5.3,
"p99": 7.8,
"n": 912
},
"decode": {
"p50": 1.1,
"p95": 2.8,
"p99": 4.0,
"n": 912
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.2,
"n": 912
},
"present": {
"p50": 5.9,
"p95": 14.0,
"p99": 18.5,
"n": 611
},
"content": {
"p50": 10.1,
"p95": 12.3,
"p99": 13.9,
"n": 912
},
"content_shown": {
"p50": 16.7,
"p95": 23.6,
"p99": 28.3,
"n": 611
}
},
"fps": 60.0,
"fps_shown": 40.1,
"late_pct": 0.11,
"stall_max": 25.8,
"stalls_over_100ms": 0,
"mbps": 0.48,
"keyframes": 1,
"size": "1280x720",
"captured": 912,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 26.4,
"p95": 34.2,
"p99": 53.5,
"n": 30
},
"input_shown_ms": {
"p50": 28.6,
"p95": 41.5,
"p99": 55.9,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 8.9,
"p95": 20.4,
"p99": 28.0,
"n": 30
},
"mac": {
"p50": 5.1,
"p95": 14.5,
"p99": 16.2,
"n": 30
},
"back": {
"p50": 10.1,
"p95": 13.7,
"p99": 13.8,
"n": 30
}
},
"inputs": {
"asked": 30,
"sent": 30,
"seen": 30
},
"timeline": [
{
"t": 0,
"fps": 60,
"mbps": 0.51,
"content_p50": 10.3,
"content_p95": 11.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.5,
"content_p95": 12.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 60,
"mbps": 0.49,
"content_p50": 10.1,
"content_p95": 12.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.9,
"content_p95": 11.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 60,
"mbps": 0.47,
"content_p50": 9.7,
"content_p95": 11.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 60,
"mbps": 0.59,
"content_p50": 10.2,
"content_p95": 11.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.2,
"content_p95": 12.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 60,
"mbps": 0.47,
"content_p50": 9.7,
"content_p95": 12.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.4,
"content_p95": 13.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 60,
"mbps": 0.47,
"content_p50": 9.9,
"content_p95": 12.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 60,
"mbps": 0.47,
"content_p50": 9.8,
"content_p95": 12.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 11,
"fps": 60,
"mbps": 0.46,
"content_p50": 10.3,
"content_p95": 12.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 12,
"fps": 60,
"mbps": 0.49,
"content_p50": 10.2,
"content_p95": 12.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 13,
"fps": 60,
"mbps": 0.48,
"content_p50": 10.0,
"content_p95": 11.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 14,
"fps": 60,
"mbps": 0.46,
"content_p50": 10.2,
"content_p95": 12.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 15,
"fps": 12,
"mbps": 0.1,
"content_p50": 10.7,
"content_p95": 11.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 10.1,
"content_p95": 12.3,
"input_p50": 26.4,
"input_p95": 34.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "local",
"late_pct": "local",
"stall_max": "local"
},
"controller": {
"adapt": true,
"baseRtt": 4.958,
"ceiling": 5529600,
"fps": 60,
"inFlight": 1,
"scale": 1,
"slack": 47.417,
"target": 2764800,
"tier": 0
},
"events": [
{
"t": 16.58,
"e": "down to 2764 kbit/s: queue 19 ms, held 6, oldest 210 ms, base 4 ms, sent 322 got 265 wants 459"
}
],
"source_fps": 60.0,
"cpu": {
"mac_agent_pct": 15.7,
"frame_viewer_pct": 42.5,
"frame_tailscaled_pct": 5.5,
"frame_sshd_pct": 0.7,
"frame_gamescope_pct": 8.7,
"frame_total_pct": 26.1
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
"show_s": 1.26,
"panel": "valve.steam.desktopgame.2001639889",
"src": "test"
},
{
"scenario": "scroll",
"frames_sent": 852,
"frames_drawn": 852,
"frames_shown": 614,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": -4.4,
"p95": 2.6,
"p99": 4.2,
"n": 852
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 852
},
"encode": {
"p50": 6.7,
"p95": 9.8,
"p99": 16.7,
"n": 852
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 852
},
"network": {
"p50": 5.3,
"p95": 13.1,
"p99": 28.2,
"n": 852
},
"decode": {
"p50": 6.0,
"p95": 10.9,
"p99": 19.0,
"n": 852
},
"draw": {
"p50": 0.7,
"p95": 1.4,
"p99": 1.8,
"n": 852
},
"present": {
"p50": 4.1,
"p95": 16.2,
"p99": 20.6,
"n": 614
},
"content": {
"p50": 19.5,
"p95": 31.4,
"p99": 64.5,
"n": 852
},
"content_shown": {
"p50": 25.4,
"p95": 38.8,
"p99": 76.7,
"n": 614
}
},
"fps": 56.2,
"fps_shown": 40.5,
"late_pct": 4.69,
"stall_max": 253.7,
"stalls_over_100ms": 2,
"mbps": 9.02,
"keyframes": 1,
"size": "1920x1290",
"captured": 866,
"viewer_never_drawn": 0,
"input_ms": {
"n": 0
},
"input_shown_ms": {
"n": 0
},
"input_parts_ms": {
"uplink": {
"n": 0
},
"mac": {
"n": 0
},
"back": {
"n": 0
}
},
"inputs": {
"asked": 0,
"sent": 0,
"seen": 0
},
"timeline": [
{
"t": 0,
"fps": 45,
"mbps": 7.77,
"content_p50": 19.0,
"content_p95": 163.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 1,
"fps": 51,
"mbps": 6.54,
"content_p50": 19.4,
"content_p95": 107.0,
"bitrate": 7430400,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 2,
"fps": 57,
"mbps": 8.64,
"content_p50": 19.9,
"content_p95": 31.8,
"bitrate": 10055362,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 3,
"fps": 58,
"mbps": 9.46,
"content_p50": 19.1,
"content_p95": 24.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 4,
"fps": 58,
"mbps": 9.11,
"content_p50": 19.4,
"content_p95": 22.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 5,
"fps": 57,
"mbps": 9.18,
"content_p50": 19.9,
"content_p95": 25.3,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 6,
"fps": 58,
"mbps": 11.64,
"content_p50": 19.8,
"content_p95": 49.6,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 7,
"fps": 57,
"mbps": 9.12,
"content_p50": 20.5,
"content_p95": 31.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 8,
"fps": 58,
"mbps": 9.29,
"content_p50": 19.5,
"content_p95": 25.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 9,
"fps": 57,
"mbps": 9.43,
"content_p50": 19.8,
"content_p95": 49.6,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 10,
"fps": 58,
"mbps": 8.59,
"content_p50": 19.2,
"content_p95": 24.8,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 11,
"fps": 57,
"mbps": 9.42,
"content_p50": 19.9,
"content_p95": 27.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 12,
"fps": 58,
"mbps": 9.0,
"content_p50": 20.6,
"content_p95": 31.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 13,
"fps": 57,
"mbps": 8.84,
"content_p50": 19.6,
"content_p95": 25.3,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 14,
"fps": 57,
"mbps": 9.17,
"content_p50": 19.3,
"content_p95": 27.8,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 15,
"fps": 9,
"mbps": 1.52,
"content_p50": 19.3,
"content_p95": 30.0,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
}
],
"adapt": [],
"content_p50": 19.5,
"content_p95": 31.4,
"input_p50": null,
"input_p95": null,
"grades": {
"content_p50": "local",
"content_p95": "local",
"fps": "acceptable",
"late_pct": "acceptable",
"stall_max": "bad"
},
"controller": {
"adapt": true,
"baseRtt": 6.959,
"ceiling": 14860800,
"fps": 60,
"inFlight": 2,
"scale": 1,
"slack": 43.54,
"target": 14860800,
"tier": 0
},
"events": [
{
"t": 1.09,
"e": "down to 7430 kbit/s: queue 131 ms, held 3, oldest 231 ms, base 6 ms, sent 4272 got 3724 wants 9321"
}
],
"source_fps": 57.0,
"cpu": {
"mac_agent_pct": 8.5,
"frame_viewer_pct": 71.7,
"frame_tailscaled_pct": 8.4,
"frame_sshd_pct": 0.9,
"frame_gamescope_pct": 8.4,
"frame_total_pct": 30.1
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
"show_s": 1.25,
"panel": "valve.steam.desktopgame.2001195625",
"src": "separate:8050"
}
]
}
-682
View File
@@ -1,682 +0,0 @@
{
"label": "wifi2",
"date": "2026-09-28T21:18:40",
"commit": "1d05f57",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"ssh_opts": [],
"encoder": "",
"duration_s": 15.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"headset": "",
"scenarios": [
{
"scenario": "test",
"frames_sent": 902,
"frames_drawn": 902,
"frames_shown": 604,
"duration_s": 15.2,
"stages_ms": {
"capture": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 902
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 0.1,
"n": 902
},
"encode": {
"p50": 4.3,
"p95": 5.0,
"p99": 5.3,
"n": 902
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.3,
"n": 902
},
"network": {
"p50": 3.8,
"p95": 5.3,
"p99": 8.0,
"n": 902
},
"decode": {
"p50": 1.1,
"p95": 2.8,
"p99": 3.8,
"n": 902
},
"draw": {
"p50": 0.3,
"p95": 0.8,
"p99": 1.2,
"n": 902
},
"present": {
"p50": 5.9,
"p95": 17.0,
"p99": 18.6,
"n": 604
},
"content": {
"p50": 9.8,
"p95": 12.1,
"p99": 14.4,
"n": 902
},
"content_shown": {
"p50": 15.2,
"p95": 27.0,
"p99": 28.4,
"n": 604
}
},
"fps": 59.3,
"fps_shown": 39.7,
"late_pct": 0.55,
"stall_max": 187.5,
"stalls_over_100ms": 1,
"mbps": 0.47,
"keyframes": 1,
"size": "1280x720",
"captured": 913,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 27.8,
"p95": 34.9,
"p99": 206.9,
"n": 30
},
"input_shown_ms": {
"p50": 35.1,
"p95": 52.6,
"p99": 207.2,
"n": 24
},
"input_parts_ms": {
"uplink": {
"p50": 9.8,
"p95": 22.5,
"p99": 184.8,
"n": 30
},
"mac": {
"p50": 5.3,
"p95": 14.4,
"p99": 19.6,
"n": 30
},
"back": {
"p50": 9.8,
"p95": 13.4,
"p99": 13.9,
"n": 30
}
},
"inputs": {
"asked": 30,
"sent": 30,
"seen": 30
},
"timeline": [
{
"t": 0,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.3,
"content_p95": 11.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 50,
"mbps": 0.39,
"content_p50": 9.1,
"content_p95": 14.4,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.2,
"content_p95": 11.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.4,
"content_p95": 10.5,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.5,
"content_p95": 12.5,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.0,
"content_p95": 12.8,
"bitrate": 4757991,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 60,
"mbps": 0.57,
"content_p50": 9.5,
"content_p95": 11.7,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 60,
"mbps": 0.47,
"content_p50": 9.8,
"content_p95": 11.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.2,
"content_p95": 12.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.0,
"content_p95": 13.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.2,
"content_p95": 12.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 11,
"fps": 60,
"mbps": 0.45,
"content_p50": 9.9,
"content_p95": 11.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 12,
"fps": 60,
"mbps": 0.46,
"content_p50": 10.0,
"content_p95": 11.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 13,
"fps": 60,
"mbps": 0.47,
"content_p50": 10.3,
"content_p95": 12.3,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 14,
"fps": 60,
"mbps": 0.48,
"content_p50": 9.8,
"content_p95": 11.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 15,
"fps": 12,
"mbps": 0.1,
"content_p50": 9.3,
"content_p95": 11.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 9.8,
"content_p95": 12.1,
"input_p50": 27.8,
"input_p95": 34.9,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "local",
"late_pct": "local",
"stall_max": "acceptable"
},
"controller": {
"adapt": true,
"baseRtt": 5.084,
"ceiling": 5529600,
"fps": 60,
"inFlight": 1,
"scale": 1,
"slack": 49.603,
"target": 5529600,
"tier": 0
},
"events": [
{
"t": 1.77,
"e": "down to 2764 kbit/s: queue 23 ms, held 5, oldest 0 ms, base 5 ms, sent 315 got 315 wants 472"
}
],
"source_fps": 60.1,
"cpu": {
"mac_agent_pct": 16.1,
"frame_viewer_pct": 40.9,
"frame_tailscaled_pct": 5.2,
"frame_sshd_pct": 0.7,
"frame_gamescope_pct": 8.4,
"frame_total_pct": 25.2
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
"show_s": 1.27,
"panel": "valve.steam.desktopgame.2001639889",
"src": "test"
},
{
"scenario": "scroll",
"frames_sent": 832,
"frames_drawn": 832,
"frames_shown": 602,
"duration_s": 15.1,
"stages_ms": {
"capture": {
"p50": -3.9,
"p95": 0.8,
"p99": 6.8,
"n": 832
},
"queue": {
"p50": 0.0,
"p95": 0.1,
"p99": 1.9,
"n": 832
},
"encode": {
"p50": 6.8,
"p95": 10.5,
"p99": 18.8,
"n": 832
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 832
},
"network": {
"p50": 5.8,
"p95": 18.7,
"p99": 56.7,
"n": 832
},
"decode": {
"p50": 5.9,
"p95": 11.7,
"p99": 26.0,
"n": 832
},
"draw": {
"p50": 0.7,
"p95": 1.4,
"p99": 1.7,
"n": 832
},
"present": {
"p50": 4.4,
"p95": 18.5,
"p99": 25.0,
"n": 602
},
"content": {
"p50": 20.2,
"p95": 36.9,
"p99": 90.7,
"n": 832
},
"content_shown": {
"p50": 26.4,
"p95": 45.4,
"p99": 101.0,
"n": 602
}
},
"fps": 54.9,
"fps_shown": 39.7,
"late_pct": 6.96,
"stall_max": 204.9,
"stalls_over_100ms": 2,
"mbps": 9.04,
"keyframes": 1,
"size": "1920x1290",
"captured": 852,
"viewer_never_drawn": 0,
"input_ms": {
"n": 0
},
"input_shown_ms": {
"n": 0
},
"input_parts_ms": {
"uplink": {
"n": 0
},
"mac": {
"n": 0
},
"back": {
"n": 0
}
},
"inputs": {
"asked": 0,
"sent": 0,
"seen": 0
},
"timeline": [
{
"t": 0,
"fps": 49,
"mbps": 8.68,
"content_p50": 19.8,
"content_p95": 119.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 1,
"fps": 47,
"mbps": 6.06,
"content_p50": 19.8,
"content_p95": 88.6,
"bitrate": 7430400,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 2,
"fps": 52,
"mbps": 8.07,
"content_p50": 19.4,
"content_p95": 25.5,
"bitrate": 10055362,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 3,
"fps": 56,
"mbps": 9.79,
"content_p50": 21.4,
"content_p95": 29.1,
"bitrate": 13549185,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 4,
"fps": 57,
"mbps": 8.93,
"content_p50": 21.5,
"content_p95": 35.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 5,
"fps": 57,
"mbps": 10.59,
"content_p50": 21.1,
"content_p95": 35.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 6,
"fps": 56,
"mbps": 11.55,
"content_p50": 19.7,
"content_p95": 66.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 7,
"fps": 57,
"mbps": 8.8,
"content_p50": 18.8,
"content_p95": 24.8,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 8,
"fps": 57,
"mbps": 9.17,
"content_p50": 19.5,
"content_p95": 24.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 9,
"fps": 57,
"mbps": 9.39,
"content_p50": 19.4,
"content_p95": 23.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 10,
"fps": 57,
"mbps": 8.53,
"content_p50": 19.5,
"content_p95": 38.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 11,
"fps": 56,
"mbps": 9.23,
"content_p50": 20.7,
"content_p95": 30.9,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 12,
"fps": 53,
"mbps": 8.87,
"content_p50": 20.6,
"content_p95": 35.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 13,
"fps": 57,
"mbps": 8.94,
"content_p50": 20.4,
"content_p95": 28.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 14,
"fps": 57,
"mbps": 9.14,
"content_p50": 21.0,
"content_p95": 35.5,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 15,
"fps": 7,
"mbps": 1.2,
"content_p50": 21.8,
"content_p95": 23.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
}
],
"adapt": [],
"content_p50": 20.2,
"content_p95": 36.9,
"input_p50": null,
"input_p95": null,
"grades": {
"content_p50": "local",
"content_p95": "local",
"fps": "acceptable",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"adapt": true,
"baseRtt": 6.833,
"ceiling": 14860800,
"fps": 60,
"inFlight": 2,
"scale": 1,
"slack": 44.791,
"target": 14860800,
"tier": 0
},
"events": [
{
"t": 1.22,
"e": "down to 7430 kbit/s: queue 74 ms, held 7, oldest 235 ms, base 6 ms, sent 5436 got 4487 wants 11082"
}
],
"source_fps": 56.4,
"cpu": {
"mac_agent_pct": 8.8,
"frame_viewer_pct": 69.5,
"frame_tailscaled_pct": 9.5,
"frame_sshd_pct": 0.8,
"frame_gamescope_pct": 8.4,
"frame_total_pct": 31.8
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
"show_s": 1.25,
"panel": "valve.steam.desktopgame.2001116820",
"src": "separate:8327"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,984 +0,0 @@
{
"label": "adapt-clean",
"date": "2026-09-28T17:13:59",
"commit": "a3c6e5c+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"ssh_opts": [],
"encoder": "",
"duration_s": 20.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"headset": ":39.639829 [Info] - 0 - entering standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 653,
"frames_drawn": 653,
"frames_shown": 649,
"duration_s": 20.7,
"stages_ms": {
"capture": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 653
},
"queue": {
"p50": 11.2,
"p95": 16.1,
"p99": 17.1,
"n": 653
},
"encode": {
"p50": 3.5,
"p95": 4.2,
"p99": 4.4,
"n": 653
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 653
},
"network": {
"p50": 2.9,
"p95": 7.0,
"p99": 12.0,
"n": 653
},
"decode": {
"p50": 1.0,
"p95": 2.4,
"p99": 2.8,
"n": 653
},
"draw": {
"p50": 0.3,
"p95": 0.6,
"p99": 0.9,
"n": 653
},
"present": {
"p50": 0.5,
"p95": 0.9,
"p99": 5.9,
"n": 649
},
"content": {
"p50": 19.8,
"p95": 26.2,
"p99": 30.1,
"n": 653
},
"content_shown": {
"p50": 20.3,
"p95": 26.9,
"p99": 31.1,
"n": 649
}
},
"fps": 31.5,
"fps_shown": 31.3,
"late_pct": 98.01,
"stall_max": 144.0,
"stalls_over_100ms": 2,
"mbps": 0.13,
"keyframes": 2,
"size": "960x540",
"captured": 1336,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.5,
"p95": 61.8,
"p99": 63.4,
"n": 40
},
"input_shown_ms": {
"p50": 44.3,
"p95": 62.3,
"p99": 63.9,
"n": 40
},
"input_parts_ms": {
"uplink": {
"p50": 11.1,
"p95": 29.2,
"p99": 36.1,
"n": 40
},
"mac": {
"p50": 10.4,
"p95": 28.2,
"p99": 32.4,
"n": 40
},
"back": {
"p50": 17.5,
"p95": 27.9,
"p99": 28.8,
"n": 40
}
},
"inputs": {
"sent": 40,
"seen": 40
},
"timeline": [
{
"t": 0,
"fps": 31,
"mbps": 0.13,
"content_p50": 21.5,
"content_p95": 27.1,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 1,
"fps": 31,
"mbps": 0.12,
"content_p50": 22.4,
"content_p95": 26.3,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 2,
"fps": 32,
"mbps": 0.13,
"content_p50": 18.9,
"content_p95": 24.8,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 3,
"fps": 31,
"mbps": 0.12,
"content_p50": 18.2,
"content_p95": 24.6,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 4,
"fps": 32,
"mbps": 0.13,
"content_p50": 21.0,
"content_p95": 25.6,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 5,
"fps": 32,
"mbps": 0.12,
"content_p50": 19.5,
"content_p95": 24.3,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 6,
"fps": 31,
"mbps": 0.12,
"content_p50": 22.2,
"content_p95": 25.6,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 7,
"fps": 32,
"mbps": 0.13,
"content_p50": 19.9,
"content_p95": 24.0,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 8,
"fps": 31,
"mbps": 0.21,
"content_p50": 22.7,
"content_p95": 26.9,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 9,
"fps": 31,
"mbps": 0.12,
"content_p50": 20.2,
"content_p95": 26.1,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 10,
"fps": 32,
"mbps": 0.12,
"content_p50": 18.2,
"content_p95": 23.2,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 11,
"fps": 31,
"mbps": 0.12,
"content_p50": 19.8,
"content_p95": 24.2,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 12,
"fps": 32,
"mbps": 0.13,
"content_p50": 19.2,
"content_p95": 24.8,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 13,
"fps": 32,
"mbps": 0.12,
"content_p50": 21.0,
"content_p95": 24.3,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 14,
"fps": 30,
"mbps": 0.13,
"content_p50": 20.4,
"content_p95": 96.7,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 15,
"fps": 32,
"mbps": 0.13,
"content_p50": 18.2,
"content_p95": 24.4,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 16,
"fps": 31,
"mbps": 0.12,
"content_p50": 18.4,
"content_p95": 25.1,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 17,
"fps": 32,
"mbps": 0.13,
"content_p50": 17.2,
"content_p95": 23.5,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 18,
"fps": 32,
"mbps": 0.19,
"content_p50": 19.4,
"content_p95": 25.2,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 19,
"fps": 31,
"mbps": 0.14,
"content_p50": 21.1,
"content_p95": 84.3,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 20,
"fps": 24,
"mbps": 0.08,
"content_p50": 20.8,
"content_p95": 25.1,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
}
],
"adapt": [],
"content_p50": 19.8,
"content_p95": 26.2,
"input_p50": 43.5,
"input_p95": 61.8,
"grades": {
"content_p50": "local",
"content_p95": "local",
"input_p50": "local",
"input_p95": "local",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"source_fps": 64.5,
"cpu": {
"mac_agent_pct": 11.3,
"frame_viewer_pct": 35.9,
"frame_tailscaled_pct": 3.5,
"frame_sshd_pct": 0.6,
"frame_gamescope_pct": 7.5,
"frame_total_pct": 16.1
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
"show_s": 4.68,
"panel": "valve.steam.desktopgame.2001639889",
"src": "test"
},
{
"scenario": "scroll",
"frames_sent": 937,
"frames_drawn": 937,
"frames_shown": 720,
"duration_s": 20.7,
"stages_ms": {
"capture": {
"p50": -3.9,
"p95": 0.9,
"p99": 8.2,
"n": 937
},
"queue": {
"p50": 0.0,
"p95": 16.3,
"p99": 20.1,
"n": 937
},
"encode": {
"p50": 6.4,
"p95": 7.2,
"p99": 9.3,
"n": 937
},
"socket": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.2,
"n": 937
},
"network": {
"p50": 7.3,
"p95": 13.0,
"p99": 22.8,
"n": 937
},
"decode": {
"p50": 6.2,
"p95": 12.7,
"p99": 18.6,
"n": 937
},
"draw": {
"p50": 0.6,
"p95": 1.1,
"p99": 1.9,
"n": 937
},
"present": {
"p50": 2.9,
"p95": 18.5,
"p99": 24.5,
"n": 720
},
"content": {
"p50": 19.9,
"p95": 37.2,
"p99": 50.2,
"n": 937
},
"content_shown": {
"p50": 27.0,
"p95": 45.7,
"p99": 55.0,
"n": 720
}
},
"fps": 45.3,
"fps_shown": 34.8,
"late_pct": 28.63,
"stall_max": 99.3,
"stalls_over_100ms": 0,
"mbps": 7.59,
"keyframes": 2,
"size": "1920x1290",
"captured": 1215,
"viewer_never_drawn": 0,
"input_ms": {
"n": 0
},
"input_shown_ms": {
"n": 0
},
"input_parts_ms": {
"uplink": {
"n": 0
},
"mac": {
"n": 0
},
"back": {
"n": 0
}
},
"inputs": {
"sent": 0,
"seen": 0
},
"timeline": [
{
"t": 0,
"fps": 30,
"mbps": 4.01,
"content_p50": 31.9,
"content_p95": 52.3,
"bitrate": 4867140,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 1,
"fps": 30,
"mbps": 4.12,
"content_p50": 29.9,
"content_p95": 45.3,
"bitrate": 3807054,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 2,
"fps": 30,
"mbps": 3.26,
"content_p50": 31.8,
"content_p95": 52.5,
"bitrate": 3235995,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 3,
"fps": 30,
"mbps": 3.72,
"content_p50": 24.5,
"content_p95": 37.7,
"bitrate": 4238740,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 4,
"fps": 30,
"mbps": 5.09,
"content_p50": 25.2,
"content_p95": 39.6,
"bitrate": 5992063,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 5,
"fps": 31,
"mbps": 7.29,
"content_p50": 29.1,
"content_p95": 53.5,
"bitrate": 5526067,
"tier": 2,
"w": 1920,
"net": null
},
{
"t": 6,
"fps": 35,
"mbps": 4.58,
"content_p50": 25.3,
"content_p95": 32.4,
"bitrate": 6018152,
"tier": 1,
"w": 1920,
"net": null
},
{
"t": 7,
"fps": 46,
"mbps": 7.12,
"content_p50": 25.1,
"content_p95": 34.3,
"bitrate": 8412933,
"tier": 1,
"w": 1920,
"net": null
},
{
"t": 8,
"fps": 46,
"mbps": 8.25,
"content_p50": 27.1,
"content_p95": 40.3,
"bitrate": 10760191,
"tier": 1,
"w": 1920,
"net": null
},
{
"t": 9,
"fps": 47,
"mbps": 9.3,
"content_p50": 27.8,
"content_p95": 41.2,
"bitrate": 13717060,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 10,
"fps": 55,
"mbps": 8.98,
"content_p50": 16.5,
"content_p95": 21.7,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 11,
"fps": 51,
"mbps": 9.32,
"content_p50": 18.6,
"content_p95": 35.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 12,
"fps": 56,
"mbps": 9.53,
"content_p50": 15.4,
"content_p95": 26.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 13,
"fps": 55,
"mbps": 8.46,
"content_p50": 18.6,
"content_p95": 23.2,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 14,
"fps": 54,
"mbps": 9.82,
"content_p50": 15.9,
"content_p95": 28.4,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 15,
"fps": 54,
"mbps": 11.11,
"content_p50": 15.5,
"content_p95": 26.1,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 16,
"fps": 54,
"mbps": 9.79,
"content_p50": 17.4,
"content_p95": 25.3,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 17,
"fps": 57,
"mbps": 9.3,
"content_p50": 16.3,
"content_p95": 23.5,
"bitrate": 14860800,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 18,
"fps": 55,
"mbps": 8.3,
"content_p50": 19.5,
"content_p95": 31.0,
"bitrate": 7984791,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 19,
"fps": 51,
"mbps": 8.56,
"content_p50": 16.4,
"content_p95": 29.1,
"bitrate": 10220855,
"tier": 0,
"w": 1920,
"net": null
},
{
"t": 20,
"fps": 40,
"mbps": 7.13,
"content_p50": 18.3,
"content_p95": 23.9,
"bitrate": 12025604,
"tier": 0,
"w": 1920,
"net": null
}
],
"adapt": [],
"content_p50": 19.9,
"content_p95": 37.2,
"input_p50": null,
"input_p95": null,
"grades": {
"content_p50": "local",
"content_p95": "local",
"fps": "acceptable",
"late_pct": "bad",
"stall_max": "local"
},
"source_fps": 58.7,
"cpu": {
"mac_agent_pct": 6.6,
"frame_viewer_pct": 72.4,
"frame_tailscaled_pct": 8.1,
"frame_sshd_pct": 0.9,
"frame_gamescope_pct": 10.0,
"frame_total_pct": 24.2
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
"show_s": 6.43,
"panel": "valve.steam.desktopgame.2001414593",
"src": "separate:9384"
},
{
"scenario": "type",
"frames_sent": 25,
"frames_drawn": 25,
"frames_shown": 25,
"duration_s": 14.7,
"stages_ms": {
"capture": {
"p50": -0.3,
"p95": 0.9,
"p99": 1.1,
"n": 25
},
"queue": {
"p50": 0.0,
"p95": 0.0,
"p99": 0.0,
"n": 25
},
"encode": {
"p50": 5.0,
"p95": 33.4,
"p99": 33.6,
"n": 25
},
"socket": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 25
},
"network": {
"p50": 6.1,
"p95": 14.9,
"p99": 24.4,
"n": 25
},
"decode": {
"p50": 3.0,
"p95": 10.5,
"p99": 11.4,
"n": 25
},
"draw": {
"p50": 0.6,
"p95": 1.0,
"p99": 1.1,
"n": 25
},
"present": {
"p50": 0.8,
"p95": 1.4,
"p99": 1.7,
"n": 25
},
"content": {
"p50": 12.6,
"p95": 41.0,
"p99": 46.3,
"n": 25
},
"content_shown": {
"p50": 13.9,
"p95": 41.8,
"p99": 46.6,
"n": 25
}
},
"fps": 1.7,
"fps_shown": 1.7,
"late_pct": 100.0,
"stall_max": 3099.2,
"stalls_over_100ms": 21,
"mbps": 0.04,
"keyframes": 4,
"size": "960x646",
"captured": 79,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 41.3,
"p95": 60.3,
"p99": 68.6,
"n": 18
},
"input_shown_ms": {
"p50": 43.0,
"p95": 61.1,
"p99": 69.6,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 5.3,
"p95": 11.6,
"p99": 14.2,
"n": 18
},
"mac": {
"p50": 23.1,
"p95": 35.0,
"p99": 42.6,
"n": 18
},
"back": {
"p50": 11.8,
"p95": 19.2,
"p99": 22.7,
"n": 18
}
},
"inputs": {
"sent": 66,
"seen": 18
},
"timeline": [
{
"t": 3,
"fps": 1,
"mbps": 0.01,
"content_p50": 11.8,
"content_p95": 11.8,
"bitrate": 1224075,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 4,
"fps": 1,
"mbps": 0.02,
"content_p50": 14.7,
"content_p95": 14.7,
"bitrate": 2091896,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 5,
"fps": 4,
"mbps": 0.1,
"content_p50": 18.6,
"content_p95": 37.4,
"bitrate": 3071304,
"tier": 3,
"w": 1440,
"net": null
},
{
"t": 6,
"fps": 5,
"mbps": 0.13,
"content_p50": 4.2,
"content_p95": 19.2,
"bitrate": 4031277,
"tier": 3,
"w": 1440,
"net": null
},
{
"t": 7,
"fps": 6,
"mbps": 0.04,
"content_p50": 12.0,
"content_p95": 46.3,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 10,
"fps": 1,
"mbps": 0.01,
"content_p50": 12.2,
"content_p95": 12.2,
"bitrate": 300000,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 12,
"fps": 1,
"mbps": 0.02,
"content_p50": -1.2,
"content_p95": -1.2,
"bitrate": 842857,
"tier": 4,
"w": 960,
"net": null
},
{
"t": 14,
"fps": 1,
"mbps": 0.02,
"content_p50": 14.4,
"content_p95": 14.4,
"bitrate": 2091896,
"tier": 4,
"w": 960,
"net": null
}
],
"adapt": [],
"content_p50": 12.6,
"content_p95": 41.0,
"input_p50": 41.3,
"input_p95": 60.3,
"grades": {
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "local"
},
"source_fps": 5.4,
"cpu": {
"mac_agent_pct": 1.2,
"frame_viewer_pct": 5.0,
"frame_tailscaled_pct": 1.5,
"frame_sshd_pct": 0.2,
"frame_gamescope_pct": 4.7,
"frame_total_pct": 8.5
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 140 Hz",
"show_s": 5.12,
"panel": "valve.steam.desktopgame.2001910179",
"src": "separate:9510"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,311 +0,0 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -1,302 +0,0 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
+3 -26
View File
@@ -1,32 +1,9 @@
# compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
reports for Android apps on the Steam Frame. Only the maintainer's copy of
Frame Control has the key to read or write it (see `shared()` in
`ui/frame_compat_db.py`). Everyone else's reports stay on their computer
unless they turn on **Share compatibility results**. Then the reports also go
to PostHog as `compat_report` events, and the maintainer syncs them in (below).
## Community reports
```sh
python3 ui/frame_compat_db.py sync --dry-run # what would be added
python3 ui/frame_compat_db.py sync # add them
```
`sync` reads `compat_report` events through PostHog's query API and adds
them with `via` set to `community`, `community-probe` or `community-install`.
It skips invalid reports and anything over 30 per reporter per day. Each run
re-reads the last 30 days, because an offline copy sends its reports late,
with the time they were made. `posthog-sync.json`, next to the outbox,
remembers which reports it has handled and each reporter's daily count, so
nothing is added twice and the cap holds across runs.
It needs:
- the PostHog project id: `"project"` in `ui/telemetry.json`
- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
Keychain (service `frame-control-posthog`, account `personal-api-key`)
reports for Android apps on the Steam Frame. For now only the maintainer's
copy of Frame Control has the key to read or write it. Everyone else's reports
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
claimed, so it doesn't expire). The browser page only says it's private.
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
tests, which have no GPU or desktop to encode with.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination, with_x264=False):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
if with_x264:
names.append('x264')
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
args = parser.parse_args()
build(args.out.resolve(), args.with_x264)
-134
View File
@@ -1,134 +0,0 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
FC_API int fc_has_property(const char *element, const char *property) {
/* Probe the same initialized GStreamer instance that capture uses. The
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
GstElement *source=gst_element_factory_make(element,NULL);
if(!source)return 0;
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
gst_object_unref(source);
return found;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
-130
View File
@@ -1,130 +0,0 @@
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
* Swift original, so a change to either must be made to both:
* tests/test_pc_controller.py checks this one against the Swift trace. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
-29
View File
@@ -1,29 +0,0 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
-147
View File
@@ -1,147 +0,0 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
-222
View File
@@ -1,222 +0,0 @@
# 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 **Settings** (the gear in the header) **→ Open assistant settings**, 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.
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
backend, no API key, no prestarted server) and a human approved or rejected
each change in the approval page in a real Chrome window:
| Tool | Result on the Frame |
|---|---|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
| `uninstall` | Approved; app and locale removed |
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
A separate Claude Code CLI session, with only this server configured, read
status, `computer_state` and a headset capture, and requested an install. It
received an approval URL and did not execute anything.
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
Against a loopback stub model, a send without consent made zero requests. With
consent it made exactly one, carrying the text and a fresh Frame screenshot.
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
and tunnel.
**Not verified while unworn:** every headset capture was a uniform dark frame,
so SteamVR's rendered view of panels and the game could not be checked; window
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
runtime it pulled in; Flatpak may also remove related extensions when that
runtime is removed by hand.
![Approval page showing the exact install action](img/mcp-approval-install.png)
![The installed Flatpak rendering in its own gamescope panel window](img/mcp-panel-binary.png)
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
## 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.
-57
View File
@@ -1,57 +0,0 @@
# Import an Android game backup
A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
The import screen shows the game app and its game-data files together. **Import
game & data** installs the APK, starts its own Steam library instance, waits for
Android and the installed package, copies the OBBs, and closes the game. Open it
again from the Steam library to play. The screen explains this before import;
choosing files alone never starts a game.
Files are matched using the package inside the APK and the standard
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
patch files are preserved. OBB version numbers need not equal the APK's current
version code: updates can reuse older expansion files. The copy uses the existing
SHA-256 transfer check inside Android and writes to that game's own
`/sdcard/Android/obb/<package>` folder.
If no data files are selected, the screen reminds you to include any OBBs in the
backup before offering **Install game app**. An APK cannot reliably tell Frame
Control whether separate expansion files are required. Game-data files are never
silently sent to Downloads by this flow.
If setup fails, the import screen keeps the error and retry instructions visible.
The APK may already be installed, but the game is not reported ready. Import the
same backup again, or choose just its OBB files to add data to an existing install.
The importer checks the installed package and version before writing data and
closes the instance after a failed copy. Unrelated or duplicate files, running
games that would be replaced, and downgrades are rejected. Uploaded staging files
are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
matching, sibling discovery, browser upload staging and cleanup, instance/version
guards and failure recovery with mocked headset operations. Browser verification
uses the real page and upload/inspection endpoints with simulated installation.
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
install/start/copy/close sequence. Reopening its persistent instance confirmed
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
shortcut and instance were removed afterward, the shared device lock was
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
-144
View File
@@ -1,144 +0,0 @@
# 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.
-197
View File
@@ -1,197 +0,0 @@
# APK repositories
Frame Control supports **F-Droid-format repositories**, including F-Droid,
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
indexes are authenticated before their apps appear. Search lists builds with
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
## Formats considered
| Format | Users and purpose | Support in this source |
|---|---|---|
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
Research references (checked 2026-09-28):
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
- [Droid-ify](https://github.com/Droid-ify/client) and
[Neo Store](https://github.com/NeoApplications/Neo-Store).
- [Obtainium](https://github.com/ImranR98/Obtainium), its
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
and [community app configurations](https://apps.obtainium.imranr.dev/).
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
The absence of a specification in these materials is not proof that no
historical or private custom-feed format exists.
## Add a repository in Frame Control
From the Frame Control checkout, use its source-management CLI:
```sh
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
python3 ui/apk_sources/fdroid.py list
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
```
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
A URL must identify the repository directory, not its website or an index file.
Adding fetches and validates the complete index **before saving** the source.
Without a fingerprint, Frame Control verifies the JAR signature and remembers
its signer: trust on first use (TOFU). This establishes continuity with the
first server response, not independent publisher identity. Obtain the published
fingerprint through a trusted channel when possible; the store's Add a source
form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
separate work. Built-in sources can be disabled but cannot be removed.
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
Authenticated reduced indexes and APKs live under
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
expired index is still served (marked stale in the store) while it refreshes in
the background; a failed refresh is retried after 10 minutes.
Only the running Frame Control app prunes cached APKs (at start and after store
downloads); the command-line tools never do.
The existing catalogue's unverified index cache is never treated as authenticated.
Rollback protection: each repository's newest accepted signed index timestamp
is kept in `apk-repo-state.json` next to the settings, and an older index is
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
Do not publish paid app mirrors or bypass store licences. Check distribution
terms before adding someone else's repository; this module does not infer legal
permission from a signature or automatically audit a repository's terms.
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
and its documented Android/Java dependencies on the publishing machine, then:
```sh
mkdir my-fdroid
cd my-fdroid
fdroid init
# Set repo_url in config.yml to https://example.org/fdroid/repo
# Also set repo_name and repo_description; keep the generated signing key safe.
cp /path/to/your-free-app.apk repo/
fdroid update --create-metadata
# Review the generated metadata (name, summary, licence, source and website).
fdroid update
```
Serve the generated **repo directory** at that HTTPS URL, including APKs,
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
private signing keystore or configuration passwords. Configure fdroidserver's
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
public directory with your existing deployment tool. Publish the SHA-256
repository certificate fingerprint displayed by fdroidserver in a link such as
`https://example.org/fdroid/repo?fingerprint=…`.
Keep the repository signing key backed up: changing it breaks existing pins.
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
publish again. Test the published URL with Frame Control's `add`, `search` and
`download` commands. The above publisher setup is documented from fdroidserver;
it was not executed as part of this implementation.
## Verification and limits
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
recognized. It checks the signer certificate pin, the signature over `.SF`,
the whole-manifest digest, and the manifest's digest of the JSON member.
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
rejected. Certificates are pinned identities, not validated as Web PKI chains.
HTTPS certificates are separately checked by Python's normal TLS validation.
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
fingerprint, index hash, TLS and server errors never trigger an unsigned
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
`verified: true` means the bytes match the signed repository's APK hash; it
is not an independent APK publisher-signature or runtime compatibility verdict.
There is no repository timestamp rollback/expiry policy or automated signing-key
rotation yet. An old correctly signed index can still validate.
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
the real IzzyOnDroid repository was added with its published pin, searched for
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
No headset connection or installation was performed.
-116
View File
@@ -1,116 +0,0 @@
# Developer-consented APK sources
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
Frame compatibility. These adapters fetch only public publisher releases or
link to publisher pages. They do not acquire store entitlements, defeat access
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|---|---|---|---|
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
The table distinguishes observed restrictions from unknown permission. An
unverified policy is a reason to defer automation, not a claim that a site is
unlawful. Only the two implemented source kinds are registered by their own
`sources()` functions; the other rows are recommendations, not new UI entries.
## Adapters
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
[Open Brush](https://github.com/icosa-foundation/open-brush), and
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
OpenXR project/release evidence, not a blanket claim of headset compatibility.
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
Open Brush and SuperTux publish the selected builds as prereleases; curated
opt-ins preserve that label in version records. Exact APK filename patterns
avoid downloading desktop archives or alternate non-Quest builds.
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
provenance was not established in this pass.
Default GitHub search is offline against this small list. Queries
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
leaves the latter unknown and rejects a requested `version_code` rather than
silently fetching a different build.
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
preview embedded in the port's README (not a headset capture). The hello_xr
sample has a launcher icon and GitHub social banner; no published screenshot
was found in the inspected repository/README, so its screenshot list is empty.
Uncurated topic results use the owner's avatar and GitHub's repository social
preview. These are repository placeholders, not app screenshots. Itch's recorded
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
Downloads stream to unique temporary files, require an APK manifest entry,
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
means the downloaded SHA-256 matches GitHub's published digest. Without such a
digest, the computed SHA-256 is returned with `verified: False`; neither value
claims publisher-signature validation. Installation must inspect the APK as
usual. OBBs, split APKs, paid assets and external release-body download links
are unsupported.
## Evidence and limits
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
the GitHub adapter, matched its published SHA-256
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
`python3 ui/frame_android.py info <apk>` exited 0: package
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
successful live end-to-end search. Fixture search finds Off Nominal and parses
nine Android entries from the ten-item feed (one has only an HTML platform).
A real itch download and APK inspection were deliberately not performed:
robots restrictions take precedence over that requested proof. No current
policy text is claimed verified where the table records failed access.
Tests use recorded, reduced API/RSS fixtures with network access blocked in
the new test class. They cover selection, prereleases, unknown topic results,
paid/non-Android exclusion, URL restrictions, redirect credential removal,
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
-133
View File
@@ -5,12 +5,6 @@ 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
@@ -39,12 +33,6 @@ section below), instead of going into Lepton Development:
run at once alongside Lepton Development, each in its own container
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
Split APKs (a base plus `split_*.apk` files) install as one app:
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
APK, so splits that add native libraries or assets are merged into the base
and re-signed; density and language splits are refused. See
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
from the shortcut, stopped, and relaunched with their data intact. ADB and
Lepton Development aren't involved.
@@ -58,33 +46,6 @@ Lepton Development must be installed once. Over SSH,
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
needs to be confirmed or started in the headset.
## When an app needs a newer Android
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
an APK, it shows compatible versions from F-Droid's main and archive repos and
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
arm64-only build, and says how many compatible builds it found in total.
Each index is reduced to its compatible builds once a day and cached (about
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
instant.
Choose **Install** to download a listed version, verify its SHA-256 against
the index, and install it as its own app.
You can also inspect a file or look up a package from the command line:
```sh
python3 ui/frame_android.py info some-app.apk
python3 ui/frame_android.py versions some-app.apk
python3 ui/frame_android.py versions org.example.app
```
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
version whose minimum is Android 11 or lower and that has an arm64-v8a build
(or no native code). Frame Control does not fetch APKs from those search sites.
Older versions may lack fixes, and being installable does not guarantee an
app will run: see the missing services below. Android may refuse a downgrade
or an update signed by a different publisher; removing the app deletes its data.
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
Lepton Development runs in a throwaway "dev" context. When it exits for any
@@ -183,92 +144,6 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
@@ -423,11 +298,3 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably
don't.
## Expansion files and save backups
SideQuest-inspired CLI helpers install local OBB files into an already-running
app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
-174
View File
@@ -1,174 +0,0 @@
# Backup and recovery
**Tools → Backup & recovery** copies what a factory reset would lose from the
Steam Frame to your computer, puts it back afterwards, and gets the headset out
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
app or the Frame itself.
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore, and the switch out of a real Desktop Mode session. Unit
tests and the fake Frame cover those (see [Verification](#verification)).
On 2026-10-24 a read-only session check on the same build again reported Game
Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the
`steamos-session-select` command was present. Opening the nested desktop for a
clipboard test did not enter full Desktop Mode. A full session switch was not
attempted because it restarts the headset session and a reliable return path
was not established; the **Return to Game Mode** branch for a real full
Desktop Mode session remains unverified.
## Back up
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
(all three by default), then **Back up now**. **See what's included** lists each
part and its size first. Backing up only reads the headset.
Each backup is a folder named after the time it started:
```
~/Documents/Frame Control Backups/2026-10-23-142530/
manifest.json
saves/saves-1234.tar.gz one per game
saves/saves-linux.tar.gz
settings/settings-steam.tar.gz
settings/settings-system.tar.gz
android/org.example.game.tar.gz one per Android app
```
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
file with its size and SHA-256, and each archive's SHA-256. It also lists what
was skipped and why. A backup is written under a hidden `.…partial` name and
renamed when complete, so a cut-off backup never shows up as one.
### What's saved
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|---|---|---|
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
"Kept for reference" files are in the backup but not put back automatically:
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
point at apps that aren't installed after a reset. Open the backup folder to
copy one by hand if you need it.
### What's never copied
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
`registry.vdf` and `ssfn*` files.
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
(`secrets.env`). These are refused by name wherever they turn up, including
inside a game's folder, and a restore refuses them too.
- Games, Proton and the Steam runtimes: reinstall them from Steam.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links (never followed), and single files over 2 GiB.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
Android apps that are open are skipped (the helper needs the app closed); close
them and back up again.
## Restore after a factory reset
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
Steam with the same account.
2. Reinstall your games, and **start each once** so Proton creates its folders.
3. Reinstall your Android apps from Frame Control.
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
each part against the headset and says what it would do: how many files it
replaces and how many are new.
5. Tick the parts to restore and **Restore selected**. It asks before replacing
anything.
Parts that can't go back yet are skipped, not failed, with the reason:
| Reason | What to do |
|---|---|
| Install the game first | Install it from Steam, then restore |
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
| Sign in to Steam first | Controller layouts go under your account's folder |
| Install the app first / close the app first | Android app data needs the app installed and closed |
| The backup file doesn't match its checksum | The archive changed since it was made |
A restore writes only the files in the parts you chose, and only inside that
part's folders: it never deletes a file, and a save archive can't write outside
its game. Before replacing a file it copies the current one to
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
already identical are left alone. Android app data goes through the existing
`restore-data` helper, which keeps the previous data folder beside it.
## Stuck in Desktop Mode
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. SteamOS picks the session through
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
default `/etc/sddm.conf.d/steamos.conf`), and switches with
`steamos-session-select` (`holo-session-select` on newer images). This is what
Valve's own devkit tools on the Frame read and run
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
**Check** reads that file and whether gamescope and Plasma are running; it
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
running without gamescope (Desktop Mode) does **Return to Game Mode** run
exactly `steamos-session-select gamescope` (or `holo-session-select
gamescope`) over SSH, after asking. It closes the desktop and anything open in
it. Frame Control never edits the SDDM files itself, and runs nothing when the
headset is in Game Mode, when it can't tell which mode it's in, or when the
image has neither command.
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
20260925.6191901), and whether `steamos-session-select` there works without
the Developer Mode password. If the switch is refused, Frame Control says so and
leaves the headset as it was.
### Manual fallback
If the button can't help, or the headset isn't reachable:
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamos-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
`Erase User Data` is a factory reset.
## Verification
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links never followed, the manifest and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
-59
View File
@@ -1,59 +0,0 @@
# Battery and health monitoring
Open **Control Center → Battery & thermal health**, or expand **System
information**, to read the Frame's charge, charging state, signed battery
power, battery temperature and driver-reported health. Optional readings show
charge cycles, full charge capacity, design capacity and full / design capacity.
The existing battery chip also shows charging state and temperature.
These are read-only readings from the Linux host's `/sys/class/power_supply`,
not Android's container battery view. The probe prefers the documented Frame
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
battery fallback. The UI shows the selected source path.
| Reading | Linux attribute | Interpretation |
|---|---|---|
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
Unavailable means the field is absent, unreadable or invalid. One missing
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
as invalid readings; these bounds are not thermal warning thresholds.
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
does not clamp it or invent a wear warning. The app does not estimate health
from charge percentage or assign temperature thresholds. Existing thermal
alerts use the kernel's own hot/critical trip points.
A connected charger does not necessarily mean charging: the Frame can still
drain under load or sit idle near full charge. A 100% capacity reading alone
does not prove the driver reports Full. Missing status is explicitly unknown;
near-zero measured battery power with a charger is shown as not charging.
Failed, stale or disconnected status samples clear Control Center readings.
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
writes to power supplies, pauses charging, changes current or sets a limit.
## Verification
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
malformed/missing values, permission failures, source selection and partial
sensor support. `tests/control_center.cjs` exercises charging states, optional
metrics and failed/stale/disconnected sample handling. These tests do not
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
piped over SSH to a real Frame running SteamOS 0.5.6 (build
`20261015.6141207`). Its read-only sysfs result selected
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
headset; Windows and macOS were not tested. Optional fields are not claimed to
be available on every firmware build.
-9
View File
@@ -1,9 +0,0 @@
# Changelog
## Unreleased
- Battery monitoring now selects the Frame fuel gauge predictably, handles
missing or invalid readings, and shows optional cycle count, full/design
capacity and estimated capacity health in Control Center. Unknown charging
status stays explicit. Monitoring is read-only; charge limits remain
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
-168
View File
@@ -1,168 +0,0 @@
# Battery charge limit (80%): research
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
charge-limit setting uses on the Deck) is not configured for the Frame. The
only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
For safe read-only readings in Control Center, see [battery and health
monitoring](battery-health.md). It does not provide charging controls.
## How Steam does it on other devices
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
SteamOS 3.7.8 (beta client 2025-05-08)
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
- Steam calls SteamOS Manager on the session bus:
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
`steamosctl get-max-charge-level` / `set-max-charge-level` /
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
and `steamos-manager/src/bin/steamosctl.rs` in
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
read at commit `83981ab` (2026-10-21).
- The root daemon writes the value to sysfs only if the device's TOML in
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
`charge_control_end_threshold` on the ACPI battery
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
On ARM there's no ACPI, so this path can't match there either.
- With no `[battery_charge_limit]` section, the interface fails with
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
## What the Frame has
- **Upstream `data/devices/steam-frame.toml`** (matched by
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
and performance profiles only. It has **no `[battery_charge_limit]`**, so
SteamOS Manager on the Frame has no charge-limit backend. Its git history
(2026-09-10 to 2026-10-12) never added one.
- **Battery driver:** the Frame's battery is
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
exposes only read-only properties (capacity, voltage, current, temp,
charge_full…), with no `charge_control_end_threshold`. Valve's build is
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
so, a charge stop would have to come from the separate charger IC, not this
node.
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
Steam's limit on those by patching steamos-manager
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
read-only checks below answer this.)
- **What third parties found:**
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
the Frame has no `charge_control_end_threshold` and falls back to a vendor
`charging_pause` attribute. It also reports that `constant_charge_current`
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
(`deckard-charger.service`). By its own README, that tool has only run its
read-only `status` on a Frame and has **not** been tested against real
charging. Two questions are still open: whether pausing stops the current,
and whether Valve's daemon undoes it.
## Why not build it now
- **No supported endpoint.** The only Valve-supported interface,
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
the user's password).
- **A rival owner.** Valve's charge daemon already adjusts charging
parameters. Toggling `charging_pause` underneath it could be undone without
warning, fight it in a loop, or leave charging paused.
- **Stuck at a low charge.** If the app or the Frame dies while charging is
paused, the Frame could sit at a low charge on the charger. Whether a
reboot clears `charging_pause` is unknown.
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
reflect our limit.
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
call:
- Read: `steamosctl get-max-charge-level`
- Set: `steamosctl set-max-charge-level 80`
- Undo: `steamosctl set-max-charge-level -- -1`
That runs as `steamos` with no sudo. It's the same value Steam's own
Settings → Power shows, so both stay in sync.
## Headset checks (read-only first)
Run these over SSH as `steamos`. Steps 1–4 only read.
```sh
# 1. Does SteamOS Manager offer the interface on this build?
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
busctl --user introspect com.steampowered.SteamOSManager1 \
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
# 2. Which power supplies exist, and which drivers back them?
for d in /sys/class/power_supply/*; do
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
ls $d
done
# 3. Any charge-control attributes, with their values and permissions?
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
# 4. What is Valve's charger daemon, and what does it touch?
systemctl status deckard-charger.service --no-pager
systemctl cat deckard-charger.service
journalctl -b -u deckard-charger.service --no-pager | tail -50
```
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
on a charger at a charge above 80%. Watch `current_now` on the
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
confirm charging resumes. Do this on a supervised Frame only. If only step 3
finds a vendor attribute, **stop**: don't write it, and leave the question
with Valve.
## Sources
- steamos-manager source, commit `83981ab` (2026-10-21):
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
the Steam toggle shows on unsupported hardware
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
`qcom_battmgr` threshold on other SM8650/SM8750 devices
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
untested on real charging
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
-67
View File
@@ -1,67 +0,0 @@
# 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.
-247
View File
@@ -1,247 +0,0 @@
# Headsets, addresses and the connection
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
| Module | What it does |
|---|---|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
## Headsets
Each headset keeps its own SSH alias, as Set Up Connection has always written
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
work for each one.
- **Nothing to migrate by hand.** On first start, the app imports every
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
the block's HostName as its first address. It also copies the host key your
`known_hosts` already trusts for that address into the headset's own
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
When it writes its block, the app picks the headset up by itself. If Set Up
Connection runs again and finds a headset somewhere new, that address is added
at the top of its list.
- **Use this headset** (or the switcher in the header, or the app's
**Frame → Headset** menu) moves the whole app to another headset; every panel
reloads from it. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
the box; either way it isn't imported again unless Set Up Connection changes it.
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
app shows it as not set up and lets ssh's own config decide where it goes.
## Addresses
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
and changeable), an optional label, the networks it has worked on, and when it
last worked with its round-trip time.
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
and ranks them:
1. addresses that worked on the network this computer is on now;
2. mDNS names;
3. Tailscale addresses, if Tailscale is running here;
4. addresses not tried on this network yet;
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
answered is tried. Every success records the network on that address, so next
time on that network it's tried first.
**Test now** probes every address and tries SSH on each one that answers, without
disturbing the connection in use: "SSH works", "answered as a different
headset", "refused this computer's key", or why it didn't answer. **Find on
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
permission. Where the system does share the Wi-Fi name, it's shown, and you can
name any network yourself ("Home Wi-Fi") on the Devices tab.
The app rereads the gateway every 5 seconds and Tailscale's state every
30 seconds. Changing networks reconnects.
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
2. **Finding the headset**: each address resolving, trying, answered in N ms,
no answer, refused, or can't be found.
3. **Opening SSH** to the address that answered.
4. **Checking the headset's identity**: the host key must match the one pinned
for this headset.
5. **Logging in** as the headset's user.
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
with the reason in plain words and a countdown to the next try (5, 10, 20,
then every 30 seconds). Retry now skips the wait.
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
alias's block in `~/.ssh/config` is also updated to the last address that
worked (and to the user and port you set), so Terminal's `ssh frame` and the
scripts follow. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
is keyed by address, so a different device answering at a remembered IP (a DHCP
lease that moved) would look like a new host there. The app keeps one known_hosts
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`), along with a flag that never clears
(`identity_established`), as soon as ssh saves the key: even if the login is then
refused. A headset counts as having had an identity if the flag is set, a
fingerprint is recorded, its pin file exists at all, or a connection to it ever
worked (older files get the flag from that when loaded, so editing or removing
the address that worked doesn't undo it); then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
no local command (ProxyJump's own ssh would take `ForwardAgent` and
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
configured. Every word of that ProxyCommand is quoted for the shell that runs it
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
it refuses, since those can't be made safe from here: add an address it can
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
Devices tab offer **Set Up Connection…**, which pairs this computer again.
Neither is reported as a fault: the diagnostics report each kind at most once
a session ([privacy.md](privacy.md)).
## One server at a time
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
data folder). Two would each connect, reconnect and edit the headsets on their own, and
one could move the other's install to a different headset. A second one, say
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
## API
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| Request | Returns |
|---|---|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
through a shell.
## The registry file
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
share the format later (it still connects to one host; see
[iphone.md](iphone.md)):
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
```
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
## Tests
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
computer for the addresses, and temporary folders for `~/.ssh`
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
never touch yours.
-34
View File
@@ -1,34 +0,0 @@
{
"date": "2026-10-20T03:53:25Z",
"build_id": "20260925.6191901",
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
"sibling_discovery": true,
"automatic_import": "passed",
"closed_after_import": true,
"independent_readback": [
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
],
"cleanup": {
"test_install_removed": true,
"running_instances_before": {},
"running_instances_after": {},
"battery_before": [
"100",
"Full",
"Charging"
],
"battery_after": [
"99",
"Discharging",
"Charging"
],
"shared_lock_released": true
},
"limits": [
"No headset rendering or gameplay assertion; headset unworn.",
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
"A game that exits before its Lepton instance can accept data was not tested."
]
}
-47
View File
@@ -1,47 +0,0 @@
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-29
View File
@@ -1,29 +0,0 @@
Locked real-Frame repeat, 2026-09-29
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
-128
View File
@@ -1,128 +0,0 @@
# 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).
-122
View File
@@ -1,122 +0,0 @@
# UEVR from Frame Control, 2026-09-29
**Verified observations**, with limits below. Same device and software as
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
12:44–12:52 and 13:01–13:03. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
reported `activity_level` 3 (user interaction timeout; the headset was not
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
capture was a uniform dark frame. Frame submits prove the app is presenting
to SteamVR, not that the image is correct.
## Manual injection (11:13–11:28)
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
started with the game process's Wine variables, and its WPF window drew
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
(64-bit, PCD3D_SM5))`, a Wine-side process id.
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
was not restored on the next start.
- XTest clicks landed on the game: `XQueryPointer` reported child
`0x2000001` (the game) at the injector's coordinates, and
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
- `XSendEvent` button events to the injector window worked. After OpenVR and
Inject, `/proc/<game>/maps` contained
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
Proton's `vrclient_x64.dll`. UEVR's log:
```text
[11:17:32.948] Hooking D3D11
[11:17:33.020] Hooked DirectX 11
[11:17:34.263] Framework initialized
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
[11:17:39.791] UGameViewportClient::Draw called for the first time.
[11:17:39.792] is stereo enabled called!
```
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
A second session's injector exited within about 20 seconds. The third,
started fresh, injected again (`frame_submits` 1412). After the game was
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
files were removed.
## Through Frame Control (11:33–11:49)
Local `ui/server.py` against the `frame` alias. First the API, then the
page's **VR mod** dialog, driven with a headless Chromium:
| Step | Result |
|---|---|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
| `uninstall` while running | Refused: "the game is running; quit it first" |
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
## The GUI injector wasn't reliable (12:02–12:40)
After review fixes, cold starts through the API with UEVR's own injector:
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
relaunched the injector didn't rescue a bad session. In both failures of
the last batch, the first injector ignored the clicks and the next two
never showed a window within 60 s.
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
with OpenVR still unselected. The injector's log had no error.
Lock released at 12:40 before redesigning.
## Frame Control's own injector (12:44–12:52)
`start` now runs `frame_inject.py` under python.org's embeddable Python
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
the game's Wine session. The old install was removed and the new one
installed without the headset lock, since neither launches anything. The
prefix listing after removal matched the pre-install state; the new install
is 47 MB.
| Step | Result |
|---|---|
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
Battery 98–100 % on charge. Lock released at 12:52.
## After the second review (13:01–13:03)
`frame_inject.py` now declares every Win32 signature, checks each result,
frees its remote buffers and closes its handles; uninstall accepts only our
folder and UEVR's own settings folders. Rechecked: install; three cold starts
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
through the API, plus the UI run.
## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end.
- The test lock was released at 13:03.
-53
View File
@@ -1,53 +0,0 @@
{
"date": "2026-09-28",
"os": {
"version": "0.4.1",
"build": "20260925.6191901",
"variant": "vr"
},
"performance": {
"compositorFps": 72.0,
"frameMs": 13.89,
"appFps": null,
"gpuMs": 3.07,
"compositorCpuMs": 0.61,
"cpuPercent": 40.6,
"gpuMHz": 903.0
},
"batteryPercent": 16,
"maxTempC": 73.5,
"ownership": [
{
"id": 1009850,
"owned": false,
"installed": false,
"frame": 0
},
{
"id": 1173510,
"owned": false,
"installed": false,
"frame": 0
},
{
"id": 1068820,
"owned": false,
"installed": false,
"frame": 0
},
{
"id": 908520,
"owned": false,
"installed": false,
"frame": 0
},
{
"id": 1494460,
"owned": false,
"installed": false,
"frame": 0
}
],
"hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
"comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

-58
View File
@@ -1,58 +0,0 @@
# Independent review attempts — 2026-09-28
Target: the implementation and evidence in
[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
frozen diff before those commits were made. No executable code changed after
the final review snapshot.
Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
No completed verdict or model self-identification was returned. This is not a
passed review, and there is no "no actionable findings" claim.
## First attempt
Launcher:
```sh
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
```
The prompt required read-only review, no delegation, no edits and no Frame
access. The reviewer inspected surrounding code, then stopped while checking
the public OpenVR header. The tool runner reported:
> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
The real launcher exit status was **0**, but no verdict was returned. A zero
process status here is not evidence that the review completed.
## Tool-free retry
Launcher:
```sh
devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
```
The self-contained prompt supplied the complete frozen changes, surrounding
code, standards and locally fetched authoritative OpenVR header excerpts. It
explicitly prohibited tools, edits, delegation and device access. This avoided
the first attempt's permission boundary without escalating permissions.
No output or verdict arrived within the fifteen-minute review window. The
process was sent SIGTERM at 918 seconds; the shell recorded real exit status
**143**. Findings are unavailable. Review must be completed before considering
this partial draft ready; playspace feasibility is also still paused.
## Other validation
- 166 Python unit tests passed locally.
- 8 website tests and UI JavaScript syntax passed locally.
- Desktop and phone-width attached-preview checks passed using live telemetry;
paid optional install buttons were disabled, and unavailable metrics cleared.
- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
passed, as did Windows/Linux server tests and the main checks job. Docker was
unavailable locally. macOS/iOS jobs were still queued at this handoff.
- Real-device evidence and the paused-control limitation are in
[VR utilities](../../vr-utilities.md).
-151
View File
@@ -1,151 +0,0 @@
# Windows VM and real Frame test, 2026-10-24
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
Media Foundation stream, but every desktop capture failed at pipeline parsing:
`no property "capture-api" in element "d3d11screencapturesrc"`.
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
bundle captured the actual Windows desktop with Desktop Duplication and sent
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
snapshot. Individual windows are explicitly unavailable with this bundle.
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
## Evidence
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
with the missing-property error for both codecs.
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
sets `-Dd3d11-wgc=disabled`. Upstream
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
display produces five H.264 and five JPEG frames. Window sources are hidden
with an explicit reason, and the display selects `capture_api=dxgi`.
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
panel launcher and shared viewer. The image below was captured from that
viewer's own X window on the Frame, rather than the VM's console.
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
[guest system details](windows-vm-2026-10-24/windows-system.txt).
![Windows VM desktop decoded in the Frame's own viewer](windows-vm-2026-10-24/headset-view.png)
## Host and VM prerequisites
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
Swap was already full. No other VM or service workload was started or stopped.
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
The execution sandbox hid KVM and denied Docker/network access. Checks outside
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
opened it successfully; no host permissions changed.
Created only `frame-winvm-priority3`, labelled
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
(Windows for Docker v6.06, QEMU v11.1.1).
The container downloaded Windows 11 from Microsoft, with no activation changes.
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
Its installer exited 0. The VM stream used an intermediate Python/UI-only
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
showed that probing GStreamer through a separately loaded DLL could see a
different, uninitialized instance. The final fix probes optional properties
through the capture DLL itself. Windows CI checks that native change; the VM
stream was not repeated with the final DLL.
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
password authentication is disabled. Its config needed an explicit
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
key login worked. The default informational share was replaced with a private
writable staging share inside this container. These were test-VM setup steps,
not app defects. Host setup files and the stopped VM disk are retained under
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
The Frame test key was generated inside Windows and its private key stayed
there. Only its public key was temporarily authorized on the Frame. After
testing, the guest key and scheduled task were removed; `docker stop -t 150`
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
and `Shutdown completed!`). The disk is retained
for reuse; the VM is not running.
## Checks
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
focused final PC suite **27 tests, OK, one native class skipped**. The C
controller's frozen 600-state trace ran on Linux.
- Actual Windows installer bundle with the intermediate Python/UI fix:
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
CI-only x264 tests). Its GObject property probe, host/encoder check and
x264-absence check ran. The final native probe is checked in the PR's
Windows CI build, not by this VM run.
- Browser check with the real production HTML/JavaScript and mocked Windows API
responses: DXGI whole-screen Show remains available, the limitation appears
once, WGC window sources retain Show, off hides controls, and the limitation
text is escaped. This used a static asset server, without headset API access.
- `node --test app/test/*.test.js`: passed, three test files.
- `node --test site/test/*.test.mjs`: passed, one test file.
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
including loopback socket `PermissionError`. The successful full runs were
outside that socket restriction.
- An initial guest test archive omitted `apk-catalog`, causing one import
error; the complete archive produced the 33-test passing Windows run.
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
`/tmp/frame-windows-full-tests-unrestricted.log`,
`/tmp/frame-windows-full-fixed-tests.log`, and
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
capture, Windows test and headset evidence independently of those local files.
## Shared Frame session and cleanup
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
Not charging (another supply reports Full). The lock was free.
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
viewer changes, and removed only the lock acquired by this task. The first,
35-second attempt failed because the test wrapper sent CRLF script input and
left SSH stdin open. Production already sends script bytes and closes stdin;
correcting the wrapper resolved both. Its key was removed and lock released.
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
This mostly idle, roughly ten-second sample reported 19 fps and content delay
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
possible profile paths and log, then removed the exact public-key line. The
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
Windows with that key failed with exit 255. Separate checks found no task-owned
viewer processes, the profile absent, and the shared lock released. Battery
remained 99%; the sampled gamescope focus properties matched before/after.
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
## Limits
This verifies a Windows VM's desktop capture, Media Foundation encoding and
real Frame viewer decode/rendering. It does not verify physical-PC hardware
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
controller, tunnel and viewer were exercised directly. No independent review was performed.
@@ -1,53 +0,0 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
"windows": [],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800,
"capture_api": "dxgi"
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 65
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 113339
}
]
}
@@ -1,96 +0,0 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windows": [
{
"src": "window:131836",
"id": 131836,
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
"app": "Windows",
"w": 1115,
"h": 628,
"x": 51,
"y": 52
},
{
"src": "window:327926",
"id": 327926,
"title": "Administrator: Install",
"app": "Windows",
"w": 979,
"h": 512,
"x": 85,
"y": 78
},
{
"src": "window:65856",
"id": 65856,
"title": "Program Manager",
"app": "Windows",
"w": 1280,
"h": 800,
"x": 0,
"y": 0
}
],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
}
]
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

@@ -1,65 +0,0 @@
{
"gstreamer": "1.28.8",
"plugins": [
"coreelements",
"videotestsrc",
"videoconvertscale",
"app",
"jpeg",
"videoparsersbad",
"d3d11",
"mediafoundation"
],
"packages": [
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
],
"elements": {
"d3d11screencapturesrc": {
"name": "The name of the object",
"parent": "The parent of the object",
"blocksize": "Size in bytes to read per buffer (-1 = default)",
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
"do-timestamp": "Apply current stream time to buffers",
"automatic-eos": "Automatically EOS when the segment is done",
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
"monitor-handle": "A HMONITOR handle of monitor to capture",
"show-cursor": "Whether to show mouse cursor",
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
"crop-width": "Width of screen capture area (0 = maximum)",
"crop-height": "Height of screen capture area (0 = maximum)"
},
"d3d12screencapturesrc": null,
"mfh264enc": {
"name": "The name of the object",
"parent": "The parent of the object",
"qos": "Handle Quality-of-Service events from downstream",
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
"bitrate": "Bitrate in kbit/sec",
"rc-mode": "Rate Control Mode",
"adaptive-mode": "Adaptive Mode",
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
"cabac": "Enable CABAC entropy coding",
"sps-id": "The SPS id to use",
"pps-id": "The PPS id to use",
"bframes": "The maximum number of consecutive B frames",
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
"content-type": "Indicates the type of video content",
"qp": "QP applied when rc-mode is \"qvbr\"",
"low-latency": "Enable low latency encoding",
"min-qp": "The minimum allowed QP applied to all rc-mode",
"max-qp": "The maximum allowed QP applied to all rc-mode",
"qp-i": "QP applied to I frames",
"qp-p": "QP applied to P frames",
"qp-b": "QP applied to B frames",
"ref": "The number of reference frames",
"d3d11-aware": "Whether device can support Direct3D11 interop"
}
}
}
@@ -1,18 +0,0 @@
WindowsProductName WindowsVersion OsBuildNumber
------------------ -------------- -------------
Windows 10 Pro 2009 26200
Name DriverVersion Status
---- ------------- ------
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
Used Free
---- ----
23951253504 9314816000
@@ -1,79 +0,0 @@
python.exe : test_original_mac_congestion_and_recovery
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
compiler not installed; native build jobs cover this trace'
At line:12 char:1
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
Exception
+ FullyQualifiedErrorId : NativeCommandError
test_controller_is_inert_after_close
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
test_library_path_prepend_adds_no_empty_entry
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
test_linux_uses_only_portal_fd_and_node
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
test_malformed_viewer_input_is_dropped_not_fatal
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
test_pc_card_receives_window_capture_limitation
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
test_pc_reuses_launcher_and_namespaces_panel_ids
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
test_portal_input_clamps_and_releases_held_state
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
test_refused_windows_focus_never_clicks_the_covering_app
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
test_session_end_releases_native_input_only_once
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
test_stats_keep_capture_time_and_bound_records
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
test_windows_release_and_negative_monitor_origin
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
test_windows_sources_follow_actual_wgc_capabilities
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
test_windows_uses_wgc_and_media_foundation
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
test_never_offered_off_windows_even_when_turned_on
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
... ok
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
... ok
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
test_turning_off_stops_streams_and_the_helper
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
test_turning_on_checks_for_the_bundled_libraries
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
... ok
test_source_bound_retry_ack_and_stop
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
test_agent_reports_its_host_and_encoder
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
'x264 is only in CI test bundles, not the installer'
test_bundled_pipewire_context_needs_no_system_modules
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
'Linux PipeWire client libraries'
test_installer_bundle_leaves_out_gpl_x264
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
test_last_raw_picture_survives_a_closed_gate
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
in CI test bundles, not the installer'
test_native_property_probe_checks_the_instantiated_element
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
test_card_says_experimental_and_what_is_unverified
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
test_opt_in_is_remembered_with_the_client_preferences
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
test_tool_link_is_windows_only_and_marked_experimental
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
----------------------------------------------------------------------
Ran 33 tests in 1.684s
OK (skipped=4)
-122
View File
@@ -1,122 +0,0 @@
# Family and comfort
Frame Control's Home tab has a **Family and comfort** card, on desktop and
on iPhone. No third-party notification or parental-control app is needed.
This is Frame Control code using Python, Steam and SteamVR already on the Frame.
![Family and comfort controls in the desktop app](img/comfort-desktop.png)
## Sessions
Set a limit of 1–240 minutes, optional break and check-in intervals, then
**Start session**. Break and check-in intervals of 0 turn those reminders off.
**Cancel session** cancels the timer and monitoring without changing the game.
Cancel before starting a session with different settings.
The Frame shows a one-minute warning, then opens Steam Home in its dashboard.
**Games stay running**: save and pause before the limit. Some games pause when
the dashboard opens; others do not. There is no kill, power-off, Steam restart,
account restriction or parental lock. The wearer can return to the game.
**Documented implementation:** the timer is a single, opt-in Python worker in
the Frame user's account. Desktop and iPhone share its state. It keeps going
when the companion disconnects, closes or is suspended. It exits after
completion or cancellation (normally within five seconds). Cancellation waits
for any in-flight SteamVR action to finish within its timeout; it is not a boot
service. A Frame reboot invalidates the session. Suspend counts toward the
limit, using Linux's boot-time clock. If a warning was delayed by suspend or a
SteamVR failure, Home waits until at least a full minute after a successful
warning. A failed Home transition remains active and retries, with an error
shown in the companion. A stale worker is reported as unverified enforcement.
## Alerts and breaks
During a session, battery, overheating and check-in alerts go to connected
companions. Break reminders and session warnings also appear on the headset.
- **Low battery:** 15% or below while discharging. One alert until charging or
recovery to 20%, so values around 15% do not produce repeated notifications.
- **Overheating:** a thermal zone reaches its own kernel-reported hot/critical
trip, or the battery reports `Overheat`. Missing sensors mean unknown, not
safe. These are status alerts, not medical advice or an extra thermal governor.
- **Check in:** an alert after the chosen number of active minutes.
- **Breaks:** a SteamVR reminder and companion notification at the chosen interval.
**Inferred:** SteamVR activity levels 1 and 2 are a useful proxy for use, not
proof someone is wearing the headset. Inactive readings reset continuous use;
missing readings add no time. Long gaps count at most 30 seconds. Breaks and
check-ins are distinct from the elapsed-time session limit.
Click **Enable / test notifications** on each companion. iOS asks for permission;
macOS, Windows and Linux follow their notification settings. The page also shows
recent events and errors. Keep Frame Control open and connected for companion
alerts. **Phone alerts are local, not push notifications:** iOS suspension,
force-quit or a lost SSH connection prevents live delivery. Old alerts are not
replayed as a notification burst on reconnect. Headset warnings and the session
limit continue without the phone. A physical iPhone's background delivery has
not been verified and is not guaranteed.
## Casting
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
screen where supported. Show that screen to people in the room, or use the
computer/phone's own screen mirroring. It creates no new stream transport,
public URL or LAN server. iPhone uses the inline viewer if full screen is not
available. The image includes private content visible to the wearer.
## What is installed
The shared authenticated `/api/comfort` endpoint copies three bundled Python
files to `~/.cache/frame-control/comfort/<content-hash>/`. Session state and
locks live in `~/.local/state/frame-control/comfort/`, with a private directory
and 0600 state file. There is no network listener or system service. Cancel a
session before removing these directories. The iPhone's normal server still
exits on disconnect; the explicitly started comfort worker is the exception.
## Verification
**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`: shipped
`/opt/steamvr/bin/linuxarm64/vrcmd --notify TEXT` reported success for a custom
reminder. Steam's CDP `SteamUIStore.Navigate('/library/home')` and
`SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main')`
opened Home while the running app ID stayed unchanged. Prior page and dashboard
visibility were restored. Kernel hot/critical trips and SteamVR activity were
read from the real device. No temperature or battery fault was induced.
**Verified locally:** deterministic fake-Frame tests cover late warnings,
failed warnings/Home actions, cancellation, activity gaps, thresholds, duplicate
suppression, reboot invalidation, shared session state and the exact Home
JavaScript. `python3 -m unittest discover -s tests` runs them. The iOS Simulator
build tests notification content and bounds. Physical iPhone delivery and
wearer-perceived headset notification visibility remain unverified.
**Verified end to end on the same Frame:** a two-minute session with no companion
connection for 135 seconds emitted its warning, break and check-in, then opened
Home. The running app ID was unchanged; the test restored the previous page and
dashboard visibility and confirmed the worker exited. Casting through the Home
shortcut decoded the existing headset stream at 30 fps.
**Verified on the iOS 26.5 Simulator:** connected to the real Frame, approved the
notification prompt, and saw the native Frame Control test banner. Seven iOS
tests passed.
![Native test notification in the iOS Simulator](img/comfort-notification-ios.png)
Desktop and 390-pixel phone layouts had no horizontal overflow.
On macOS the development Electron app's real notification attempt was denied
(`UNErrorDomain` 1); the bridge now returns that failure instead of reporting
success. Successful macOS/Windows/Linux notification display remains unverified.
**Verified on the real Frame:** its naturally discharging 15% battery produced
one low-battery event during a short session; the test then cancelled the
session. Overheating alerts use fake sensor samples in tests: the shared
headset was not deliberately overheated.
**Verified 2026-09-29 on the same Frame:** a fresh one-minute session opened
Home more than 60 seconds after the successful warning. The test restored the
previous page and dashboard visibility. Local regression coverage now includes
slow notification delivery, a total Home-action timeout, failed worker startup,
unreadable saved state, malformed activity samples and notification UX: 173
Python tests passed. Desktop and 390-pixel layouts were checked again; system
notification-denial guidance stayed visible across polls. Initial event history
did not replay notifications, and only the latest new event was announced.
-35
View File
@@ -15,10 +15,6 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
Screenshots and Steam clips have their own buttons on **Tools → Capture &
Live**, and **Settings → Copy to this computer** copies new ones here
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
## Clipboard
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
@@ -40,34 +36,3 @@ desktop has to be running in the headset. It's text only, and pastes over about
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
headset.
### In Frame Control
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
clipboard next to the keyboard and trackpad, both ways:
- **Send text** or **Send this computer's clipboard** puts text on the headset's
clipboard, as above.
- **Copy the headset's clipboard** brings it back: copy something in a desktop
app on the Frame, then press it, and paste on your computer. It calls
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
back as base64, so accents, emoji and line breaks arrive unchanged. The
desktop app and the iPhone app put it on their own clipboard. With the server
in a browser, the server sets the clipboard of the computer it runs on:
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
`wl-copy`, `xclip` or `xsel` on Linux.
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
refused with a message, so send big things as a file. An empty clipboard, or
one holding an image, says so. Neither the server nor its log ever records what
was on the clipboard.
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
Reading that PC clipboard returned the exact marker. The prior headset
clipboard was restored and the test desktop was closed. The PC side used a
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
copy action in a headset app, and image/size error cases remain untested on a
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
+21 -146
View File
@@ -17,23 +17,13 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has six tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
player and the panel switcher; then sending files and text, Flatpaks, remote and
power), **Devices** (your headsets and their addresses), and **Control** (the
searchable Control Center, metrics, supported settings and headset guidance).
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
privacy and updates, library artwork and the assistant. The battery chip opens
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
build. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
The window has four tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports) and **Tools** (sending files and text,
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
anywhere in the window. When the Frame can't be reached, one banner says why in
plain words and the app retries every few seconds, filling everything in once it
answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
@@ -49,27 +39,11 @@ counts them while they run.
gamescope's flat layer instead.
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
save them to `~/Pictures/SteamFrame`.
- **Record** what the headset shows, with its sound, to an `.mp4` in
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
recordings from Steam's game recording at any length, without re-encoding.
See [recording.md](recording.md). Not tested on a headset yet.
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
restore chosen parts after a factory reset, and **Return to Game Mode** when
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Force quit** a game that has stopped responding: Steam games, sideloaded
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
Games lists what's running; each game's own **Force quit…** (on its library
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
a game** card do the same. Each asks first, because unsaved progress is lost.
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
@@ -82,11 +56,9 @@ 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. 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.
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.
- **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
@@ -96,95 +68,14 @@ counts them while they run.
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md).
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
Frame's apps through KDE Connect, and move clipboard text both ways. Send
typed text or your computer's clipboard to the headset, or copy the headset's
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
clipboard**. The clipboard needs the desktop panel open in the headset; it's
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
its own panel in the headset. Place it with the SteamVR dashboard, click and
scroll with the laser, and type on the Mac. Streams hardware H.264 through
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
- **Devices**: several headsets, each with several addresses (LAN IPs per
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
tries them all at once and learns which worked on which network. Add, edit,
reorder and test addresses, find a headset on Tailscale or on this network,
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## Control Center
Open **Control** or `#control` to search controls, status and help. Categories
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
preferences. Search is local: it matches labels, descriptions and familiar terms
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
existing screen; it does not install, cast, open a helper or change a setting.
Home, Games, Android, Tools and Devices retain their existing features.
The overview and detail cards use the shared `/api/status` sample: charge and
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
and VR timings. Values refresh with the normal 30-second poll. A failed status
request, lost connection, headset switch or sample received more than 90 seconds
ago clears current readings and disables audio writes. The client receipt time
and failed-read state are retained through page startup and UI redraws. Only a
new successful status read clears a failure; reusing the retained sample does not.
The headset timestamp is shown without assuming
its clock matches this computer. Missing sensors show **Unavailable**;
they are not converted to zero. Compositor FPS is separate from application FPS;
GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Force quit a game** lists what's running only when you choose **Check what's
running**, and says so when nothing is. Each **Force quit…** names the game and
warns that unsaved progress will be lost before anything is sent; it uses the
selected headset, refuses a second quit while one is running, and drops the
list when you switch headsets.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
blocks duplicate/pending actions, and requests status after command acknowledgment
for readback, draining an earlier poll first. Mute preserves an unapplied volume
draft; switching headsets discards it. It reports
unconfirmed or differing output separately from confirmed state. API validation
rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
Hardware guidance is deliberately separate from controls. Microphone/routing,
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
tracking-origin changes, saved spatial layouts, firmware updates and recovery
have no verified remote setting here. Use the headset's own controls or the
documented manual procedures. Movement comfort remains per-game; automatic
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
Android data backup/restore remain script/MCP capabilities, not new UI switches.
**Verification:** the Control Center's JavaScript runs under Node with mocked
API and DOM boundaries; the server tests mock SSH and reject invalid values
before any device command. Packaging tests cover the shared CSS/JS in desktop
and iPhone bundles. These checks establish wiring, search, navigation, offline,
error and device-switch behavior; they do not establish wearer experience,
physical settings persistence or a new hardware capability.
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
@@ -195,18 +86,13 @@ 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. 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.
trusts root certificates already in the Windows store.
`app/build/fetch-deps.js` downloads both, 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
header, so other websites can't drive it or read captures. Everything reaches
the headset through its SSH alias (`frame` for the first one), pointed at the
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
[devices.md](devices.md)). On macOS and Linux it keeps one
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
@@ -248,8 +134,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
and others). The log is at
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
instead of the app, sending the clipboard, or copying the headset's to this
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
## Building
@@ -262,16 +148,5 @@ 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 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. Settings → Open assistant settings opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
release (`.github/workflows/release.yml`).
+468 -99
View File
@@ -1,121 +1,490 @@
# Frame doctor
# Frame doctor runbook
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
first-time setup, pairing and common Frame failures without applying repairs.
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
installed; the desktop app does not bundle this command.
Checks and fixes for a Frame that's unreachable, crashing, or whose Steam,
SteamVR, Lepton or panels misbehave. A future `scripts/frame-doctor.sh` should
run the checks in section order, print OK, WARN or BROKEN for each, and apply
only the fixes marked **safe**. Anything marked **ask** needs the user's OK,
and anything marked **user** needs a hand on the headset.
Sources are the Frame's own journal and `coredumpctl` history (boots from
2026-09-25 to 2026-09-28) and this repo's docs. Each entry cites where it came
from. Dates are when a fact was seen. BUILD_IDs were 20260922.6101926 until
2026-09-26 and 20260925.6191901 after.
## Never do these
- `modprobe -r ath12k` on a wedged Wi-Fi chip. It oopsed the kernel on
2026-09-28 and caused the displays-broken, Steam-damaged boot in section 3.
- Leave WoWLAN armed. The next sleep breaks Wi-Fi until reboot (section 2).
- Suspend the Frame from a script. Nothing can wake it remotely (section 6).
- Let the SteamOS health checks count up to their repair. The SteamVR one
re-extracts Steam at 3 failures and tries to switch OS slots at 4 (section 4).
- Kill `gamescope` to stop a gamescope crash loop. Kill the orphaned SteamVR
processes instead (section 3).
- Write the sudo password to disk or logs.
- Leave `power.pauseCompositorOnStandby` or `power.turnOffScreensTimeout`
changed after testing (section 3).
- Force a power-off (holding Power) or reset while Steam is extracting or
repairing. That's how files got truncated on 2026-09-28. Use an orderly
`systemctl reboot`/`poweroff` or the power menu. Holding Power is for an
unresponsive Frame, with the user's involvement.
- Run long diagnostics while a Steam or SteamVR restart loop is live without
freezing the health-check trackers first (section 4). The repair threshold is
3 SteamVR failures, and a loop reaches it in under a minute.
## 0. Reaching the Frame
| Path | How | Works when |
|---|---|---|
| Tailscale | `ssh frame` (`frame.<tailnet>.ts.net`) | Wi-Fi up, and Tailscale on the Mac and the Frame |
| LAN | `ssh -o HostName=192.168.1.237 -o HostKeyAlias=frame.<tailnet>.ts.net frame`, or `frame.local` | Wi-Fi up. The alias avoids "Host key verification failed" |
| USB-C | Same, with `HostName=10.86.200.233` | Cable to the Mac, even with Wi-Fi dead. The Mac gets `en9` "Steam Frame" 10.86.200.234/29 (`networksetup -listallhardwareports`) |
| ADB over USB-C | `adb -s frame shell` | SSH refused, for example after Developer Mode was lost ([how-the-frame-works.md](how-the-frame-works.md), boot-loop row) |
- **Asleep means off the network (verified 2026-09-27, unreachable for about
2.5 h).** Every path times out and nothing remote wakes it
(section 6). **user**: press power. `tailscale status | grep frame` on the
Mac shows "offline, last seen N ago".
- **`frame` alias doesn't resolve.** The Mac's Tailscale is off. Use
`frame.local` ([tailscale.md](tailscale.md)). Bare `frame` doesn't resolve on
macOS. Check with `dns-sd -G v4 frame.local` ([ssh.md](ssh.md)).
- **Pairing answers `403 "please put the Steam client in pairing mode"`.**
**user**: Steam, then Settings → Developer → Pair new host. `connect.sh`
retries for 2 min ([ssh.md](ssh.md)).
- **iPhone app can't use devkit pairing.** It only installs an RSA key, and
Citadel signs RSA with SHA-1, which OpenSSH 9.7 rejects. Use ed25519 and
password pairing instead ([ssh.md](ssh.md), 2026-09-27).
- **Locked out after `connect.sh --harden`.** Undo with `sudo rm
/etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd`
(**ask**, over USB-C or ADB) ([ssh.md](ssh.md)).
- **No SSH at all (Developer Mode off).** Run `scripts/serve-bootstrap.sh`, and
the **user** types `curl -fsS mac.local:8765|bash` in Konsole. Stop the
server afterwards, because it's plain HTTP ([ssh.md](ssh.md)).
- **Tailscale exposes every loopback port** (8080 Steam DevTools, 5555
unauthenticated ADB, 27062, 3389) to the tailnet. Check read-only with
`~/.local/bin/tailscale debug prefs | grep ShieldsUp` (the CLI isn't on `PATH`; verified 2026-09-28) and the tailnet ACLs. Report it
as a WARN. `~/.local/bin/tailscale set --shields-up` is a mitigation, not a check, and it
also blocks inbound SSH over Tailscale, so it's **ask**, and only with
another way in available ([tailscale.md](tailscale.md)).
- **sudo:** `printf '%s\n' "$PW" | ssh frame 'sudo -S -p "" …'`. It's the
password the user set on the Frame.
## 1. Boot and crash history
```sh
scripts/frame-doctor.sh
scripts/frame-doctor.sh --alias bedroom --lan frame.local
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
ssh frame 'uptime; journalctl --list-boots --no-pager | tail -n 6'
ssh frame 'journalctl -b -1 -k --no-pager -q | grep -aE "Unable to handle kernel|Internal error|Kernel panic" | tail -n 3'
ssh frame 'coredumpctl list --no-pager --since -1d'
```
Use only addresses belonging to your intended headset. The command tries the
existing `frame` SSH alias first (Tailscale if setup configured it that way),
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
override those defaults. It reports the successful path without printing its
address. It neither discovers other devices nor enables Tailscale.
- **Kernel oops in the previous boot.** Report "oops observed". An oops alone
doesn't prove a reset, because Linux can keep running after one. Classify the
reset as unclean only if the oops is among the last lines of that boot and no
shutdown lines follow
(`journalctl -b -1 -q -n 30 | grep -aE "systemd-shutdown|Reached target.*(Reboot|Power)"`
is empty). On 2026-09-28 the oops was the last thing logged at 20:57:53. After
an unclean reset, check sections 3 and 4 closely.
- **A boot ending with no shutdown lines and no errors.** On 2026-09-26 there
were five boots of 0–12 min like this (−12, −9, −8, −7, −5), with nothing
failing beforehand. They were probably hard power-offs during setup. Treat
them as unexplained, not as crashes.
- **Crash signatures seen so far** (all `coredumpctl`, UID 1000):
The existing alias supplies the user, port, keys and known-hosts file. LAN and
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
alias was set. A first connection therefore needs trusted setup first. The
doctor never accepts a new host key, deletes a saved key, generates keys or
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
can evaluate `Match exec` conditions in that configuration. Proxy commands,
jumps, local commands, forwarding and connection sharing are disabled for
these direct diagnostic connections.
| When | What crashed | Cause | Section |
|---|---|---|---|
| 09-25 21:02–21:03 | vrcompositor SEGV, steamwebhelper SEGV, then Android composer, surfaceflinger and gamescope ABRT | Lepton crash cascade. Two `pasta` processes were both failing to listen on port 16385 just before | 7 |
| 09-25 22:30–22:42 | `app_process64` ×3 | Android apps during APK testing | 7 |
| 09-25 23:20 | `ffmpeg` | hardware H.264 encoder | 10 |
| 09-26 13:56–22:45, 09-27 09:51 | `chromium-xr/chrome` ×16 | Chromium XR (panels, Mac view) | 8 |
| 09-26 21:11–21:49 | XRService ABRT ×9, vrcompositor SEGV ×5, gamescope ABRT ×4 | leftover SteamVR processes from a failed start (29 Steam restarts, 31 SteamVR failures that boot) | 3 |
| 09-28 16:27–17:49 | `app_process64` ×4 | Android runtime amid `binder_user_error` floods | 7 |
| 09-28 17:01 | `kdeconnectd` | SMS plugin during device teardown | 9 |
| 09-28 20:58–21:39 | vrcompositor SEGV ×10, XRService ×15, steamwebhelper ×2 | broken displays after a kernel oops | 3 |
## Reading the result
Per-boot counters a doctor should print:
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
feature works. Optional features that are off may produce a warning.
```sh
ssh frame 'for b in 0 -1; do
k=$(journalctl -b $b -k -q) || { echo "boot $b: journal unreadable"; continue; }
u=$(journalctl -b $b --user -u steam.service -q) || { echo "boot $b: user journal unreadable"; continue; }
s=$(journalctl -b $b -q) || { echo "boot $b: journal unreadable"; continue; }
echo "boot $b dsi=$(grep -ac "wait for video done" <<<"$k") steam_restarts=$(grep -ac "Scheduled restart" <<<"$u") steamvr_fail=$(grep -ac "steamvr.service: Failed" <<<"$s")"
done'
```
| Section | Read-only evidence and limits |
Report an unreadable journal as unknown, not as zero. What matters is
whether the counts are **still rising**, so run it twice a minute apart. One
or two SteamVR start failures around boot are normal
([how-the-frame-works.md](how-the-frame-works.md), boot-loop row). Healthy
boots on 2026-09-28 were 0 / 0 / 0. The 2026-09-26 21:22 boot reached
0 / 29 / 31 (leftover processes), and the 2026-09-28 20:58 boot reached
424 / 61 / 62 (broken displays), both rising every ~15 s.
## 2. Wi-Fi
| Check | Healthy | Broken |
|---|---|---|
| `nmcli -t d \| grep ^wlan0` | `wlan0:wifi:connected:…` | `wlan0:wifi:unavailable:` |
| `journalctl -b -k \| grep -a ath12k` | none, or a few at boot | `failed to wakeup from wow: -110`, `Resuming from non M3 state (RESET)`, `WMI_PDEV_SET_PARAM_CMDID timeout`, `fail to start mac operations` |
| `iw phy phy0 wowlan show` | `WoWLAN is disabled.` | `wake up on magic packet` |
- **WoWLAN armed (safe).** Disarm it by UUID, because the user may have made
same-name duplicates. `default` means "use NetworkManager's global
`wifi.wake-on-wlan`". The Frame sets none (checked 2026-09-28), so it falls
back to `ignore`, which leaves the chip untouched and doesn't clear an armed
chip. `0` disarms it:
```sh
set -e
U=$(nmcli -t -f UUID,DEVICE c show --active | awk -F: '$2=="wlan0"{print $1}')
[ -n "$U" ] || { echo "no active connection on wlan0"; exit 1; }
nmcli -g 802-11-wireless.wake-on-wlan c show "$U" # record the old value
systemd-run --user --wait --pipe -q nmcli c modify "$U" 802-11-wireless.wake-on-wlan 0
systemd-run --user --wait --pipe -q nmcli device modify wlan0 802-11-wireless.wake-on-wlan 0
iw phy phy0 wowlan show | grep -q "WoWLAN is disabled" || { echo "still armed"; exit 1; }
```
Use the **active** connection on wlan0, because there can be same-name
duplicates. `c modify` saves the setting. `device modify` changes only
WoWLAN on the live device, unlike `device reapply`, which would also apply
any other saved changes such as IP or DNS. Tested 2026-09-28: Wi-Fi stayed
connected. NetworkManager only allows the
modify under `systemd-run --user`. From SSH it's `auth`. Leave the profile
at `0`, since that stays safe even if a global `wifi.wake-on-wlan` is added
later. Setting the recorded old value back is **ask**. On 2026-09-28 the
profile was set back to `default` by hand. If the Wi-Fi is `unavailable`,
there's no active connection, so this has to wait for the reboot, and then
arming comes from the profile.
- **`unavailable` after resume (user/ask).** Do a **clean** reboot: power
menu, or `sudo systemctl reboot` over USB-C. Never reload the module.
- **Duplicate "ThisIsTheWifi" profiles.** Ones with `TIMESTAMP-REAL` `never`
are unused. Deleting them is **ask**.
## 3. Displays, SteamVR, gamescope
| Check | Healthy | Broken |
|---|---|---|
| `journalctl -b -k \| grep -ac "wait for video done"` | `0` | hundreds (`msm_dsi ae94000.dsi / ae96000.dsi`) |
| `coredumpctl list vrcompositor --since -10min` | none | SEGV every ~15 s |
| `grep -a "failed to wait for present" ~/.local/share/Steam/logs/vrcompositor.txt` | none recent | `WaitForPendingPresent: failed to wait for present` |
| `journalctl -b --user -u steamvr.service \| grep -a "left-over process"` | none | `Found left-over process … (vrserver) … (vrcompositor) in control group` |
| journal `gamescope` | quiet | `rendervulkan.cpp:2181 … Assertion '!modifiers.empty()'` about once a second |
- **Broken displays (DSI timeouts).** The chain is: the GPU can't present,
vrcompositor SEGVs on its first frame, `steamvr.service` fails and stops the
gamescope VR session, and gamescope and Steam get SIGKILLed. The user sees
"There was an issue launching Steam". Fix (**ask/user**): a **clean**
reboot. An unclean reset after a kernel oops caused it, and the clean reboot
had 0 DSI errors (2026-09-28). Don't touch Steam while this is happening.
- **Leftover SteamVR processes (2026-09-26 21:22 boot).** A first
`steamvr.service` start failed on `dependency`, its vrserver, XRService and
vrcompositor kept running, and each restart crashed against them. The same
fix as the next item applies, with the same guard.
- **gamescope crash loop on `!modifiers.empty()`** (verified 2026-09-25,
[apks.md](apks.md)). gamescope keeps attaching to SteamVR processes orphaned
from a dead session. The broad fix is
`for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done`,
and it recovers within about a minute. That kills **every** matching
process, including a working session, so it's always **ask**. A doctor may
signal automatically only **individually verified stale PIDs**, and only
when **all** of these hold:
- The loop is live: new vrcompositor/gamescope crashes in the last 2
minutes, and `NRestarts` rising between two reads.
- The process started before the current `steamvr.service` main process:
compare `ps -o pid,lstart,args -C vrserver,vrcompositor,vrdashboard`
with `systemctl --user show steamvr.service -p ExecMainStartTimestamp`.
- The journal ties it to the failed run:
`Found left-over process <pid> (…) in control group`.
Re-read `/proc/<pid>/stat` start time and `comm` just before signalling, and
signal by number, never by name. A process that's merely outside
`steamvr.service`'s cgroup could be a legitimate launch, so that's **ask**.
- **Standby test settings left on.** Check that `vrcmd --get-settings`
(or `~/.config/openvr/config/steamvr.vrsettings`) shows
`power.pauseCompositorOnStandby` = 1 and `power.turnOffScreensTimeout` = 5.
If they differ, report a WARN and leave them alone (**ask**), since the user
may want them. If a doctor run changes them for a test, it must snapshot both
values first, including whether they were set in `steamvr.vrsettings` at all.
Afterwards it restores exactly those values, removing the keys if they were
absent, rather than the defaults 1 and 5.
The bool setter needs `1`/`0`, not `true`
([how-the-frame-works.md](how-the-frame-works.md)).
- **Dashboard open over an app** (`visible-blurred` just means it's open).
Run `SteamClient.OpenVR.VROverlay.HideDashboard()` over CDP on port 8080
only when the doctor itself is driving an app test. Otherwise it's **ask**,
because the user may have opened it.
- **Steam launch stuck in standby** at `ShowInterstitials`/`CreatingProcess`
(`console_log.txt`). Run `SteamClient.Apps.ContinueGameAction(<action id>,
"<appid>", "<task>")` over CDP.
## 4. Steam client and the SteamOS health checks
| Check | Healthy | Broken |
|---|---|---|
| `systemctl --user show steam.service -p NRestarts` | `0` or stable | climbing every ~15 s |
| `tail -n 40 ~/.local/share/Steam/logs/connection_log.txt \| grep -a "Logged On"` | `[Logged On, …] [U:1:<id>]` | only `[Logged Off, 0, 0] [U:1:0]` |
| `grep -a BVerifyInstalledFiles ~/.local/share/Steam/logs/steam_output.log` | none | `<file> is N bytes, expected M`, `bad symlink …` |
| last line of `steam_output.log` | client running | `Installing update...` or `Extracting package...` for minutes |
| `pgrep -af child-update-ui` + `/proc/<pid>/wchan` | none | `drm_syncobj_array_wait_timeout` |
| `cat /run/user/1000/steam{,vr}-short-session-tracker; ls -l` those files | empty | `frog…` / `frog:glasses:…` building up |
Check section 3 first. If the displays are broken, Steam can't get past its
first frame, whatever the files look like.
**The two health checks** (read from `/usr/share/deckard/`, 2026-09-28):
- `steam-health-check` (run by `steam.service`) appends `frog` to
`steam-short-session-tracker` for each run that fails in under 120 s or lasts
under 5 s. At 5 it runs `do_repair`. On BUILD_ID 20260925.6191901 the
script deletes `~/.steam` (keeping `registry.vdf`) and then either extracts
`/usr/lib/steam/steam.tar.zst` (705 MB) over `~/.local/share/Steam` (the
"unpacked" install this Frame has) or, on an overlay install, deletes the
upper-dir files that shadow `/usr/local/steam`. Then it touches
`.install-complete`. It also repairs at **every Steam start** if
`.install-complete` is missing, whatever the counter says.
- `steamvr-health-check` (run by `steamvr.service`) appends `frog:glasses:`
for each failed or under-10-s SteamVR run. At 3 it runs `steam-health-check
--repair-now`. At 4 it also runs `steamos-bootconf set-mode reboot-other`,
which fails as non-root.
- **What a repair erases isn't consistent across notes.** On 2026-09-26
(BUILD_ID 20260922.6101926) the boot-loop row in
[how-the-frame-works.md](how-the-frame-works.md) records that all of
`~/.local/share/Steam` was deleted, including games, login and Developer
Mode. On 2026-09-28 the scripts above only extract over it, a repair ran at
21:13 (`.install-complete` mtime), and the login survived. Treat any repair
as possibly destructive. Before a restart that could trigger one, check that
`.install-complete` exists.
- **Stop them counting while you fix the cause (safe, resets at boot).** Do
this **first**, right after connecting, if `NRestarts` or either tracker is
rising, before any long checks:
```sh
rc=0
for f in /run/user/1000/steam-short-session-tracker /run/user/1000/steamvr-short-session-tracker; do
{ [ -e "$f" ] || : > "$f"; } && chmod u+w "$f" && : > "$f" && chmod 444 "$f" || rc=1
# verify: empty and not writable
[ -e "$f" ] && [ ! -s "$f" ] && [ ! -w "$f" ] && echo "frozen $f" || { echo "NOT frozen $f"; rc=1; }
done
exit $rc
```
A doctor must stop and not restart Steam or SteamVR unless this exits 0.
It's idempotent, so run it on files that are already 444. Both were
already 444 on 2026-09-28, applied by an earlier session. This only stops
the **counting**. The start-time repair when `.install-complete` is missing
still runs. Re-apply after every reboot while the loop's cause is unfixed.
Fixes:
- **Verify files yourself (safe, read-only).** The record is
`~/.local/share/Steam/package/steam_client_<branch>_linuxarm64.installed`,
with lines of `path,size;mtime;crc32` (size `-1` is a directory). Compare
sizes and `zlib.crc32` (13,518 files on 2026-09-28). `steam_output.log` is
rewritten on every launch, so copy it before the next restart. `bad symlink`
reports taken mid-extraction are transient.
- **Truncated files (ask).** Restart Steam (`systemctl --user restart
steam.service`), and it re-verifies and re-extracts from `package/`.
Preconditions:
- The displays are healthy.
- The trackers are frozen.
- `.install-complete` exists.
- The updater is idle: the `steam_output.log` tail hasn't changed for 60 s
and the steam process isn't writing (`/proc/<pid>/io` `write_bytes` is
steady).
- No game or app is running.
Re-verify afterwards.
- **Updater deadlocked on the update UI.** Kill only the `-child-update-ui`
process, and the install continues (worked 2026-09-26). On 2026-09-28 it
was followed by a truncated `steamui.so`, so re-verify afterwards. If the
deadlock came from broken displays, fix those first.
- **Stale pending install (ask, with backup).** `package/steam_client_<branch>_linuxarm64`
with no extension means an install is pending. Only act when all of these hold:
- `cmp` shows it's identical to `.manifest`.
- The verify is clean.
- The updater is idle (as above).
Then stop Steam, move it to `~/.cache/frame-control/…pending-backup`, and
start Steam. The log should
show `Nothing to do`, then `Verification complete`, then webhelpers.
- **Heavy repair (ask).** `steam-health-check --repair-now`, or the boot
menu's `Repair Steam Installation` (section 12).
- **Dead ends (don't repeat).**
- `STEAM_EXTRA_ARGS=-no-child-update-ui` still draws GLX in-process and
blocks.
- With `DISPLAY` unset, Steam exits ("XOpenDisplay failed"), with no text
fallback.
- Xvfb has no GLX visual here.
- Steam's launch path is `steam.service` → `/usr/share/deckard/select_steam.sh
RUNSTEAM.sh`. Runtime drop-ins in `/run/user/1000/systemd/user/steam.service.d/`
are cleared at reboot. Remove any you add.
- **`create-shortcut` refuses ids with hyphens** (`missing/invalid arguments`).
Ids must match `^[A-Za-z_][A-Za-z0-9_.]+$`. It reports "Steam client is not
running" when Steam is down, and re-running finishes the install without a
re-upload ([sideloading.md](sideloading.md)).
## 5. Idle sleep and keep-awake
- **Frame slept mid-task.** SSH doesn't count as activity, and Steam's idle
timer (`system_idle_suspend_ac_sec` 3600, `…_battery_sec` 900) suspends it.
The journal shows `Switching to power state: [ k_ESystemPowerState_Sleep ]`.
Fix (**safe**): `scripts/keep-awake.sh on` before long work and `off` after.
It uses one shared unit and one saved-settings file. So a doctor records
whether `fc-keep-awake` was already active, and runs `off` only if it was
the one that turned it on. Otherwise it releases another task's lock and
restores that task's saved timers.
It sets both timers to 0 and holds the `fc-keep-awake` user-unit inhibitor.
A plain SSH-session inhibitor is refused.
- **Check:** `systemctl --user is-active fc-keep-awake`,
`systemd-inhibit --list | grep "Frame Control"`. WARN if it's held with no
agent working, since that drains the battery on battery power.
- **Charging shows "Discharging" at ~0 W while full on a charger.** This is a
reporting quirk. Treat under 0.5 W on a charger as "not charging"
([how-the-frame-works.md](how-the-frame-works.md)).
## 6. Remote wake
It doesn't work on this build. WoWLAN arms, but the WCN7850 is reset in both
`deep` and `s2idle`, packets don't wake it, and Wi-Fi is dead after resume.
Tested 2026-09-28. Details are in [how-the-frame-works.md](how-the-frame-works.md)
and [open-questions.md](open-questions.md). Doctor checks: `cat
/sys/power/mem_sleep` should read `s2idle [deep]` (resets at boot), and WoWLAN
should be disabled.
## 7. Lepton (Android)
| Check | Broken sign |
|---|---|
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
| `podman ps --format '{{.Names}} {{.Ports}}'` | two containers with the same name or instance, or both bound to the same host port. Several instances with different ports are normal ([apks.md](apks.md)) |
| journal `pasta` | `Listen failed for HOST TCP port 0.0.0.0/16385: Address already in use` repeating |
| journal | `android.hardware.graphics.composer@2.1-service` or `surfaceflinger` aborts right after an app crash |
| `dmesg` / journal | floods of `binder_user_error: N callbacks suppressed` near `app_process64` crashes |
| journal | `Clearing baked app data due to non steamlaunch container` |
Journal samples are capped at 2,000 entries; current-boot samples cover the
last ten minutes. This can miss older or high-volume failures. Empty,
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
Commands time out individually, and each SSH probe is bounded to 90 seconds.
- **Duplicate Lepton containers or port clash (2026-09-25 21:01).** Two
`pasta` instances fought over 16385, and a minute later the compositor,
webhelper, Android composer, surfaceflinger and gamescope crashed together.
Fix (**ask**): find the two instances that share the port (`podman ps`,
`ss -ltnp | grep 16385`) and stop only the duplicate. Leave other instances
running.
- **Lepton's graphics HAL aborts after an app crash, taking the container down**
(3 times on 2026-09-25). It's intermittent, so retry ([apks.md](apks.md)).
Then check section 3 for a gamescope loop.
- **All ADB-installed apps gone.** Lepton Development wipes its data whenever
it exits outside a Steam launch. Use `install-apk.sh` (a per-app Steam
launch, whose data survives), or the launch option `LEPTON_NO_CLEANUP=1
%command%` (inferred) ([apks.md](apks.md)).
- **App dies on first file write with `ENOENT`.** Its `STEAM_COMPAT_DATA_PATH`
is outside `~/.local/share/Steam`. Use `steamapps/compatdata/<id>`.
- **Lepton won't start outside Steam.** Set `IS_PARENT=true` and use `setsid
--wait`. "unbound variable" means `STEAM_COMPAT_SHADER_PATH` is unset
([apks.md](apks.md)).
- **App compatibility, not a fault** ([apks.md](apks.md)):
- Compose older than 1.11, SDL2/Kivy and Godot 4.3 crash on the missing
clipboard service.
- `INSTALL_FAILED_OLDER_SDK` means minSdk is over 30.
- `INSTALL_FAILED_NO_MATCHING_ABIS` means there's no arm64 build.
- `monkey` returning `-5` means you should launch the activity directly.
`--brief` prints a metadata line first, so take the last line:
`adb -s $S shell am start -W -n "$(adb -s $S shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER <pkg> | tail -n 1)"`.
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
unknown checks after connecting; **2** if no authenticated SSH path exists or
local SSH setup is unavailable. Panel verification currently means a connected
run normally exits 1. Automation should read the labelled checks rather than
treating a warning as proof the headset is unusable.
## 8. Chromium XR (panels, Mac view, WebXR)
## First-time setup and pairing
- **16 crashes on 2026-09-26/27.** The logs showed `Failed to create a
temporary file for memory-mapping: No such process (3)`, then `Received
signal 11 SEGV_MAPERR`. The cause isn't known yet. Check with
`coredumpctl list chrome --since -1d`.
- **Zygote crash about 30 s after a Steam-launched start.** Steam's
`gameoverlayrenderer.so` is in `LD_PRELOAD`, and the launcher strips it
(verified 2026-09-27, [webxr-chromium.md](webxr-chromium.md)). Check that
the running chrome's `/proc/<pid>/environ` has no `gameoverlayrenderer`.
- **XR process seccomp crash (syscall 209) or `VRInitError_Init_Internal`.**
The launcher runs with `--disable-seccomp-filter-sandbox`. Use that profile
only for VR sites ([webxr-chromium.md](webxr-chromium.md)).
- **Mac view kept working when Steam was down** (2026-09-28). It's a separate
Chromium talking to the Mac over the LAN. It's a useful way in when Steam is
broken, but it's killed by any reboot and needs relaunching.
- **No SSH response:** wake the headset with its power button. Check the cable
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
failure can look alike; a timeout cannot distinguish them.
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
open Steam Settings → Developer → **Pair new host**, then approve setup's
request. The doctor only checks whether devkit port 32000 responds; that
does not prove pairing mode is open or the service is healthy.
- **Unknown or changed identity:** verify the intended headset through setup
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
- **No devkit response:** check Developer Mode and the address. See
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
## 9. KDE Connect
No password is requested. Enter any password required by the separate setup
flow only in its local terminal; never paste it into an issue or report.
- **`kdeconnectd` crashed on 2026-09-28 17:01** in `kdeconnect_sms.so` under
`Device::~Device` (device teardown). Check whether it's still running with
`pgrep -f frame-control/kdeconnect/root/usr/lib/kdeconnectd`. Fix
(**safe**): restart it the way this repo launches it. A doctor should
report a missing daemon rather than guess.
## Restart safety and recovery
## 10. Streaming and capture
Before deeper checks, and again afterwards, the doctor samples Steam and
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
counters stop diagnostics immediately. Nonempty health-check trackers also stop
deeper checks even when stable, since a short sample could miss a slower loop.
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
counters with empty trackers still cannot rule out a slower restart loop.
- **`ffmpeg` crash with `h264_v4l2m2m`** (hardware encoder, 2026-09-25/26).
Use `libx264 -preset ultrafast -tune zerolatency`
([how-the-frame-works.md](how-the-frame-works.md)).
- **`vrcmd --screenshot` writes nothing.** Use `ui/frame_vrshot.py`
(`IVRScreenshots`).
- **No Mac cursor in the VNC mirror.** Load `scripts/mac-cursor-ring.lua` in
Hammerspoon on the Mac (`dofile(".../scripts/mac-cursor-ring.lua")` in
`~/.hammerspoon/init.lua`). It isn't a standalone script. Toggle it with
ctrl+alt+cmd+M.
- **Remmina asks for the Mac login password.** macOS offers RFB type 30
first. Seed the password with `--update-profile … --set-option password`
([streaming.md](streaming.md)).
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
is the source for these section numbers and observed signatures (September
2026 builds). Its section 4 describes freezing and verifying health-check
trackers before prolonged recovery during a live loop. That changes headset
state, so this read-only implementation stops and directs you there instead.
It does not implement issue #34's automatic fixes, before/after repair runs,
or full recovery automation. Review build-specific recovery instructions with
the person using the headset; a repair can remove data.
## 11. Panels
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
reset or force power off during extraction/repair, or write passwords to disk.
The doctor never restarts services, signals processes, repairs Steam, changes
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
a report. Nothing needs restoring after its checks. Manual/device tests must
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
- **Window stays on the default panel.** Run one `panel-on-frame.sh` at a
time. Tag windows by hand with `DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c
-set STEAM_GAME <id>` ([panels.md](panels.md)).
- **Single-instance apps** (Remmina, KDE). Close them in Plasma first.
- **Wayland-only apps** can't be floated this way.
- **Dragging selects instead of scrolling.** That's by design for tagged
windows (laser mode).
- **`Failed to get app info`** for a made-up id is benign.
## Privacy and verification
## 12. Boot loop, recovery, re-image
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
arguments, environment variables, SSH errors, account IDs, network addresses,
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
the only pairing-service probe is a TCP connection. SSH produces normal access
logs. Review summaries before sharing them; feature availability can still be
personal information. Redirecting stdout saves a report only if you choose it.
Work down this list, least destructive first ([recovery-and-images.md](recovery-and-images.md)).
The boot menu is inferred from Valve's docs and hasn't been tried on this Frame.
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
stable restart counters are explicitly simulated, not proof about SteamOS.
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
`20261015.6141207`. It verified the configured SSH alias and key login,
then reported `WARN Restart safety`: nonempty health-check trackers. It
correctly skipped all eleven deeper sections and exited 1. No raw logs,
addresses or keys were printed, and no repair or restart was attempted.
This verifies the safety stop on that device; the deeper checks are not
claimed as headset-tested.
1. **If the Frame is reachable, freeze the health-check trackers first and
verify them** (section 4), then diagnose. A reboot clears the freeze and
restarts the failing services, and 3 SteamVR failures trigger a repair.
2. **Clean reboot** only when the diagnosed fault needs one (broken displays,
dead Wi-Fi). Straight after reconnecting, freeze and verify the trackers
again before anything else.
3. **Boot menu (user):** shut down cleanly if the Frame responds. Hold Power
~10 s until the LED is off only if it doesn't, and never while Steam is
extracting or repairing. Then power on holding **AUX** (top button). Choose `Previous` (the other A/B slot, keeps data),
then try `Repair Steam Installation`.
4. **`Erase User Data`** wipes `~`: SSH keys, Tailscale, Flatpaks and setup
(**ask**).
5. **Re-image** with `steamframe-oobe-repair-<build>` over USB or cable/EDL
(`qdl`). Copies are in `~/Downloads/steam-frame-recovery/` (**ask**).
## What a doctor script should do
1. Find a path (Tailscale, then LAN, then USB), and report which one worked.
2. Print uptime, the last boots, and whether the previous boot ended in an
oops.
3. Run the read-only checks in sections 2–11 and print one line each: OK,
WARN or BROKEN.
4. **Order matters.** If a restart loop is live (`NRestarts` or a tracker
rising between two reads a few seconds apart), freeze the trackers
**before** any other check. Then fix displays before Steam. After any
reboot, check the trackers again, because they reset.
5. Apply only **safe** fixes, printing each command. For reboots, deleting
profiles, heavy repairs and anything touching a user profile: print the
fix and ask.
6. Never do anything under "Never do these".
7. Re-run the checks after any fix and report the before and after.
8. The fake-Frame harness (`fakeframe-ctl sleep|disk-full|sshd|devkit-service|keys`,
[testing.md](testing.md)) can exercise the unreachable, disk-full and
no-SSH branches without a headset.
## Incident 2026-09-28
| Time | What happened |
|---|---|
| 19:36 | WoWLAN armed, `deep` suspend, magic packets sent. No wake. Power-button resume: chip in MHI RESET, Wi-Fi dead. User restarted. |
| 20:10 | WoWLAN disarmed. Clean boot, no DSI errors. |
| 20:29 | `s2idle` via sudo, WoWLAN re-armed, suspend. No wake, same chip reset, Wi-Fi `unavailable`. |
| 20:57:53 | Over USB-C: `modprobe -r ath12k`, and the **kernel oopsed** and the Frame reset itself. |
| 20:58 | Boot with `steamclient.so` truncated (18.6 of 50.3 MB) and DSI timeouts from +28 s. Steam "couldn't connect", then "There was an issue launching Steam". Mac view still worked. |
| 21:00–21:13 | Steam re-extracts and hangs on "Installing update..." (update UI stuck on the GPU). Killing the UI child let it continue, and `steamui.so` was later found truncated. The health-check repair ran at 21:13. |
| 21:28 | Own CRC check: all 13,518 files OK. |
| 21:30 | Moved aside the identical pending manifest. Steam reached login, then died every ~15 s: vrcompositor SEGV on DSI timeouts. |
| 21:39 | User did a clean reboot. 0 DSI errors, Steam logged on 21:40:30, NRestarts 0. |
-91
View File
@@ -1,91 +0,0 @@
# Game storage
Open **Tools → Game storage** for a read-only snapshot of the headset's user
filesystem and Steam libraries. Refresh after an installation, download or move.
The page never moves, deletes or modifies content, starts Steam, requests root,
or recursively scans game files. It works through the existing SSH connection.
## What the numbers mean
- **Total, free and used** come from `statvfs` on the headset user's home and
each library path. Free is space available to that user; reserved blocks mean
used + free can be less than total. These are filesystem sizes, not advertised
drive capacity, and do not include separate system partitions.
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
the default Steam library. Modern nested and older numbered-path records are
accepted; canonical duplicate paths are shown once. No drive is mounted.
- Two paths can share one filesystem. Their capacity must **not** be added.
An unmounted drive whose mount directory still exists can report the parent
filesystem's capacity: the page labels this as the filesystem containing the
path, rather than claiming the removable drive is connected.
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
largest first, with unknown sizes last. A missing, negative or invalid size
is **Unknown**, while an explicit zero remains zero. Records may be stale,
including during downloads or moves; they do not measure allocated blocks.
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
Additional apps or runtimes may fall in the game/app group.
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
sideloaded games, media and OS files are **unmeasured other usage**. The page
does not subtract manifest totals from filesystem usage to invent a size for
them. Recorded content is not an exhaustive storage breakdown.
Permission denied, missing/disconnected paths, unavailable filesystem reads,
invalid metadata and partial inventories are reported separately. A failed read
is never proof of an empty library. If library discovery fails, other libraries
may be missing from the view. Refresh clears the old results immediately; a
headset switch clears the snapshot and ignores late replies from the old device.
## Moving a game safely
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
choose the source drive, select the games and choose **Move**, then pick a
Steam-supported destination. A second Steam library must already be available.
Steam may withhold Move or a destination when it cannot perform the operation.
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
Do not manually delete files to make room or interrupt an in-progress move.
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
handoff: the page does not send a URI or silently open a UI on the headset.
The guide's separate Windows instructions for moving the entire Steam client
are outside this feature's scope.
### Why Frame Control does not automate moving
The repository's Valve devkit tools register and launch developer titles; no
supported game-move operation is provided there. The existing Steam CEF bridge
uses internal client APIs, which are not a verified supported contract for a
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
and installed-depot queries but no documented move transaction with completion,
cancellation, rollback and error guarantees. The public support guide describes
the user-operated Storage UI, rather than such an automation interface.
Consequently this feature adds no move endpoint or guessed internal Steam call,
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
requires a supported interface and verified behaviour for full disks, disconnects,
running games, downloads, cancellation and Steam restart first.
## Verification and limits
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
missing discovery/drives, reserved capacity and read-only file preservation.
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
with DOM/transport boundaries faked, including escaping, offline errors,
out-of-order refreshes and headset switches. It runs through unittest discovery.
The production storage component was rendered with synthetic data at 390px and
900px widths; both fit without horizontal overflow. This checks component
layout, not the full connected application or headset browser.
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
to a real Frame running SteamOS build `20261015.6141207`. It reported
710.4 GiB free on the user filesystem, one available Steam library, 29
readable app manifests, zero unknown sizes and zero parse/permission issues.
No game names or raw metadata were printed in the test report. The
connected UI, removable media, Steam's Move UI and an actual game move were
not tested. UI and error-state fixtures remain synthetic.
+2 -11
View File
@@ -38,11 +38,6 @@ 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` |
@@ -52,23 +47,20 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
| **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
| **A Chromium app window on gamescope's `:0` with its own `STEAM_GAME` becomes a panel, even when started over SSH.** `chromium-xr/chrome --ozone-platform=x11 --app=URL --window-size=1280,720` got a 1920×1080 window, and tagging it produced `[Overlays] Created: valve.steam.desktopgame.<id>` in `~/.local/share/Steam/logs/vrwebhelper_systemui.txt`. Chromium XR decoded a 1080p H.264 WebCodecs stream from the Mac at about 60 fps. XTest events sent to `:0` (libXtst through Python ctypes) did not reach the page. The Frame has `libXtst`, `xprop`, `xwininfo`, `curl` and the `C.utf8` locale. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md) |
| **Streaming video into a panel: what the Frame adds.** Chromium XR decodes H.264 in **software** (1920×1290 at about 9 Mbit/s took 4–6 ms per frame); its GL is ANGLE → zink → Turnip on the Adreno 750, and it logs `GetVSyncParametersIfAvailable() failed`. An idle page's `requestAnimationFrame` runs at about 140 Hz; when the headset is worn or woken, SteamVR picks the 4320×2160 @ 144 Hz mode. **An unworn headset throttles panel apps** whatever they draw: a local canvas page ran at 58 fps for about 6 s, then 28, then about 15 once in standby (vrserver logs `entering standby`, with `power.pauseCompositorOnStandby 1`). `vrcmd --handlewakeup` gave 137–144 fps for about 2 s, then 36. So a panel's frame rate and compositor delay can only be measured while it's worn. `tailscaled` runs with `--tun=userspace-networking` and cost about 8% of a core at 9 Mbit/s (0.5% over the LAN address). Wi-Fi power saving is on (`iw … get power_save`). **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md#measuring) |
| **USB-C networking.** Plugged into a Mac, the Frame is a USB network device: macOS names the port "Steam Frame" (here `en9`, 10.86.200.234/29), and the Frame's `usb0` is 10.86.200.233. Ping is about 0.9 ms, and SSH works with the usual host key (`-o HostName=10.86.200.233 -o HostKeyAlias=<tailscale name>`). Steam's Remote Play discovery also broadcasts over it. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | Frame Control's Mac stream uses it when present ([mac-in-headset.md](mac-in-headset.md)) |
| **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 |
| **Wake-on-WLAN doesn't work from `deep` or `s2idle`, and leaving it on breaks Wi-Fi after resume. Leave it off.** Sleep is `PM: suspend entry (deep)` (`/sys/power/mem_sleep` = `s2idle [deep]`). The Wi-Fi is a WCN7850 on `ath12k_pci` (PCIe, SM8650). Magic-packet WoWLAN can be armed without sudo: under `systemd-run --user --wait --pipe`, `nmcli c modify <connection> 802-11-wireless.wake-on-wlan magic` then `nmcli device reapply wlan0` makes `iw phy phy0 wowlan show` report `wake up on magic packet` (from SSH, `settings.modify.system` is only `auth`). **Tested 2026-09-28**, BUILD_ID 20260925.6191901, on the charger: after `PM: suspend entry (deep)` at 19:36:55, a unicast magic packet (UDP 9 and 7, to 192.168.1.237, with the Mac's ARP entry still present) and broadcast packets (192.168.1.255 and 255.255.255.255) got no wake in 30 s each. On a manual power-button wake 12 min later, the journal showed the chip had been reset during sleep: `mhi mhi0: Resuming from non M3 state (RESET)`, then `ath12k_pci: failed to wakeup from wow: -110`, WMI timeouts, `wiphy_resume returns -11`. Wi-Fi then disconnected and didn't come back, and the Frame needed a restart. So WoW did arm (no power-down fallback), but the WCN7850 loses power in `deep`. To turn it off, `wake-on-wlan default` alone doesn't clear the chip. `default` means NM's global `wifi.wake-on-wlan`, and the Frame sets none, so it falls back to `ignore`, which leaves the chip untouched. Set `0` and reapply (`WoWLAN is disabled.`), then set `default` again, or leave `0`. The Steam Deck with iwd fails differently: the NM setting never reached the driver there ([Switchboard](https://github.com/lfkdsk/Switchboard/blob/main/docs/steam-deck.md#wake-on-wlan)). `s2idle` failed the same way (tested 2026-09-28, set with `echo s2idle | sudo tee /sys/power/mem_sleep`, which lasts until reboot): `PM: suspend entry (s2idle)` at 20:29:57, no wake from unicast or broadcast packets, and on the power-button wake the same `Resuming from non M3 state (RESET)` and `failed to wakeup from wow`. Wi-Fi stayed `unavailable` until a restart. So the WCN7850 is reset during sleep either way. That points at ath12k WoW on this kernel/firmware rather than at the sleep depth. `systemctl suspend -i` under `systemd-run --user` asks for authentication, and plain `systemctl suspend` is refused while keep-awake's block inhibitor is held. | Waking the Frame remotely, [open-questions.md](open-questions.md) |
| **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 |
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). **Seen again 2026-09-28** on BUILD_ID 20260925.6191901, beta client 1790377368. The Frame rebooted by itself while off the network. At 21:13 the check tried `reboot-other` (`bootenv: Permission denied`) and reset the unpacked Steam install, and the tracker had 15 entries. The tracker fix above, applied over SSH, stopped the resets (the checks then log `Permission denied`), but Steam itself kept exiting about 17 s after each start (34 restarts). Cause not found yet. | Diagnosing a boot loop |
| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop |
## Debug recipes
@@ -96,6 +88,5 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md)
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
- What's still unverified: [open-questions.md](open-questions.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

+4 -17
View File
@@ -12,8 +12,7 @@ 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`,
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;
under 1 MB) 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
@@ -26,10 +25,7 @@ as its transport too), so the desktop and phone share one code path. Android
display settings use `podman exec` into each Lepton container instead of adb,
which the Frame doesn't have.
The app server stops after the phone disconnects. An explicitly started
[comfort session](family-comfort.md) keeps its timer and headset reminders running
until the session ends or is cancelled; phone notifications require the app to
remain connected and running. The copied
Nothing is left running on the Frame after the phone disconnects; the copied
files stay in `~/.cache/frame-control` (delete it any time).
## Pairing
@@ -102,9 +98,8 @@ app installed opens its App Store page.
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
Sending text to the Frame's clipboard, or copying the Frame's to the phone
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
panel open in the headset, as on the desktop app.
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
@@ -112,11 +107,3 @@ running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
don't.
## Family and comfort
The shared Home card sets session limits, breaks and check-ins; **Cast to this
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
permission and sends a local test. These are local notifications, not APNs push;
iOS background suspension can interrupt phone alerts. The headset timer still
runs. See [the behavior and verification limits](family-comfort.md).
-222
View File
@@ -1,222 +0,0 @@
# 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.
### Locked repeat, 2026-09-29 (verified)
Acquired `/tmp/frame-test.lock` before installing or launching anything and
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
Reinstalled and launched both original APKs in immersive Lepton instances.
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
unmodified-client test, not the newer installer's automatic compatibility-layer
path. Neither reached a session that could be paired or exercised further.
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
record the failures.
SteamVR's screenshot API returned a 1920×1080 headset capture after each
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
rendering or a working client. The headset was unworn; visibility, controllers,
frame rate and motion-to-photon latency could not be judged. The explicit
OpenXR errors, rather than the dark captures, establish the client blocker.
Both test installs, app data, shader caches, shortcuts, containers and temporary
capture files were removed. The original Steam and SteamVR process IDs were
unchanged. No reboot, power action or global setting change was used. Native
clients remain untested, and no Linux gaming host was available for Valve's
streaming path. The recommendation below is unchanged.
### 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.
-486
View File
@@ -1,486 +0,0 @@
# Mac in the headset
Frame Control can show any Mac window, or a whole Mac screen, as its own panel
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
The confidence labels are the same as in [ssh.md](ssh.md).
## Why this design
First-party options come first, as the repo's rule asks, with the reason
each one was or wasn't chosen. The full list for every device is in
[streaming.md](streaming.md#first-party-options-and-why-they-do-or-dont-fit).
Checked 2026-09-28.
| Goal | First-party option | Chosen? | Why |
|---|---|---|---|
| One Mac screen in the headset | **Apple Screen Sharing** (VNC) → Remmina (Remmina 1.4.43 is already installed on this Frame) | Kept as the fallback (`panel-on-frame.sh mac-screen`) | It's the closest to first-party and needs nothing new. But VNC sends compressed tiles rather than video, so moving content is slow: noticeable lag even on a good 5 GHz link (**verified** 2026-09-27, see [streaming.md](streaming.md)), and the Mac's pointer isn't in the picture without a helper. It shows only whole screens |
| One Mac screen | **Steam Remote Play**, Mac as host (Valve) | For Mac games only; see [Steam's own streaming](#steams-own-streaming) | The Mac's and the Frame's Steam clients already find each other (**verified**). But Remote Play streams a game (the whole desktop only while the game is out of focus, untested from a Mac), never single windows, and a Mac can't host the Frame's VR streaming |
| One Mac screen | **AirPlay** (Apple) | No | Apple licenses AirPlay receivers only to TV and speaker makers, and nothing official runs on Linux. UxPlay is an unofficial receiver, and it mirrors a whole screen, not single windows |
| One Mac screen | **Sidecar / Mac Virtual Display** (Apple) | No | These work only with an iPad or Apple Vision Pro |
| **Each Mac window as its own panel** | None | – | No first-party way does this: Apple's per-app streaming is only for Vision Pro, and Valve's desktop streaming needs a Windows SteamVR host. So Frame Control does it itself |
| Mac keyboard and trackpad driving the headset | **Bluetooth HID** | No | macOS can't act as a Bluetooth keyboard or mouse. A real Bluetooth keyboard paired with the Frame still works |
| Mac keyboard and trackpad | **KDE Connect** (KDE) | No | The Frame has no `kdeconnectd` and it isn't on Flathub. Its Mac app has no keyboard or mouse sharing (**inferred**), and on Wayland it can only reach the desktop panel |
| Mac keyboard and trackpad | **xrdp** (Valve, Developer Mode) | No | It runs a separate Linux session that you view on the Mac. It isn't the headset's view, and it doesn't carry input the other way |
What that leaves is our own stream: nothing to install on the Mac or the
Frame, and whole screens or single windows. Here the Mac's own keyboard and
trackpad need no forwarding, because the windows are still on the Mac. The
laser is the only input that has to be sent back.
Other routes that were compared:
| Option | One screen | Each window | Speed | Verdict |
|---|---|---|---|---|
| Sunshine → Moonlight | ✓ | – | Good | Sunshine's macOS support is still experimental ([discussion #777](https://github.com/orgs/LizardByte/discussions/777)), and it captures whole screens only |
| Virtual Desktop, Immersed | – | – | – | No Frame client as of September 2026 |
| **Frame Control's own stream** | ✓ | ✓ | Hardware H.264, sending only changed frames | **Built** |
To type into VR surfaces other than these panels (SteamVR's dashboard,
games), the Frame supports a uinput keyboard and mouse without sudo
(verified 2026-09-27: `steamos` is in `input`, and `/dev/uinput` is
`root:input 660`). That's a separate feature, not part of this one.
## Steam's own streaming
The Frame is built around Steam streaming, so this was checked first
(2026-09-28). It fits Mac games, not Mac windows.
- **VR streaming from the Mac: no.** The Frame streams VR from a PC running
SteamVR ("Steam Link" with foveated streaming). SteamVR dropped macOS in
2020, and Valve lists PCs, laptops, Steam Deck and Steam Machine as
hosts, never a Mac (**documented**:
[UploadVR](https://www.uploadvr.com/steamvr-drops-mac-support/),
[Road to VR](https://roadtovr.com/steam-frame-game-certification-specs/)).
- **Flat Remote Play from the Mac: probably, for Steam games.**
- Steam on this Mac has streaming on, and the two Steam clients already
see each other. The Frame's `remote_connections.txt` shows it
connecting directly to "Alexs-MacBook-Pro-7" at 192.168.1.211:27036,
and the Mac's shows the Frame connecting over Wi-Fi and over the USB-C
link (**verified** in both clients' logs).
- Whether a stream then starts, and how a flat game looks in the headset
(reviews describe a theater screen), is **not tested yet**. The Frame's
Steam was crash-looping during this session (below).
- Mac-hosted Remote Play has a long-standing report of the stream
closing as the game loads
([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/),
**reported**).
- **The Mac desktop through Steam: untested; single windows: no.** Valve
says Remote Play shows the host's desktop when the game loses focus
([Steam Remote Play FAQ](https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2),
**documented**), so a whole Mac screen may be reachable by starting a
game, then switching away from it. Nobody has tried that from a Mac
host. It would still be one screen in one panel: Remote Play has
nothing like one panel per Mac window, so Frame Control's own stream
stays the way to see separate windows.
- **Steam has a "stream desktop" call, and it pairs with a Mac
(verified 2026-09-28).** In the Remote Play device list, the Frame's
Steam UI calls `SteamClient.RemotePlay.StartDesktopStream(<client id>)`
for a connected device. Called over CDP with the Mac's client ID, it made
the Mac's Steam show "Authorize Device" and ask for a 4-digit code shown
on the Frame. Once the code was entered, the Mac logged
`k_ERemoteDeviceAuthorizationSuccess`. No stream started in that attempt,
and a second attempt, now that the device is authorized, is the next
test. If it streams the Mac's desktop, that's a whole-screen option built
into Steam: one panel, Valve's encoder and transport. It still wouldn't
give each window its own panel.
- **What Steam's work did give us: the USB-C link.** Plugged into the Mac,
the Frame appears as a network port called "Steam Frame". Steam's Remote
Play discovery uses it, and so does Frame Control's stream now (see
"USB-C, when it's plugged in" below).
Next steps, once Steam on the Frame is healthy:
1. Call `StartDesktopStream` again now that the Frame is authorized, and
compare its latency and sharpness with Frame Control's stream of the same
screen.
2. Stream the one Mac game installed here (Fortune Mill) from the Frame's
library, over Wi-Fi and over USB-C.
3. Record whether it starts, how it's shown, and its latency. Steam's
streaming overlay shows this; our benchmark can't measure it.
4. While streaming, switch away from the game on the Mac, and see whether
the Mac's desktop appears in the headset, and whether its keyboard and
pointer work.
5. If it works, Frame Control's Games page could offer "Stream from the
Mac" for Mac-installed games.
## How it works
```
Mac Frame
ScreenCaptureKit (one window or display)
→ VideoToolbox H.264 (hardware, low-latency,
no B-frames)
→ frame-mac-view, 127.0.0.1 ──ssh -R──→ 127.0.0.1:479xx
→ Chromium app window per stream
(WebCodecs decode), on gamescope's
X display, tagged STEAM_GAME
→ its own SteamVR panel
← CGEvent (clicks, drags, wheel, keys) ←──── pointer, wheel and key events
```
- **The agent** is `mac/bin/frame-mac-view`, built from `mac/frame-mac-view`
(Swift, no dependencies; `build.sh`). Frame Control's server starts it on
first use and stops it on quit.
- It captures with ScreenCaptureKit, which sends frames only when something
changes, so idle windows cost nothing.
- It encodes in hardware with VideoToolbox's low-latency rate control (plain
real-time mode where that's unavailable).
- While anyone is watching, it keeps the Mac's display awake. A sleeping
display isn't drawn, so there would be nothing to capture.
- **The link** is an `ssh -R` tunnel on its own connection. It's encrypted and
works anywhere `ssh frame` works, Tailscale included, with no firewall
changes on the Mac. If the headset sleeps or the network drops, Frame
Control reopens the tunnel on the same port, and open viewers reconnect by
themselves.
- **USB-C, when it's plugged in.** Connected to the Mac by cable, the Frame
is also a USB network device: macOS lists a network port called "Steam
Frame", and the Frame's `usb0` answers in under 1 ms. Frame Control
checks for it each time it opens the tunnel and uses it when it's there,
with the Frame's usual SSH host key. Otherwise it uses the normal path.
`FRAME_MACVIEW_USB=0` turns this off. **Verified** 2026-09-28, in two
interleaved pairs of runs:
| | USB-C | Wi-Fi (Tailscale) |
|---|---|---|
| test: content p50 / p95 | 6.9–7.3 / 8.4–8.8 ms | 9.8–10.1 / 12.1–12.3 ms |
| test: click to drawn p50 | 16.6–16.9 ms | 26.4–27.8 ms |
| scroll: content p95 | 23.0–23.5 ms | 31.4–36.9 ms |
| scroll: late frames | 3.2–3.3% | 4.7–7.0% |
(`bench/results/2026-09-28-*-usb1.json`, `-usb2`, `-wifi1`, `-wifi2`.)
- **Access.**
- Frame Control's own key never leaves the Mac.
- Each viewer is opened with a **single-use ticket**. It's tied to one
window or display and expires after a minute. It's spent as soon as the
viewer confirms it has received its reconnect key. Until then, a retry
gets the same key, so a connection lost at that moment doesn't strand
the viewer. Stop revokes tickets that haven't been used yet.
- After that, the viewer holds a reconnect key for that one source, in
memory only. **Stop** revokes it.
- Remaining risk: a program running as `steamos` on the Frame could read a
ticket from Chromium's command line in the first second or so and use it
first. That gets it the one source being opened, not the Mac, and the
real viewer would then fail to connect. Android apps in Lepton run in
their own podman container, so they shouldn't see the Frame's process
list (inferred, not checked).
- **The viewer** is `ui/mac-view.html`, served by the agent. It opens on the
Frame as a Chromium app window, preferring Chromium XR (`~/chromium-xr`,
built with H.264) over Flathub Chromium.
- The page puts `[fcNNNNN]` in its title. The launcher finds the window by
that tag and sets `STEAM_GAME` to a stable id per source, which gives it
its own panel (see [panels.md](panels.md)). The same Mac window gets the
same panel id each time.
- It decodes with WebCodecs. If it falls behind, it skips to the next
keyframe instead of showing old frames late.
- It falls back to JPEG stills (**Compatible** quality) where H.264 isn't
available.
- **Flow control.** The agent never lets frames queue up anywhere on the
way. It skips capture frames *before* encoding, so no reference frame goes
missing, and it lowers the bitrate, then the frame rate, then the size, to
fit the link (see [Adapting to the network](#adapting-to-the-network)).
- **Input.**
- A click on a window's panel brings that Mac window to the front
(Accessibility API), then clicks at the same point. Double clicks, right
clicks, drags and the wheel work too.
- Keys typed into the panel are sent as Mac key codes. Any keys or buttons
still held down are released if the viewer loses focus or disconnects, or
when the stream stops. Characters the key
table doesn't know, such as those from other keyboard layouts, are typed
as text.
- The Mac's own keyboard and trackpad keep working as normal. Click a panel
with the laser, then type on the Mac.
## Permissions (Mac)
- **Screen Recording**, to see windows. Without it, the card asks for it.
- **Accessibility**, so input from the headset reaches the Mac. Without it the
stream still works, and the viewer says clicks won't go through.
Both are granted to Frame Control. After granting, press **Refresh**, which
restarts the helper so it picks them up. The app is ad-hoc signed, so macOS
may ask again after an update.
## Quality settings
| Setting | Long side | fps | Codec | Use |
|---|---|---|---|---|
| Sharp | 2560 | 60 | H.264, ~0.14 bits/pixel | Text-heavy windows on a strong link |
| Balanced (default) | 1920 | 60 | H.264, ~0.1 bits/pixel | Most things |
| Light | 1280 | 30 | H.264 | Weak Wi-Fi or Tailscale off the LAN |
| Compatible | 1280 | 20 | JPEG | A Frame browser without H.264 |
## Measuring
Every frame carries a sequence number, and the agent records its journey on
the Mac's clock (`Sources/Stats.swift`):
| Stage | From → to |
|---|---|
| capture | the Mac composited it (ScreenCaptureKit's display time) → the agent got it |
| queue, encode | → encoding started → the encoder finished |
| network | → the viewer received it |
| decode, draw | → WebCodecs decoded it → it was drawn on the page's canvas |
| present | → the page's next animation frame |
- **Clock sync.** The viewer syncs its clock to the Mac's the way NTP does:
it pings over the stream's own WebSocket and keeps the sample with the
shortest round trip. It then reports, in Mac time, when each frame arrived
(right away, so the agent can pace itself) and when it was decoded and
drawn (in batches every 250 ms).
- **Input.** The first frame captured after a click or key carries that
event's id. So input latency is the viewer's event → injected on the Mac →
the first frame after it → drawn in the headset.
- **Where to see it.**
- `GET /stats` (key required) returns every frame and input record.
- `/status` includes a two-second summary, which Frame Control's card
shows next to each live stream.
- In the headset, add `?stats=1` to the viewer or press
Ctrl+Alt+Shift+S for an overlay.
- **The benchmark.** `scripts/macview-bench.py` runs fixed scenarios on the
real Frame, from the Mac, with nobody wearing the headset:
- **test** is the moving test pattern.
- **scroll** is a Chrome page on its own display scrolling at 240 pt/s,
which gives about 9 Mbit/s of real 1920×1290 video.
- **type** types into a Chrome text box, first fast and then with pauses.
It writes `bench/results/<date>-<commit>-<label>.json`, compares two
results, and runs interleaved A/B tests between agent settings (`ab`).
Wi-Fi changes from minute to minute, so single runs at different times
aren't comparable. Throttled links come from a shaping relay on the Mac,
which needs no sudo (`--net 50@0,3@8,50@16` means 50 Mbit/s, then 3 from
8 s, then 50 from 16 s).
- **What's graded.** "Content" runs from when the Mac composited a frame
(or when ScreenCaptureKit delivered it, if that was earlier) to when it was
drawn in the viewer. The Frame's compositor adds its own delay after that.
That part is reported, but not graded: an unworn Frame throttles panels to
about 36 fps after a few seconds, and to 15 fps in standby, whatever they
draw. This was **verified** with a local canvas page that ran on the Frame
with no network involved (`bench/pages/present.html`). The compositor's
share needs a run with the headset worn.
Targets: content p50 ≤ 25 ms (p95 ≤ 40), click to photon p50 ≤ 50 ms
(p95 ≤ 70), 60 fps with ≤ 1% late frames, no stall over 100 ms, and adapting
to a new link rate within 1 s.
Baseline on 2026-09-28 (**verified**, home Wi-Fi, Tailscale, Balanced,
`bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json`; ms p50/p95):
| Scenario | Content | Input to drawn | fps drawn | Notes |
|---|---|---|---|---|
| test (1280×720) | 9.9 | 28.8 / 39.0 | 59 | encode 4.0, network 4.0, decode 1.3 |
| scroll (1920×1290) | 14.7 | – | 50.4 | encode 6.7, decode 6.4 (software), 9.4 Mbit/s, 16% late |
| type (1920×1290) | 16.7 | 51.2 / 73.2 | – | most of the input time is the Mac app reacting |
After this work, with the controller on (**verified**, same setup,
`bench/results/2026-09-28-9e4dcdd-final.json`; ms p50/p95). Content is
now measured from the earlier of display time and delivery, which adds
about 5 ms to scroll compared with the baseline's way of measuring:
| Scenario | Content | Input to drawn | fps drawn | Grades |
|---|---|---|---|---|
| test | 10.5 / 16.7 | 29.6 / 36.3 | 60 | all within target |
| scroll | 19.8 / 27.4 | – | 55.9 | fps, late frames (4.8%) and worst gap (222 ms) only "acceptable": Wi-Fi stalls (the Mac captured 57 fps in this run; earlier runs got 46–53 from virtual displays) |
| type | 15.0 / 21.4 | 43.0 / 60.5 | – | all within target |
Of the targets, click to photon is met without the Frame's compositor (the
headset has to be worn to measure its share), and so is content latency.
The frame-rate and no-stall targets aren't yet met while scrolling.
**Worn** (**verified** 2026-09-28 22:00, `vrcmd --stats` activity level 1,
home Wi-Fi over Tailscale, `bench/results/2026-09-28-39afb23-worn.json`;
ms p50/p95):
| Scenario | Content | Input to drawn | fps drawn | What happened |
|---|---|---|---|---|
| test | 10.8 / 15.3 | 25.7 / 34.4 | 58 | as unworn |
| scroll | 22.2 / 141 | – | 37.7 | Wi-Fi queued 56–364 ms and held frames for 220–270 ms; the controller went to 2.6 Mbit/s and 45 fps |
| type | 13.5 / 24.3 | 56.8 / 106 | – | slower replies than unworn (43 / 60) |
The Frame's CPU wasn't the limit: about 27% in total, and the viewer took
45% of one core. The link to a headset on someone's head is much rougher
than to one lying still. The adaptation keeps latency bounded there, but
the frame rate drops. The USB-C cable avoids Wi-Fi entirely. Frames
actually shown were still about 39 fps for the test pattern while worn, so
the unworn throttling isn't the whole story. The cause is **unknown**.
What was learned (all **verified**, unless marked):
- The biggest costs are encoding (4–7 ms), network (4–6 ms), and decoding
on the Frame. Chromium XR on the Frame decodes H.264 in **software**.
- Wi-Fi alone stalls for 240–580 ms now and then, over Tailscale and over
the LAN alike. Over the LAN (`--host 192.168.1.237`) latency was no
better, but the Frame used about 8% less CPU, because tailscaled runs in
userspace there.
- With no controller, a link that slows down queues without limit. In one
run, frames arrived 1.9 s late, and at worst 9.4 s late.
- Tried, and no help, so not kept: VideoToolbox options (require hardware,
no frame delay, prioritise speed, a hard data-rate cap), Chromium flags
(`--disable-gpu-vsync`, `--disable-frame-rate-limit`,
`--use-angle=vulkan`), and a 120 Hz virtual display.
- Inconclusive, so off by default: keeping the Frame's Wi-Fi awake during
typing (`FRAME_MAC_VIEW_WARM=40`, a tiny message every 40 ms for 5 s
after input). Over three interleaved runs each, input p50 went 44 → 47 ms
and p95 92 → 71 ms, and the ranges overlapped widely
(`…-ab-keepwarm.json`). In that run, and in the baseline's typing, the
harness typed spaces as "+" (a URL-encoding bug, since fixed), so they
went through the text path rather than as space keys.
- Pointer moves now go out on an 8 ms timer, not on the page's next
animation frame, which an unworn Frame slows to 15–36 Hz. This is
**inferred** to help dragging; the benchmark has no drag scenario yet.
- Kept: the encoder's timestamps never jump more than two frame intervals.
Before this, the first frame after a pause got a quarter of a second's
bit budget, and one P-frame reached 204 KB.
## Adapting to the network
`Sources/Controller.swift`, per stream, latency first:
- **The gate.** The viewer acknowledges every frame as it arrives. A new
frame is sent only while the oldest unacknowledged one is younger than
the path's usual round trip, plus one frame interval, plus room for this
link's normal jitter (1.5 times its recent spread, 25–80 ms). So frames
never queue in SSH, TCP or the Wi-Fi driver. While the link is stuck, the
newest picture waits and goes out as soon as it moves.
- **The bitrate.** The link counts as congested when, for two checks in a
row (100 ms apart), round trips grow by more than 40 ms while the stream
uses much of its budget, or the gate holds frames back, or a frame is
stuck for 100 ms. Then the bitrate drops to a bit under what actually got
through: at least a fifth off, and at most half. Once the link has been
clear for a second, it rises by 10% steps, never above the quality
setting's bitrate.
- **The tier.** When the bitrate stays low, and the stream is really
limited by the link rather than having little to send, it steps down:
60 → 45 → 30 fps, then 75%, then 50% of the pixels. It goes straight to
the tier the bitrate supports after half a second, and steps back up one
tier at a time after two seconds with room to spare.
- `FRAME_MAC_VIEW_ADAPT=0` turns it off, for comparison.
Measured on the real Frame, 2026-09-28 (**verified**; interleaved A/B, off
versus on, medians of the runs, ms):
| Link | Scenario | Content p95, off → on | fps drawn, off → on | Result file |
|---|---|---|---|---|
| Clean Wi-Fi (3 runs each) | scroll | 32.3 → 33.6 | 57 → 56.2 | `…-ab-adapt-clean2.json` |
| Clean Wi-Fi | test | 15 → 14.2 | 60 → 59.7 | same |
| 50 → 3 → 50 Mbit/s at 8 s and 16 s (2 runs each) | scroll | **4670 → 72** | 45 → 42 | `…-ab-adapt-step3.json` |
| 50 → 3 → 50 Mbit/s | test | 66 → 16 | 60 → 60 | same |
- **Clean link.** On a clean link it costs nothing measurable. An earlier
version with a fixed gate slack lost 9 fps to Wi-Fi jitter while
scrolling (47 → 38 fps), and that's why the slack now follows the link's
jitter.
- **Throttled link.** Without the controller, frames queued for up to 5.6 s
and never caught up while the link was slow (p95 1.8–5.6 s, second by
second). With it, in the two runs:
| | Run 1 | Run 2 |
|---|---|---|
| Worst second's p95 just after the drop | 219 ms | 428 ms |
| p95 back under 100 ms for 3 s in a row | after 1 s | after 4 s |
| Stepped down to 1440 px at 30 fps | 1.8 s after the drop | 3.5 s after |
| p95 per second after that, on the 3 Mbit/s link | 55–94 ms | 60–148 ms |
Sending one frame takes about 27 ms on that link by itself. After the
link recovered, the stream was back at full size and 60 fps in about
7.5 s. It steps up one tier every two seconds, on purpose, so it doesn't
bounce. The 1 s adaptation target was met in one run of two.
- **A hiccup on a small stream.** In one clean run, a Wi-Fi hiccup made an
earlier version halve the test pattern's bitrate five times and drop it
to half size. That cut couldn't help: the stream only sends
0.47 Mbit/s. Now the controller estimates what a stream wants (captures
per second × average frame size). While a stream wants about half its
budget or less, no cut takes it below twice what it wants, so it doesn't change
tier.
## Checked so far (2026-09-28)
- **Mac (checked by hand, macOS 26.5.2).**
- The agent builds, and lists windows and displays.
- In a browser, the test pattern decoded at about 60 fps (H.264) and 30 fps
(JPEG, about 22 Mbps).
- A click in the viewer arrived at the same point in the source.
- `pmset -g assertions` showed the display-awake assertion only while a
stream was being watched.
- **Automated** (`tests/test_macview.py`, on CI's macOS runner): the agent
builds (a build failure fails the job).
- `/ping` and the page are open; everything else needs the key.
- Tickets work once and only for their own source, and Stop revokes
reconnect keys.
- A WebSocket frame claiming 2^63 bytes closes that socket, and the agent
keeps running.
- A stream sends an SPS-led H.264 keyframe, and sends another when asked.
- Stop ends the stream on the Mac even if the viewer ignores it.
- The test doesn't decode video, time it, or check where clicks land.
- **Linux aarch64 (verified in a stand-in, not on the Frame).** In an Arch
Linux ARM container with sshd, Xvfb as `:0` and Chromium 153:
- The real `show` path worked in 1–2.3 s: tunnel, ticket, launcher,
window found and tagged `STEAM_GAME`.
- Frame Control's key didn't appear anywhere in the stand-in's process
list.
- After the tunnel was killed, it came back on the same port within 4 s,
and the viewer reconnected by itself.
- Chromium decoded the stream in software with the GPU off.
- Stop closed the window, and Chromium exited.
- This test found and fixed a bug: in a C locale, `xwininfo` can't print a
title with non-ASCII characters, so the launcher reads `_NET_WM_NAME`
with `xprop`.
- **On the Frame (verified 2026-09-28, SteamOS build 20260925.6191901,
from the Mac, nobody wearing the headset).** Frame Control's **Show** with
the test pattern:
- The panel was ready in 1.45 s. SteamVR logged `[Overlays] Created:
valve.steam.desktopgame.2001639889` (in `vrwebhelper_systemui.txt`).
- The window was tagged `STEAM_GAME`, and gamescope sized it to 1920×1080
although 1280×720 was asked for.
- Chromium XR decoded it live at about 60 fps: the frame counter advanced
62 in 1.04 s.
- Over home Wi-Fi, the frames in the viewer window had been drawn on the
Mac about 11–17 ms earlier, plus `xwd`'s own time. That's measured
against the two clocks, which were 37–39 ms apart (±4 ms, measured over
one SSH session). SteamVR's compositor and the display come on top.
- Clicks and keys injected with XTest into gamescope's Xwayland didn't
reach the page. That's inconclusive, not a failure: XTest on gamescope
isn't how real input arrives. The laser should arrive as a left mouse
button and the thumbstick as a wheel, because the window has an app id
(from gamescope's source; see [panels.md](panels.md)).
- **Not yet checked:**
- Clicking, dragging and scrolling with the laser while wearing the
headset.
- Real window capture and input on a Mac with both permissions granted.
- Keys from SteamVR's on-screen keyboard.
- Whether Flathub Chromium has H.264. Chromium XR is used when it's
installed, as it is on this Frame.
- Latency with real, busy windows at Sharp.
- A benchmark run while wearing the headset. Only then does the Frame show
panels at full rate, so only then can the compositor's share of the
latency, and the frame rate you actually see, be measured.
## Limits
- Only windows on the Mac's current desktop (Space) are listed, and
minimised windows can't be captured.
- A window's panel shows only that window. Its menus and sheets are separate
windows on the Mac, so open them from the Mac or use **Whole screen**.
- Keys go to whichever Mac window is in front. Clicking a panel brings its
window to the front first.
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
with the Frame.
## Switching panels and workspace limits
**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers.
Use **Show** to request focus or **Open in headset** for Frame Control's own
switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party
overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher)
include the difference between a panel surviving a scene launch and staying
visible over it.
Saved spatial layouts are blocked on this build: the public OpenVR transform
setter denies access to gamescope-owned panels. Reconnecting an existing viewer
is supported; restoring its room position after a reboot is not. We do not
save short-lived Mac window IDs or viewer access keys as if they were a durable
workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route).
-200
View File
@@ -1,200 +0,0 @@
# Real separation of Mac windows in the headset
Research and experiments on 2026-09-28 (macOS 26.5.2, Apple M5 Pro), for
making each streamed Mac window truly independent. Builds on
[mac-in-headset.md](mac-in-headset.md). Labels: **verified** (tried here),
**documented**, **source** (read in someone's code) or **reported**.
## What "separate" lacks today
Today each window is captured on its own
(`SCContentFilter(desktopIndependentWindow:)`), but it still lives on the
Mac's one desktop:
- **Clicks.** A click has to raise the window first, so it reorders the
Mac's windows, steals focus and moves the real cursor.
- **Child windows.** A window's menus, popovers, sheets and tooltips are
separate windows, so they aren't in its panel. Apple documents that the
single-window filter leaves them out.
- **Size.** A panel's size is tied to the window's size on the Mac screen.
- **Hidden windows.** Minimised windows, and windows on other Spaces, can't
be shown live.
## How the Mac draws, and where pixels can be read
Apps draw with Core Animation into IOSurfaces. WindowServer's compositor
stacks every window onto each display, including virtual ones, and sends
the result to the screen. Pixels can be read at three points:
| Where | How | Gets | Doesn't get |
|---|---|---|---|
| One window's content | ScreenCaptureKit `desktopIndependentWindow` (public, what we use) | Live, zero-copy, even when covered by other windows (**documented**) | Menus, popovers, sheets. It pauses while the window is minimised (**reported**) |
| One window plus its children | `SCStreamConfiguration.includeChildWindows` (macOS 14.2+, **reported**) | Menus, popovers and sheets attached to the window | Anything the app puts outside the window's bounds |
| A whole display | ScreenCaptureKit display filter, with apps or windows included or excluded | Everything on that display, cursor included | Only what's on that display |
| A snapshot of any window | Private `CGSHWCaptureWindowList`, which AltTab uses for minimised windows and other Spaces (**source**: `alt-tab-macos` `PrivateApis.swift`) | Minimised windows and other Spaces | It's one still image, not a live stream |
| Another app's layer tree | Private `CALayerHost` with a context id | A live, zero-copy picture | It only works when the other app cooperates, so it's no good for arbitrary windows (**reported**) |
`CGWindowListCreateImage` and `CGDisplayStream` are obsolete in the macOS 15
SDK (**reported** by MacPorts and JUCE). Nothing reads another app's pixels
without the Screen Recording permission.
## The idea: each window gets its own virtual display
A virtual display (the private `CGVirtualDisplay`, as used by BetterDisplay,
DeskPad and quest-display) is a compositor target with no physical screen.
Put one streamed window alone on its own virtual display, sized to its
panel, and capture the whole display:
- **Nothing can overlap it.** A plain click at that point always lands on
that window, so input doesn't need raising or background-event tricks.
- **Menus, sheets, popovers, tooltips and context menus appear on the same
display, so they're in the panel.** With "Displays have separate Spaces"
on (it is on this Mac), each display also has its own menu bar, so the
app's menu bar can be part of the panel.
- **The panel's size is the display's size,** in HiDPI. Resizing the panel
means changing the display mode and resizing the window to fill it
(Accessibility API).
- **Minimised windows and other Spaces stop being a problem,** because
streamed windows live on their own displays.
- **The cursor goes where the laser points.** That display's panel is the
one being used, much as Vision Pro's Mac Virtual Display works. Keys from
the Mac's keyboard go to the window last clicked.
### Verified here (no permission needed)
- **Creating one.** It needs no permission or entitlement. 1920×1080 HiDPI
gives a 3840×2160-pixel, 60 Hz display, placed next to the built-in
screen, and `NSScreen.screensHaveSeparateSpaces` was true.
- **Many at once.** 16 were created at once with no error.
- **Placement.** `CGConfigureDisplayOrigin` far away failed with error
1014: macOS keeps displays edge to edge. Disabling one with the private
`CGSConfigureDisplayEnabled` from a command-line tool also failed with 1014.
- **Removal is deferred while the physical screen sleeps.** With the
built-in display asleep, virtual displays were not removed when released,
or when their process exited, even from their own `.app`. A second display
with the same vendor, product and serial couldn't be created. All of them
disappeared as soon as the screen woke (`caffeinate -u`). The helper must
therefore:
- keep a fixed pool of displays and reuse them rather than create new ones;
- keep the screen awake while they exist, which it already does while
streaming.
### Built: "Give each window its own display"
Frame Control's helper now does this (`mac/frame-mac-view/Sources/Separate.swift`),
and it's the default in the Tools card. Streams of this kind are named
`separate:<window id>`.
- For each window shown, it creates a HiDPI virtual display. The display is
sized to the window plus a menu bar, with a fresh serial each time, so a
display left over while the screen slept can't block a new one.
- The window is moved onto the display and resized to fill it (Accessibility
API), and the whole display is captured.
- On **Stop** the window goes back where it was, and the display is released.
- Plain per-window capture is still there: untick "Give each window its own
display". Separate mode needs Accessibility (to move the window), and
without it, Show says so rather than quietly falling back.
Verified 2026-09-28 (macOS 26.5.2, M5 Pro), using a TextEdit test document
and the benchmark's Chrome windows:
- **Separation works.** The window moved onto its own display and streamed,
with its first frames in 3.8 s. The display showed TextEdit's own menu bar.
- **Child windows come along.** A context menu and the Page Setup sheet
opened on the same display and were in the capture.
- **Input.** Typing through the stream reached the window. The benchmark's
typing scenario does this on every run.
- **Stop.** Stop put the window back at exactly its old size (656×422), and
the display was removed.
- **The helper must run a real Cocoa event loop.** Earlier, it ran only a
`RunLoop`. With that, AppKit never learned about new displays:
- `NSScreen.screens` never listed them, so placing the window always waited
3 s and then gave up;
- the display's HiDPI mode never applied, so windows were captured at 1×
(1280×860 instead of 1920×1290 pixels) and text was soft.
Running `NSApplication` (with no Dock icon) fixed both. The screen now
appears within 100 ms, and captures are at 2× (**verified** in
`bench/results/*-baseline.json` against `*-baseline-fixed.json`).
- **Frame rate.** Chrome drew at 60–61 fps on the virtual display, but
ScreenCaptureKit delivered only about 46–53 fps from it, whereas the
built-in ProMotion screen gave 121 fps (**verified**). Neither a 120 Hz
virtual display (`FRAME_MAC_VIEW_VD_HZ=120`) nor a looser
`minimumFrameInterval` changed that. Cause unknown.
- **Windows that keep their own size** (Calculator, 230×408). The window
moved and streamed, but stayed small in the display's corner, so the panel
was mostly wallpaper. Now only that corner is captured
(`SCStreamConfiguration.sourceRect`): the menu bar and the window, at least
480×360 points so menus fit. Clicks map to the cropped area. The first
frame arrived in 0.6–1.1 s, and on Stop the window went back and the
display was removed. A display's menu bar names the active app, so it
shows the window's own menus only once the panel has been clicked.
- **Several windows at once** (on the Mac, with a local stand-in viewer that
acknowledges frames). Three and four Chrome windows, each scrolling on its
own display at 1920×1290 pixels, streamed at 53–56 fps and about
9 Mbit/s each. The helper used 20% (three) or 24% (four) of one CPU core.
The hardware encoder is shared: its time per frame went from 6.7 ms for
one stream to 7–15 ms for three and 11–25 ms for four, so each extra
moving window adds latency to the others. Once in four runs, before a fix,
one window left its display when several displays appeared at the same
moment, and its stream stopped: nothing on that display was changing any
more. The helper now checks every second and puts the window back. Three
further runs with four windows were clean, and every display was gone
afterwards (checked with `CGGetOnlineDisplayList`).
- **Quitting.** On SIGTERM, or when Frame Control goes away, the helper ends
every stream first, so windows go back before their displays disappear.
Verified: a 900×600 window was back at 900×600 after SIGTERM. Closing a
viewer 0.05–1.5 s into startup left the window at its old size and no
extra display.
- **A consent prompt.** macOS 26 asks whether to let ScreenCaptureKit apps
"bypass the system private window picker". The prompt appeared *on the
virtual display*, so it would show up inside the headset panel. Allow it
once on the Mac.
### Still to check
1. That a context menu opens over the text being clicked, not just somewhere
on the display. This needs a retest after the consent prompt above is
allowed.
2. Stage Manager, which is reported to undo Accessibility resizes.
3. Where the Dock and the cursor go on a virtual display.
4. Closing the lid with virtual displays attached (clamshell).
5. Many moving windows at once in the headset: whether to lower the bitrate
or frame rate of panels you aren't using, so the one you are using keeps
the encoder to itself.
6. All of it in the headset, with the laser.
## Input without disturbing the Mac (a finer option)
For windows that stay on the Mac's own screen, input can go to a window
behind others without raising it:
- **Background click.** `CGEventPostToPid` with the CGEvent fields 91 and
92, which say which window a click is for (`kCGMouseEventWindowUnderMousePointer`
and `...ThatCanHandleThisEvent`), plus a window-relative location
(**reported**, reverse-engineered, needs testing).
- **Focus without raise.** yabai makes a window key without raising it by
posting event records with the private `SLPSPostEventRecordTo` (**source**:
`yabai/src/window_manager.c`).
- **Known failures.**
- Chromium and Electron ignore background clicks unless they're built
with the 2026 `acceptsFirstMouse` fix (electron/electron#54493).
- Games and canvas apps often need real activation.
- Password fields (Secure Event Input) drop synthetic keys.
- IME composition, as for Chinese or Japanese input, isn't reliable.
With a virtual display per window, most of this isn't needed. It's worth
having for hovering over one panel while typing in another.
## Encoding and transport
- **Codec.** Keep H.264 4:2:0. Apple's High Performance Screen Sharing uses
4:4:4 over two virtual displays (**documented**), but the Frame's Chromium
decodes H.264 in software, and no 4:4:4 decode path is confirmed there.
- **Sharper static text.** When a panel has been still for a moment, send a
high-quality refresh, either a keyframe at low quantisation or a lossless
WebP overlay, so static text is sharp. Moving content stays as video.
- **Transport.** Keep WebSocket over SSH for now, as it works everywhere.
WebRTC (UDP, congestion control) is the proven step up for Wi-Fi.
WebTransport over QUIC is promising, but its server side on macOS is
unverified.
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff. Show more