Files
Alex SouthwellandClaude Opus 5.5 e702adbd77 Live view: a Desktop view that stays still, and Control to tap on the Frame (#46)
* Live view: a Desktop view that stays still, and Control to tap on the Frame

The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from review

- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: close the targeting gaps from the second review

- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from the SWE-2 Max review

- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: stale clears, pauses reconverge, pastes split per request

- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: build the socket path on the Frame, so Windows can import it for tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: any event that goes through clears the stale flag

A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:13:21 +10:00

26 KiB
Raw Permalink Blame History

How the Frame is put together (field notes)

What we learnt by poking at a real Frame over SSH. Unless a line says otherwise, it was verified 2026-09-25 on SteamOS 0.3.0 (VARIANT_ID=vr, build 20260922.6101926, kernel 6.18, aarch64). Topic docs go deeper. This page is the map.

The layer cake

SteamVR  (vrserver, vrcompositor, vrdashboard)       ← renders the room + panels
 └─ gamescope --backend openvr                       ← one SteamVR overlay per app id
     ├─ Xwayland :0  (Steam UI, games, tagged apps)  ← STEAM_GAME property = app id
     ├─ Xwayland :1  (STEAM_GAME_DISPLAY_0)
     ├─ Wayland socket gamescope-0
     └─ steamos-nested-desktop                       ← "the Linux desktop" panel
         └─ dbus-run-session startplasma-wayland
             └─ kwin_wayland 1280×800, Wayland wayland-0, Xwayland :2
                 └─ plasmashell, Konsole, Dolphin, Flatpaks you open there
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000

Facts worth knowing

Fact Where it matters
The desktop is a nested Plasma session: runtime dir /run/user/1000/nested_plasma, its own D-Bus bus, WAYLAND_DISPLAY=wayland-0, DISPLAY=:2. A plain ssh frame app can't find it. Copy the env from plasmashell's /proc/<pid>/environ. run-on-frame.sh, paste-to-frame.sh
The desktop size is hard-coded to 1280×800 in /usr/bin/steamos-nested-desktop (read-only rootfs). panels.md
gamescope runs with --virtual-connector-strategy PerAppId. Each app id becomes a SteamVR overlay valve.steam.desktopgame.<id>, which is a panel you can float. Setting STEAM_GAME on an X11 window on :0 makes a new panel. panel-on-frame.sh, panels.md
Handy gamescope root properties on :0: GAMESCOPE_FOCUSABLE_APPS, GAMESCOPE_FOCUSABLE_WINDOWS (triples: window, app id, pid), GAMESCOPE_FOCUSED_APP. Read them with DISPLAY=:0 xprop -root. Debugging panels
gamescopectl screenshot <file> (with WAYLAND_DISPLAY=gamescope-0) captures gamescope's flat layer. Frame Control's capture
The headset view (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo). It's callable from python3 with ctypes against /opt/steamvr/bin/linuxarm64/libopenvr_api.so as an overlay app. The compositor appends .png, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. vrcmd --screenshot and vrcmd --compositorcmd screenshot_request wrote nothing, even with steamvr/rawCapturePath set. ui/frame_vrshot.py
SteamVR's steamvr-v4l2cam.service (/opt/steamvr/bin/linuxarm64/v4l2cam --output=99) copies the headset view (the system.HeadsetView mirror, one undistorted image) into the v4l2loopback device /dev/video99 ("SteamVR"), 1920×1080 RGB24. ffmpeg -f v4l2 -i /dev/video99 reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (iris_encoder, /dev/video-enc0) crashes ffmpeg's h264_v4l2m2m, so encode with libx264 -preset ultrafast -tune zerolatency: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire gamescope video source, but the Frame's GStreamer has no pipewiresrc. Verified 2026-09-26. Frame Control's live video (/api/stream)
Battery: /sys/class/power_supply/max1720x_bat_7-36 gives µV/µA (current is positive while charging), time_to_full_now/time_to_empty_now in seconds, and temp in tenths of °C. The charger shows up as tcpm-source-psy-… (type=USB, usb_type=C PD [PD_PPS]), for example 12 V × 1.67 A. Frame Control's battery card
vrcmd --stats reports activity_level (3 = standby). Telling whether the headset is being worn
Testing VR apps without wearing the headset. In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. vrcmd (in /opt/steamvr/bin/linuxarm64) settings use section.key: vrcmd --set-settings-bool power.pauseCompositorOnStandby 0 and vrcmd --set-settings-float power.turnOffScreensTimeout 3600, then vrcmd --handlewakeup, keep the compositor running, and the scene app becomes visible. If it stays visible-blurred, the Steam dashboard is open: SteamClient.OpenVR.VROverlay.HideDashboard() in Steam's SharedJSContext (CDP on 8080) closes it. The headset view then captures with ui/frame_vrshot.py. Restore afterwards with --set-settings-bool power.pauseCompositorOnStandby 1 and --set-settings-float power.turnOffScreensTimeout 5. The bool setter reads true as false, so use 1/0. A Steam launch that stalls in standby at ShowInterstitials or CreatingProcess (see console_log.txt) continues with SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>"). Verified 2026-09-27. Proving VR output remotely, webxr-chromium.md
The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. Inferred from /opt/steamvr/resources/webinterface/dashboard/ and not yet driven by hand. panels.md
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
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
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
Lepton listens for ADB on the Frame's loopback 5555, so tunnel it over SSH. It's Android 11 (API 30), 64-bit ARM only, with no clipboard service: Compose < 1.11, SDL/Kivy and Godot 4.3 apps crash on launch. apks.md, apk-catalog/
Lepton Development deletes every ADB-installed app when it exits (clear_baked_app_data "non steamlaunch container" in …/common/Lepton/lepton) unless LEPTON_NO_CLEANUP is set. apks.md
Any APK can run as its own Lepton instance: run …/common/Lepton/lepton waitforexitandrun -- app.apk with SteamAppId set and STEAM_COMPAT_DATA_PATH under ~/.local/share/Steam. Data persists and each gets its own container and panel. frame/android/lepton-app.sh, ui/frame_android.py. apks.md
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, 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 wires the provider on Linux (with kOpenXR still off by default, so it needs --enable-features=OpenXR), and 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). 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
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
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
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, 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, 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
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
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)
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)
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
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
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. Source: Valve's SteamOS Recovery FAQ and Installation and Repair FAQ, plus a menu photo in EloiStree/HelloSteamFrame#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

Debug recipes

# Which panels (app ids) exist right now?
ssh frame 'DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_APPS GAMESCOPE_FOCUSED_APP'

# Watch panels being created
ssh frame 'journalctl --user -f | grep --line-buffered "\[Overlays\]"'

# gamescope's full flags (in case Valve changes them)
ssh frame 'tr "\0" " " < /proc/$(pgrep -x gamescope | head -n 1)/cmdline'

# Everything the SteamVR dashboard can say (find hidden features)
ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashboard_english.json'

Where the rest lives