Verified on the headset: hello_xr and Open Brush's Quest build run
immersively unmodified; Wolvic's Quest build needs a LAUNCHER category
and fails on SteamVR's Android runtime being OpenXR 1.0 only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One malformed or unreachable repo no longer hides the others; skip bad
index entries; a refreshed raw index outdates its reduced copy; style the
dialog like the others; validate package ids with PKG_RE.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Rate limiting fails open on KV errors instead of dropping feedback
- Break owner/repo#1, GH-1 and github.com references in user text
- Time the form with the browser's monotonic clock, not wall-clock
- Serialize lgtm approvals and rebase before pushing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reducing an index no longer deletes apk-catalog/data/index-v2.json, which
the catalogue build reads. Drop the measurement log from docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages
(frame-control.pages.dev). POST /api/feedback validates the form and opens
a labelled issue with a fine-grained token; honeypot, minimum fill time and
KV rate limits keep spam out. Ko-fi donate buttons appear once the page
name is set in site/public/js/site.js.
- .github: the issue and PR gate from badlogic/pi-mono. New contributors'
issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves
them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md.
- CI runs the website tests; README points feedback at the form.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frame Control can now show any Mac window, or a whole screen, as its own
SteamVR panel on the Steam Frame (Tools -> Mac in the headset, macOS only).
Place it with the SteamVR dashboard; the laser clicks and scrolls, and the
Mac's own keyboard types.
- mac/frame-mac-view (Swift, no dependencies): ScreenCaptureKit capture per
window or display, VideoToolbox H.264 with low-latency rate control (JPEG
fallback), a loopback HTTP/WebSocket server, CGEvent/AX input playback,
and a display-awake assertion while anyone watches.
- ui/frame_macview.py: starts the agent, runs an ssh -R tunnel with a
supervisor that reopens it on the same port, and launches a Chromium app
window per stream on gamescope's :0, tagged with STEAM_GAME for its own panel.
- Frame Control's key never leaves the Mac: viewers get single-use,
per-source tickets and reconnect keys that Stop revokes.
- ui/mac-view.html: WebCodecs decode, keyframe recovery, pointer/wheel/keys back.
- Bundled in the Mac app build; tests/test_macview.py builds and drives the
agent on macOS.
Verified on the Frame (build 20260925.6191901) with the test pattern: panel
in about 1.5 s, about 60 fps, Mac-to-window about 11-17 ms, tunnel recovery
in 4 s. Laser input and real window capture still need a person in the headset.
Also commits the other thread's first-party rule (steam-frame skill) and
the first-party options table in docs/streaming.md.
Reviewed by GPT-6 Astra (xhigh, read-only) over six rounds; all findings fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
macOS leaves the pointer out of the Screen Sharing framebuffer, and neither Remmina showcursor setting brings it back. A Hammerspoon ring around the pointer is a real window, so it gets mirrored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without scale-to-fit a Retina Mac shows 1:1 as a zoomed-in corner. macOS offers Apple auth ahead of plain VNC auth, so Remmina asks for the Mac account login.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A thumbnail under the screenshot opens the 66-second trailer, which is attached to the 'trailer' release.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Header, content, tab bar, status strip, drawer and toasts add the notch,
Dynamic Island, rounded-corner and home-indicator insets on every side
(zero on desktops). Phones on their side use the bottom tab bar layout.
- The phone tab bar hides while a text field has focus, instead of riding
on the keyboard.
- DEBUG hook FRAME_TEST_LANDSCAPE for checking this in the Simulator.
- docs/streaming.md: iPhone mirroring (UxPlay, broadcast extension) and
keyboard/mouse input (uinput needs no sudo on the Frame, verified).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The share sheet only offers Save Image when the app declares
NSPhotoLibraryAddUsageDescription; without it screenshots couldn't be saved to
Photos. docs/iphone.md now lists what was verified against the Frame (Bonjour
discovery, waiting and reconnecting, upload, share sheet, install links,
opening Steam Link) and the two permission prompts iOS shows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The menci/archlinuxarm base failed `pacman -Syu` on GitHub's arm64 runner.
Valve's Holo Core aarch64 preview is the base the Frame's SteamOS is built on,
the iPhone app's frame-container already uses it, and its repos carry every
package the fake needs. A failed image build now reruns with the full log.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The app finds the Frame by itself (Valve's _steamos-devkit._tcp Bonjour
service, else the saved address or frame.local on port 22), so setup is two
numbered steps: wake your Frame (Looking… / Found ✓, and after a few seconds
exactly what to check), then the Developer Mode password and Connect. Manual
address and key options sit under Other ways to connect.
A paired Frame that doesn't answer is almost always asleep: instead of an
error, Waiting for your Frame says how to wake it and connects as soon as its
SSH port answers (checked every 3 s; 4 s after the stand-in came back).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tabs UI (#4) runs Flatpak installs as server.start_job jobs, so a failed
install answers 200 with a job id and reports the error on /api/job. The test
now waits for the job instead of expecting an immediate 502.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the Frame, compat_log.txt has binary bytes in it, so plain grep only said
"binary file matches" and the x86-64 launch check missed Steam's "is not
installed" line. The fake now writes that line to a compat_log.txt that starts
with a NUL, and the end-to-end test finds it the way the smoke test does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A launch that needs the program running kept looking after Steam's
"started" line, rather than failing on it before the process showed up.
Cleanup reads steamos-delete's log, which reports a failed sync but exits
0, and runs it twice to check Steam no longer lists our titles; until then
they stay tracked and the cleanup step fails.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Headset smoke test: drop the paired key from authorized_keys with a
same-mode copy swapped in, so a failed write can't truncate it; check the
throwaway key with no ssh_config or agent; clean up idempotently (tracked
and leftover titles, their json files, Steam's shortcuts via steamos-delete,
and ~/devkit-utils if it wasn't there before), with a failed cleanup a
failed step; count a launch only with fresh evidence (the process, Steam's
log, or the known missing-runtime line), matching with [d]evkit-game so
pgrep doesn't find its own shell. The test programs sleep 10 s.
Fake Frame: log a launch before its reaper can look for it. e2e: kill a
pairing client's process group when a test ends; accept an aarch64 program
running under QEMU on x86 hosts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tests/fakeframe: a container that stands in for the Frame (Arch Linux, or
Arch Linux ARM on arm64) with sshd, rsync, Valve's steamos-devkit-service
and hooks (vendored unmodified), a fake Steam client for the devkit pipe and
the DevTools port (the app's JavaScript runs in Node against stand-in
SteamClient/appStore objects), stubs for steam, wpctl, flatpak, podman,
Lepton and friends, battery and thermal files under /sys, and fault
switches (fakeframe-ctl): pairing mode, approve/deny/timeout, Steam not
running, headset asleep, sshd off, disk full, runtimes missing. A second
container is the computer running Frame Control.
tests/e2e: 29 tests driving the real ui/server.py, frame_connect.py and
frame_titles.py against it; skipped unless FRAME_E2E=1. scripts/e2e.sh
builds, runs and tears down; CI runs it on ubuntu-24.04-arm.
tests/smoke + scripts/frame-smoke.sh: the core cases against a real Frame,
recorded with its BUILD_ID, cleaning up after itself; --pair to pair a
throwaway key. docs/testing.md describes the layers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the Frame, Steam refused to register titles whose id had a hyphen
(fc-smoke-exe) with "missing/invalid arguments", and registered the same
program as FCSmokeProbe (headset smoke test, 2026-09-27, BUILD_ID
20260922.6101926). Valve's client only allows ^[A-Za-z_][A-Za-z0-9_.]+$.
title_id now makes ids of letters, digits and _, not starting with a
digit, 2 to 64 long; new installs are checked against that, while titles
already on the Frame are still listed, launched and removed. Steam's error
text is trimmed before it's quoted, and the "install it again with Steam
running" hint only follows a Steam-not-running error.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/recovery-and-images.md: where Valve's Frame images are (not linked from
the SteamOS download page), file names, sizes and our checksums, the GPT
layout with exact start sectors, what's in rootfs-A (btrfs, SteamOS 0.3.0
build 20260922.5152327, users, sudo and sshd config), extracting it, running
it without the headset, and Valve/Collabora's Holo Core aarch64 preview.
- how-the-frame-works.md: correct the recovery image file names; add verified
facts on the SSH server, tools on the image (no adb), Lepton instances as
podman containers, going off the network when asleep, and the battery
reading at full charge.
- ssh.md: pairing from an iPhone and why devkit RSA pairing doesn't fit it.
- open-questions.md, README.md and the steam-frame skill point to the new pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tests/frame-container/frame-image.sh extracts rootfs-A from Valve's Frame
recovery image (steamdeck-images.steamos.cloud/recovery), mounts it read-only
with a throwaway writable layer and starts the image's own sshd, so the iPhone
app can pair with and run its server on the real SteamOS for Frame userland.
Verified: password pairing then key login in the image's sshd log, the power
password check through the image's sudo, status reading SteamOS 0.3.0.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Valve publishes no Steam Frame OS image, so tests/frame-container builds the
Frame's SSH surface on Valve and Collabora's Holo Core aarch64 base: a
steamos user with a password and sudo, OpenSSH with keys and passwords,
Python, and a systemctl that only records requests.
Against it from the Simulator: password pairing, the host-key pin, the power
password check. Found and fixed: a changed host key or a refused login said
"Can't reach the Frame" and retried forever; they now say "Pair with the
Frame again" and offer that. The server's key rejection now says the header
may be wrong, not only missing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds debug-only test hooks (open on a tab, run page JS, leave the tunnel URL in
Caches) used to drive the app in the Simulator, and records what was verified:
all four tabs with live data, capture and 31 fps live video in WKWebView, upload,
install jobs and the power password check through the app's tunnel.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Pairing saves nothing if it was cancelled while adding the key.
- The ssh stand-in runs under bash: dash refuses job control without a
terminal, which cost stdin and the process group.
- A server that stops while the tunnel opens fails the attempt (and retries)
instead of leaving it half-connected.
- The health probe answers within its deadline even when a dead link keeps
the probe itself waiting.
- A cached version is deleted only if no server runs from it (servers now run
by absolute path) and it's two weeks unused.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Cancelling (Change headset, Forget) invalidates the attempt in flight; a
connection only becomes the app's once every step finished for it.
- A server that stops while the tunnel opens is noticed (exit callbacks are
synchronised and replayed), and the app isn't left showing a dead page.
- The port is read only once the digits are complete, and range-checked.
- Other versions in ~/.cache/frame-control stay unless untouched for 14 days,
so a second phone or iPad isn't cut off.
- The key is appended on its own line even if authorized_keys lacks a final
newline.
- The app checks every 20 s, and on returning to the foreground, that the SSH
session still answers, and reconnects if not.
- The ssh stand-in runs the command as its own process group and passes a
TERM on to all of it, so a live-video ffmpeg stops with its stream.
- Touch screens show the library's Play buttons (the rule now follows the
base one); the failure screen only says it's retrying when it is; the page
says the app reconnects rather than naming a desktop menu.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.
Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.
Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.
App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.
When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.
Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.
Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".
Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The build script, launcher and Steam shortcut helper now live in
saphid/chromium-webxr-steam-frame, so drop the duplicate copies here.
The doc keeps the findings, verification and upstream status.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Waking a recv blocked in another thread needs the handle closed on
Windows, which isn't safe under a TLS read (the previous commit, reverted
after review). A stalled download there holds the quit for the 4 s grace
period; its partial file is swept on the next start.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Once the SO_PEERCRED patch is applied, gclient sync refuses the modified
checkout, so a re-run failed. Sync once per revision instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
shutdown() from another thread doesn't wake a blocked recv on Windows, so
quitting mid-download waited out the 4 s deadline there (CI's Windows
server tests). Close the handle as well, detached first so the worker's own
close can't hit a reused handle.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>