Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 9110557e23 README: link the Frame Control trailer
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>
2026-09-27 22:07:44 +10:00
saphidandClaude Opus 5.5 13f629af35 WebXR doc: drop the stale not-verified list
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:55:09 +10:00
saphidandClaude Opus 5.5 7efce19f4c WebXR doc: measured frame rate and controller input
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:54:48 +10:00
saphidandClaude Opus 5.5 623ce683d4 Document testing VR without wearing the headset; Steam-launched WebXR verified
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:47:57 +10:00
saphidandClaude Opus 5.5 e5f3c96275 WebXR doc: keep only the verified DevTools launch recipe
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:47:33 +10:00
saphidandClaude Opus 5.5 0cb289d571 WebXR: point to the public chromium-webxr-steam-frame repo
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>
2026-09-27 07:06:42 +10:00
7 changed files with 71 additions and 372 deletions

No files matched your search

+6 -1
View File
@@ -12,12 +12,17 @@ See what the headset sees, install games and Android apps, move files and text a
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Download**](#install) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
[**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
<br>
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
<a id="trailer"></a>
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
</div>
+1
View File
@@ -33,6 +33,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| 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](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](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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+64 -69
View File
@@ -3,9 +3,11 @@
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
press its VR button, and watch in 3D in the headset.
A standalone, public version of this build (build script, the SO_PEERCRED
patch, and a Frame-side installer that adds "Chromium XR" to the Steam library)
is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame).
The build and installer now live in their own public repo,
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
Frame-side installer that adds "Chromium XR" to the Steam library. This page
keeps the findings and what was verified on this Frame.
## Why Flathub Chromium can't
@@ -45,76 +47,52 @@ gets both.
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building it
## Building and installing it
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk
holding `~/chromium-xr` drops below 12 GB free.
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
`valve.steam.desktopgame.<appid>`, like any other app.
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
takes under a minute, plus about 4 minutes to repack.
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
To debug from the Mac, launch it as a panel with DevTools on the Frame
(verified 2026-09-27):
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
([panels.md](panels.md)). DevTools has no authentication. It listens on
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
running, loopback ports are reachable from your tailnet. Close the browser
when you're done. Chromium runs one browser per profile, so close the
Steam-launched one first or the flag is ignored.
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
so the XR process died with a seccomp crash (arm64 syscall 209). The script
allows that one option. That's needed but not enough: `launch` still turns
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
allows that one option. It's needed but not enough: the launcher still turns
seccomp off (below), so the patch only matters once that's fixed too.
## Running it on the Frame
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
through Chrome's file broker and gets the broker's pid. SteamVR then binds
the app to the wrong process ("Unable to init path manager:
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
answer `/proc/self` for another process, so fixing this needs a change in
Chromium's broker client or in the CL. The namespace sandbox stays on, but
seccomp is off for every process, so use this profile for VR sites rather
than everyday browsing.
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
```sh
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
```
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
gamescope's X display, the same way as [`panel-on-frame.sh`](../scripts/panel-on-frame.sh) ([panels.md](panels.md)), so
the Plasma desktop doesn't need to be open. It uses its own profile
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
visiting the site*.
Both ways of starting it run
[`frame/chromium-xr/launch.sh`](../frame/chromium-xr/launch.sh), copied to
`~/Applications/ChromiumXR/launch.sh` on the Frame, which holds Chrome's flags.
It sits outside `~/chromium-xr` so `install` doesn't delete it.
**From the Steam library (verified 2026-09-26).** `steam` adds a non-Steam
shortcut called "Chromium XR" (with the Flathub Chromium icon, if that's
installed) through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)), without restarting Steam. It saves the app id in
`~/Applications/ChromiumXR/shortcut-appid`, so rerunning it, even after you
rename the shortcut in the library, doesn't add a second one. On this Frame
the shortcut app id is 2240749789. Launching it from the library gives
Chromium its own panel, `valve.steam.desktopgame.2240749789`, like any other
app. A Steam launch doesn't open a DevTools port, so `check` needs `launch`.
Chromium runs one browser per profile: while the Steam-launched one is open,
`launch` opens its URL in that window, without DevTools, then prints
`failed: ... exited` because no new window appeared. Close it first.
**Seccomp is off.** The wrapper passes `--disable-seccomp-filter-sandbox`. With
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
process, so fixing this needs a change in Chromium's broker client or in the
CL. The namespace sandbox stays on, but seccomp is off for every process, so
use this profile for VR sites rather than everyday browsing. DevTools on
port 9223 has no authentication. It listens on loopback, but with the
userspace Tailscale from [tailscale.md](tailscale.md) running, loopback ports
are reachable from your tailnet. Close the browser when you're done.
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
and the CLs' author are working on Steam Frame support
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
Both sandbox problems above, with the patch, are reported in
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build):
@@ -138,9 +116,26 @@ this build):
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
a stereo 360 video, played in 3D after pressing Enter VR.
**Not verified yet:**
- **Launched from the Steam library, verified remotely 2026-09-27** with
nobody wearing the headset (standby workaround in
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
shortcut starts Chromium, and SteamVR takes it as scene app
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
to red ran at about 75 frames per second, and the stereo headset capture
showed both eyes solid red. Steam preloads its overlay
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
a Steam launch. The public repo's launcher now removes it from
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
passthrough wherever the page leaves transparent pixels.
- Frame rate and dropped frames during playback (nothing was measured; it
looked fine).
- Third-party VR180 players (DeoVR and DL8 web embeds).
- Controller and hand input inside a WebXR page.
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
dropped frames only at startup. The right controller showed up as an
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
25-joint hand, with poses on every frame. A real squeeze reached the page
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
Details are in the public repo's technical notes.
**Not verified yet:** trigger, thumbstick and face buttons, the left
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
DL8 web embeds).
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# Frame-side: start the WebXR Chromium build (~/chromium-xr). The Steam
# library shortcut "Chromium XR" runs this, and so does
# `scripts/chromium-xr.sh launch` (which adds a DevTools port). Extra
# arguments go to Chrome, so a URL opens that page.
#
# Lives in ~/Applications/ChromiumXR, outside ~/chromium-xr, so reinstalling
# the build doesn't delete it.
set -euo pipefail
CHROME="$HOME/chromium-xr/chrome"
[[ -x "$CHROME" ]] || { echo "launch.sh: no build at $CHROME (run chromium-xr.sh install)" >&2; exit 1; }
# Without --no-first-run and --password-store=basic, startup can stop at a
# first-run or keyring prompt.
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
# client reads /proc/self/status through the file broker, gets the broker's
# pid, and SteamVR binds the app to the wrong process, so xrCreateInstance
# fails. The namespace sandbox stays on, but seccomp is off for every
# process, so keep this profile for VR sites.
exec "$CHROME" \
--user-data-dir="$HOME/.config/chromium-xr" \
--enable-features=OpenXR \
--ozone-platform=x11 \
--no-first-run --no-default-browser-check --password-store=basic \
--disable-seccomp-filter-sandbox \
"$@"
-135
View File
@@ -1,135 +0,0 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
# immersive-vr works on the Frame.
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
# which scripts/chromium-xr.sh install copies to the Frame.
# Re-running resumes: existing checkout and out/XR are reused.
set -euo pipefail
W=~/chromium-xr
cd "$W"
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
CL_REF=refs/changes/79/8132979/44
if [ ! -f .gclient ]; then
cat > .gclient <<'G'
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
target_os = ["linux"]
target_cpu = ["arm64"]
G
fi
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
stage "clone src at $CL_REF"
mkdir -p src
[ -d src/.git ] || git -C src init -q
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
git -C src fetch -q --depth=1 origin "$CL_REF"
git -C src checkout -q FETCH_HEAD
fi
guard
stage "src at $(git -C src log -1 --format='%h %s')"
rev=$(git -C src rev-parse HEAD)
# Sync once per revision: once the patch below is applied, gclient sync
# refuses to run on the modified checkout, so re-runs must skip it.
if [ "$(cat "$W/synced" 2>/dev/null)" != "$rev" ]; then
stage "gclient sync"
gclient sync --nohooks --no-history -D --shallow --revision "src@$rev" -j 8
guard
stage "runhooks"
gclient runhooks
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
echo "$rev" > "$W/synced"
fi
guard
cd src
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
# process (verified on the Frame 2026-09-26). Allow only that option.
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
index 435e13d396..297453f582 100644
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
@@ -11,6 +11,7 @@
#include "sandbox/linux/system_headers/linux_syscalls.h"
#include "sandbox/policy/linux/sandbox_linux.h"
+using sandbox::bpf_dsl::AllOf;
using sandbox::bpf_dsl::Allow;
using sandbox::bpf_dsl::Arg;
using sandbox::bpf_dsl::Error;
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
switch (system_call_number) {
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
- // stay disallowed; add a narrow level/optname restriction if ever needed.
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
#if defined(__NR_getpeername)
case __NR_getpeername:
#endif
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
case __NR_get_robust_list:
#endif
return Allow();
+#if defined(__NR_getsockopt)
+ case __NR_getsockopt: {
+ // SteamVR's IPC client checks who is on the other end of its socket
+ // with SO_PEERCRED. Nothing else is readable.
+ const Arg<int> level(1);
+ const Arg<int> optname(2);
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
+ .Else(Error(EPERM));
+ }
+#endif
#if defined(__NR_kill)
case __NR_kill: {
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
P
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
printf '%s\n' "$PEERCRED_PATCH" | git apply
stage "applied SO_PEERCRED patch"
fi
mkdir -p out/XR
cat > out/XR/args.gn <<'A'
target_os = "linux"
target_cpu = "arm64"
is_debug = false
is_official_build = false
is_component_build = false
dcheck_always_on = false
symbol_level = 0
blink_symbol_level = 0
v8_symbol_level = 0
proprietary_codecs = true
ffmpeg_branding = "Chrome"
use_remoteexec = false
use_siso = true
treat_warnings_as_errors = false
A
stage "gn gen"
gn gen out/XR
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
stage "build"
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
GUARD=$!
trap 'kill $GUARD 2>/dev/null || true' EXIT
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
stage "package"
cd out/XR
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
# GPU libraries aren't produced by every config; pack the ones that exist.
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
[ -e "$f" ] && files+=("$f")
done
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
#
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
#
# Usage:
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
# scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
BUILD_HOST=${BUILD_HOST:-}
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
STEAM_NAME=${STEAM_NAME:-Chromium XR}
here=${0:A:h}
wrapper='~/Applications/ChromiumXR/launch.sh'
# frame/chromium-xr/launch.sh holds Chrome's flags; both launch paths run it.
push_wrapper() {
# Write then rename, so a dropped connection can't leave a torn script.
ssh "$FRAME_ALIAS" 'mkdir -p ~/Applications/ChromiumXR && cd ~/Applications/ChromiumXR && cat > launch.sh.new && chmod +x launch.sh.new && mv launch.sh.new launch.sh' \
< "$here/../frame/chromium-xr/launch.sh"
}
case "${1:-}" in
install)
tarball=${2:-}
if [[ -z "$tarball" ]]; then
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
tarball=$tmp/chromium-xr-arm64.tar.xz
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
fi
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
# Check the new build runs before replacing the old one.
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
;;
launch)
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
# need to be open. DevTools is only on for this path (for `check`).
push_wrapper
exec "$here/panel-on-frame.sh" --name chromium-xr -- "$wrapper" \
--remote-debugging-port="$DEVTOOLS_PORT" \
"${2:-https://immersive-web.github.io/webxr-samples/}"
;;
steam)
# A non-Steam shortcut, added through the Steam client's DevTools port
# without restarting Steam (see docs/apks.md). Launching it from the
# library gives Chromium its own panel like any game. Safe to rerun: it
# refreshes the wrapper and only adds the shortcut if it's missing.
ssh "$FRAME_ALIAS" 'test -x ~/chromium-xr/chrome' ||
{ print -u2 "No build in ~/chromium-xr on the Frame: run 'chromium-xr.sh install' first"; exit 1; }
push_wrapper
# The app id is kept next to the wrapper, so renaming the shortcut in the
# library doesn't make a rerun add a second one.
shortcuts=$here/../frame/android/steam_shortcuts.py
home=$(ssh "$FRAME_ALIAS" 'printf %s "$HOME"')
saved=$(ssh "$FRAME_ALIAS" 'cat ~/Applications/ChromiumXR/shortcut-appid 2>/dev/null || true')
existing=$(ssh "$FRAME_ALIAS" python3 - list < "$shortcuts" |
python3 -c 'import json,sys
apps = json.load(sys.stdin)
ids = [a["appid"] for a in apps if str(a["appid"]) == sys.argv[2]] or [a["appid"] for a in apps if a["name"] == sys.argv[1]]
print(ids[0] if ids else "")' "$STEAM_NAME" "$saved")
if [[ -n "$existing" ]]; then
print -r -- "The shortcut is already in the Steam library (app id $existing)"
else
icon=''
for dir in /var/lib/flatpak '~/.local/share/flatpak'; do
candidate=$dir/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png
if ssh "$FRAME_ALIAS" "test -f $candidate"; then icon=${candidate/#\~/$home}; break; fi
done
existing=$(ssh "$FRAME_ALIAS" python3 - add ${(q)STEAM_NAME} ${(q)home}/Applications/ChromiumXR/launch.sh ${(q)home} ${(q)icon} < "$shortcuts")
[[ "$existing" == <-> ]] || { print -u2 -r -- "Steam didn't return a shortcut app id: $existing"; exit 1; }
print -r -- "Added $STEAM_NAME to the Steam library (shortcut app id $existing)"
fi
ssh "$FRAME_ALIAS" "printf '%s\n' $existing > ~/Applications/ChromiumXR/shortcut-appid"
;;
check)
# DevTools listens on the Frame's loopback only; evaluate there.
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
import json, sys, urllib.request, base64, os, socket, struct
port = int(sys.argv[1])
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
page = next((t for t in tabs if t["type"] == "page"), None)
if page is None:
sys.exit("no open page: run 'chromium-xr.sh launch' first")
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
s = socket.create_connection(("127.0.0.1", port), timeout=30)
key = base64.b64encode(os.urandom(16)).decode()
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
s.recv(4096)
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
"awaitPromise": True}}).encode()
mask = os.urandom(4)
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
buf = b""
reply = None
while reply is None:
chunk = s.recv(65536)
if not chunk:
sys.exit("DevTools closed the connection")
buf += chunk
# Consume every complete frame already buffered before reading again.
while len(buf) >= 2:
n = buf[1] & 0x7F
off = 2
if n == 126:
if len(buf) < 4:
break
n, off = struct.unpack(">H", buf[2:4])[0], 4
elif n == 127:
if len(buf) < 10:
break
n, off = struct.unpack(">Q", buf[2:10])[0], 10
if len(buf) < off + n:
break
frame, buf = buf[off:off + n], buf[off + n:]
msg = json.loads(frame)
if msg.get("id") == 1:
reply = msg
break
print("immersive-vr supported:", reply["result"]["result"].get("value"))
EOF
;;
*) sed -n '2,14p' "$0"; exit 2 ;;
esac