Review round 12: a failed USB-C tunnel now retries the normal path; an
existing HostKeyAlias wins; --host with --usb is rejected; the Steam
desktop-streaming claim is now 'untested' (Valve documents the desktop
showing when a game loses focus); the Show/cleanup overlap test blocks
for real (it fails without the lock). Verified live: with the cable out,
the route is the normal path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Plugged into the Mac, the Frame is a USB network device ("Steam Frame",
usb0 at ~0.9 ms). The tunnel uses it when the Frame's usb0 answers,
with the usual host key; otherwise the normal path. Interleaved runs:
content p50 7 vs 10 ms, click to drawn 17 vs 27 ms, scroll p95 23 vs
31-37 ms. FRAME_MACVIEW_USB=0 turns it off; the card says "over USB-C".
- Bench: --usb, and the route is recorded per run.
- Docs: Steam's own streaming (no SteamVR host on macOS; Remote Play pairs
but streams games, not windows; test blocked); the Frame's USB network;
the 2026-09-28 health-check boot-loop recurrence.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 11: the cleanup's check and pkill now hold a lock that Show
takes to count itself in, so a new viewer can't start between them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 10: a Show replacing its own stream could have its new viewer
ended by the cleanup; a viewer reset left the delayed relay pending.
Verified: the relay delivers what's queued, then closes the agent side.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Chromium on the Frame outlived its last viewer window (verified: 11
processes left after a run). Once nothing is shown, Stop ends it, unless
Show was pressed again meanwhile; its profile is Frame Control's own.
- Relay --delay: if the agent side fails, close the viewer side too
(review round 9).
- Docs: the final scroll run captured 57 fps; don't blame ScreenCaptureKit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in #4, #5, #8 (fbl100's verified Remmina/VNC mirror), APK
alternatives and the website. docs/streaming.md: Mac in the headset stays
the recommendation (now verified on the Frame); Remmina keeps #8's verified
evidence as the whole-screen fallback. docs/mac-in-headset.md cites #8's
lag finding.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Per-frame timing on the Mac's clock (capture, encode, network, decode,
draw), viewer clock sync and reports, input echo, /stats and a HUD.
- scripts/macview-bench.py: repeatable runs on the real Frame, a shaping
relay (no sudo), interleaved A/B between agent settings; results in
bench/results/.
- Adaptive controller: ack-based send gate with jitter-aware slack, AIMD
bitrate that knows when a stream is app-limited, fps then size tiers.
On a 50->3->50 Mbit/s step, scroll p95 went from 4.7 s to 72 ms; no cost
on a clean link.
- Separate mode: real AppKit event loop (HiDPI and NSScreen now work),
cropped capture for fixed-size windows, windows kept on their display,
graceful quit restores windows; stop/start races fixed.
- Encoder timeline clamp (no oversized frame after a pause).
- Frame Control shows each live stream's fps, delay, bitrate and tier.
Reviewed by GPT-6 Astra xhigh (read-only), 7 rounds; findings fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a maintainer's Mac the compatibility-database key is in the Keychain, so a
test that reached install reporting published fake reports. Every test module
now imports tests/sandbox.py first, which points app data at a throwaway
directory (new FRAME_CONTROL_DATA_DIR), turns telemetry off and sends the
database nowhere.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Analytics go to the maintainer's PostHog US project 343535, tagged
$lib = frame-control. Every event carries $ip 0.0.0.0, since PostHog
stores the sender's address otherwise (checked live), including events
queued by earlier versions.
- Report a problem sends a private problem_report event to PostHog instead
of a public GitHub issue, with its own random id so a contact address
can't be linked to analytics. The dialog asks how to reach the person and
shows a reference. Maintainers read reports on the PostHog dashboard or
with `python3 ui/frame_report.py inbox`.
- Community sync pages by timestamp in UTC: PostHog refuses OFFSET for
personal API keys.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Flatpak installs record their outcome inside main's background job; failed
jobs are diagnostics too. The Privacy panel lives on the Tools page (#privacy
opens it), tab analytics use the four page names, and "Test it now?" reads the
install job's result.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Anonymous PostHog analytics (ui/frame_telemetry.py): usage on by default
after a first-run notice; compatibility results and error details opt-in,
offered together by the notice's "Share more to help fix problems" button.
Random id, no person profiles or GeoIP, scrubbed text, an offline outbox,
and "Show what's been sent" in the new Privacy panel. Inert without a
project key, from a source checkout, or with DO_NOT_TRACK=1.
- APK installs now record install_failed when the APK itself won't install,
and offer a 20-second test after installing. Opted-in reports reach the
shared database through PostHog and `frame_compat_db.py sync`.
- The desktop app updates itself from published releases (app/updater.js):
update.json from releases/latest/download, SHA-256 checked, no downgrades;
macOS bundle swap, Windows NSIS, Linux AppImage, otherwise the release page.
scripts/publish-release.sh publishes a tested draft with its manifest.
- Report a problem (header button, Privacy panel, Help menu) files a GitHub
issue through the website's feedback API, with a previewed, scrubbed
diagnostics snapshot; activity and logs only when asked for.
Reviewed by GPT-6 Astra (xhigh, read-only) three times; all findings fixed.
Docs: docs/privacy.md, docs/releasing.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main now runs Android installs as background jobs and maps SSH failures to
one offline message. Alternative-version installs go through the same job,
the alternatives dialog waits on it with runJob, and send_error_json keeps
the apk blocker that opens the dialog.
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>
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>
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>
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 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>
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>
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>
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>
- Only stored and deflated entries are read: Python 3.9's bzip2 and lzma
readers inflate without bound before trimming.
- loadTitles drops a response that a newer request has overtaken, so the
install dialog's replace warning uses the fresh list.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- APK reader: read members with a bounded read, since ZipFile.read inflates
a member fully before trimming it to a forged declared size; the reference
walk counts every entry it examines, dead ends and cycles included.
- Titles: a path that exists under the root wins over stripping the archive
prefix; the prefix is taken before a linked folder is staged elsewhere
(another drive on Windows); the install dialog loads a fresh title list
first and says so if it couldn't check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- APK reader: cap AndroidManifest.xml, resources.arsc and icon sizes before
inflating them (APKs can come from install links), and follow resource
references without cycles and with a result budget.
- Sideloading: reserve devkit-steam (SteamOS's sideloaded-client trampoline);
the install dialog warns when a name replaces an installed title; a
manifest's exe may name the program as it is in the archive, above the
folder the installer steps into.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tested on a Frame (BUILD_ID 20260922.6101926):
- Devkit pairing: the service runs and answers properties.json, but /register
refuses with "please put the Steam client in pairing mode" unless Steam is on
Settings > Developer > Pair new host. Both setup paths now say so and keep
asking for 2 minutes before falling back to the password.
- Sideloading: registering, launching, quoted start paths and Remove work; a
Windows exe runs under Proton 11 through FEX. An aarch64 build starts but
outside the runtime container, and an x86-64 Linux build doesn't start
because the x86-64 Steam Linux Runtime 4.0 isn't installed. The inspect note
for x86-64 Linux builds now says so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Links to localhost need FRAME_CONTROL_LOCAL_LINKS=1: otherwise any website's
link could make the app fetch from services on this computer.
- Title staging folders (unzipped titles) carry the server's PID and are swept
on the next start like download folders, so quitting mid-install doesn't leave
gigabytes behind.
- An install from a link refreshes Sideloaded titles.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>