diff --git a/docs/webxr-chromium.md b/docs/webxr-chromium.md new file mode 100644 index 0000000..5b0bf2d --- /dev/null +++ b/docs/webxr-chromium.md @@ -0,0 +1,82 @@ +# WebXR in Chromium on the Frame + +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. + +## Why Flathub Chromium can't + +**Verified 2026-09-25** (Frame BUILD_ID 20260922.6101926, Flathub +`org.chromium.Chromium` 154.0.8037.57 aarch64): + +- `navigator.xr` exists, but `isSessionSupported("immersive-vr")` is `false`. + Flags don't change that, and neither does `--force-webxr-runtime=openxr` + with SteamVR exposed to the Flatpak. +- The binary has no OpenXR loader: no `XR_RUNTIME_JSON`, + `xrGetInstanceProcAddr` or `XR_LOADER_DEBUG` strings. The only OpenXR + strings are the `chrome://flags` entries. + +**Cause (verified against Chromium source, same date).** M154 is the first +release that compiles OpenXR on Linux: + +- `device/vr/buildflags/buildflags.gni` adds `is_linux` to `enable_openxr`. +- Flathub's tarball sets `checkout_openxr = true`. +- Flathub's GN args don't turn it off. + +But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates +the OpenXR device under `ENABLE_OPENXR && IS_WIN`. That's true on 154, 155 and +`main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The +rest of the Linux port is in two unmerged CLs (bug 506004811): + +- [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) + runs the XR device service in a Linux sandbox that allows SteamVR's + sockets, `/dev/shm` and `flock`. +- [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) + wires the provider to `OpenXrPlatformHelperLinux`. `kOpenXR` stays off by + default, so it needs `--enable-features=OpenXR`. + +8132979 sits on top of 8441736, so fetching `refs/changes/79/8132979/` +gets both. + +**The Frame side is ready.** `~/.config/openxr/1/active_runtime.json` names +SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The +Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`). + +## Building 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, runs `gclient sync --no-history`, +installs the sysroot, builds `chrome` with `symbol_level=0` and proprietary +codecs, and packs `chromium-xr-arm64.tar.xz`. Progress is logged to +`~/chromium-xr/stage`. The build aborts if `/` drops below 12 GB free. + +First run: buildhost (12 cores, 31 GB RAM), started 2026-09-25. + +## Running it on the Frame + +[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh): + +```sh +scripts/chromium-xr.sh install # scp from buildhost, unpack to ~/chromium-xr +scripts/chromium-xr.sh launch [URL] # headset desktop, --enable-features=OpenXR +scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr') +``` + +It runs natively, not as a Flatpak, so the XR sandbox and SteamVR's IPC work +as the CL expects. It uses its own profile (`~/.config/chromium-xr`) and +DevTools on loopback port 9223, so it doesn't collide with the Flatpak's 9222. + +**Verified 2026-09-25:** + +- Vulkan is there: Turnip (Mesa) on Adreno 750, API 1.4.359. +- Unprivileged user namespaces work (`unshare -Ur true`), so Chromium's + namespace sandbox shouldn't need the setuid `chrome_sandbox`. + +**Unverified (inferred):** + +- Chromium's GPU process may still fall back from Vulkan to GL on Turnip. +- An immersive session started from a window on the nested desktop may not + hand over cleanly to the SteamVR compositor. +- If the sandbox fails to start, `--no-sandbox` is the fallback for a first + test. diff --git a/scripts/build-chromium-xr.sh b/scripts/build-chromium-xr.sh new file mode 100755 index 0000000..a5786ed --- /dev/null +++ b/scripts/build-chromium-xr.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs +# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame. +# Needs ~90 GB free, no sudo. Takes hours; run it detached, e.g. on buildhost: +# 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')" +stage "gclient sync" +gclient sync --nohooks --no-history -D --shallow --revision "src@$(git -C src rev-parse HEAD)" -j 8 +guard +stage "runhooks" +gclient runhooks +src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64 +guard +cd src +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)" diff --git a/scripts/chromium-xr.sh b/scripts/chromium-xr.sh new file mode 100755 index 0000000..51a7394 --- /dev/null +++ b/scripts/chromium-xr.sh @@ -0,0 +1,97 @@ +#!/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: fetch from $BUILD_HOST +# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop +# scripts/chromium-xr.sh check # isSessionSupported via DevTools +set -euo pipefail + +FRAME_ALIAS=${FRAME_ALIAS:-frame} +BUILD_HOST=${BUILD_HOST:-buildhost} +BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz} +DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223} +here=${0:A:h} + +case "${1:-}" in + install) + tarball=${2:-} + if [[ -z "$tarball" ]]; then + 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) + # run-on-frame starts in $HOME on the Frame, so the profile path is relative. + exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \ + --user-data-dir=.config/chromium-xr \ + --enable-features=OpenXR \ + --remote-debugging-port="$DEVTOOLS_PORT" \ + "${2:-https://immersive-web.github.io/webxr-samples/}" + ;; + 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,13p' "$0"; exit 2 ;; +esac