From 60237408b3270877d64156748951cd5d02d21bda Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:13:40 +1000 Subject: [PATCH] Chromium with WebXR for the Steam Frame Build script, SteamVR sandbox patch, and a Frame installer that adds Chromium XR to the Steam library. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 + LICENSE | 27 +++ README.md | 204 ++++++++++++++++++ build/build.sh | 126 +++++++++++ docs/technical-notes.md | 97 +++++++++ frame/chromium-xr | 23 ++ frame/install.sh | 61 ++++++ frame/steam-shortcut.py | 163 ++++++++++++++ frame/uninstall.sh | 31 +++ ...sandbox-allow-getsockopt-SO_PEERCRED.patch | 40 ++++ patches/README.md | 14 ++ 11 files changed, 789 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build/build.sh create mode 100644 docs/technical-notes.md create mode 100755 frame/chromium-xr create mode 100755 frame/install.sh create mode 100755 frame/steam-shortcut.py create mode 100755 frame/uninstall.sh create mode 100644 patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch create mode 100644 patches/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bf7e4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.tar.xz +.DS_Store +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba707a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2026, saphid + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..791c8dd --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# Chromium with WebXR for the Steam Frame + +A build recipe and installer for **Chromium with immersive WebXR on Valve's +Steam Frame** headset. Press the +**Enter VR** button on a WebXR site and it opens in the headset, through +SteamVR, as a full VR experience. + +The Chromium that SteamOS offers (Flathub) can't do this. Websites see +`navigator.xr`, but `isSessionSupported("immersive-vr")` returns `false`, so +VR buttons are greyed out or missing. Upstream Chromium only wires up its +OpenXR backend on Windows and Android. This repo builds Chromium with the +in-progress Linux OpenXR changes, adds a small fix so it works with SteamVR, +and installs it on the Frame as a normal app in your Steam library. + +## What you can do with it + +The web has a lot of VR content that only needs a browser: + +- **360° and 3D video.** Travel, nature, concerts, sports and documentaries + from players that support WebXR, shown around you instead of in a flat + window. +- **Games and toys.** WebXR games and experiments made with + [three.js](https://threejs.org/examples/?q=webxr), + [A-Frame](https://aframe.io/) or [Babylon.js](https://www.babylonjs.com/), + with nothing to install. +- **Learning.** Virtual museum tours, space and anatomy explorers, and other + educational experiences built for the web. +- **Art and music.** Painting, sculpting and music toys that run in a page. +- **Building WebXR sites.** Test your own WebXR app on real headset hardware + from a normal URL, with Chromium's DevTools. + +## Status + +Tested on a Steam Frame (SteamOS 0.3.0, build 20260922.6101926, +SteamVR 2.17.10) with Chromium **156.0.8071.0**, built for arm64: + +| | | +|---|---| +| `isSessionSupported("immersive-vr")` | `true` | +| [WebXR Samples](https://immersive-web.github.io/webxr-samples/) Immersive VR Session | shows its scene in the headset | +| three.js [stereo 360 video](https://threejs.org/examples/webxr_vr_video.html) | plays in 3D | +| Launch from the Steam library | opens as its own panel, like any app | +| Controller and hand input inside WebXR pages | not tested yet | +| Frame rate | not measured (looks smooth) | + +This is an unofficial, experimental build. See [Limitations](#limitations) +before you use it for anything other than VR sites. + +## Requirements + +- A Steam Frame with SteamVR, and a way to run commands on it: the terminal + in Desktop Mode, or SSH. +- To build: an **x86-64 Linux machine** with about **90 GB free disk**, git, + Python 3, tmux (or another way to keep a long job running) and a few + hours. No root access is needed. The first build took about 9.5 hours on + a 6-core, 12-thread desktop CPU; rebuilds take minutes. + +## Build + +On the Linux build machine: + +```sh +git clone https://github.com/saphid/chromium-webxr-steam-frame +cd chromium-webxr-steam-frame +mkdir -p ~/chromium-xr +tmux new -d -s chromium-xr 'build/build.sh > ~/chromium-xr/build.log 2>&1' +``` + +`build.sh` fetches Chromium with the Linux OpenXR changes, applies the patch +in [`patches/`](patches), cross-compiles for arm64 and packs the result into +`~/chromium-xr/chromium-xr-arm64.tar.xz` (about 145 MB). + +- Follow progress with `tail -F ~/chromium-xr/stage` (milestones) or + `tail -F ~/chromium-xr/build.log` (everything). +- If it stops (reboot, full disk, network), run the same command again. It + picks up where it left off. +- To build on another disk, set `CHROMIUM_XR_DIR` and use that directory + in place of `~/chromium-xr` in the commands above, including the log path. +- It stops itself if free disk space drops below 12 GB. + +## Install on the Frame + +Copy the tarball and this repo to the Frame, then run the installer there. +For example, over SSH from the build machine (replace `steamframe` with your +Frame's hostname or IP address): + +```sh +scp ~/chromium-xr/chromium-xr-arm64.tar.xz steamos@steamframe: +ssh steamos@steamframe +git clone https://github.com/saphid/chromium-webxr-steam-frame +chromium-webxr-steam-frame/frame/install.sh ~/chromium-xr-arm64.tar.xz +``` + +The installer: + +- unpacks the build into `~/chromium-xr`, after checking the new binary runs; +- installs the `chromium-xr` launcher in `~/.local/bin`; +- adds **Chromium XR** to the Desktop Mode app menu; +- adds **Chromium XR** to your Steam library, without restarting Steam. + +Run it again with a newer tarball to update. Your profile +(`~/.config/chromium-xr`) and the Steam shortcut are kept. You can delete +the repo clone afterwards; the installer keeps what it needs to uninstall. + +If the Steam shortcut can't be added automatically, add it by hand: in +Desktop Mode, open Steam, choose **Games → Add a Non-Steam Game to My +Library**, and pick Chromium XR. + +## Use it + +1. Open **Chromium XR** from your Steam library. It appears as a panel in + the headset. +2. Go to a WebXR site, for example the + [WebXR Samples](https://immersive-web.github.io/webxr-samples/). +3. Press the site's **Enter VR** button. +4. Chromium asks **Allow VR?** in the browser panel. Choose *Allow this time* + or *Allow while visiting the site*. +5. To leave VR, use the site's exit button or the Steam button. + +The first time you launch it, Steam may show an **External Controller +Translation** notice. It's only information about controller button icons; +choose OK. + +From a terminal on the Frame, `chromium-xr https://example.com` opens a +page directly. + +## Limitations + +- **Part of the sandbox is off.** The launcher passes + `--disable-seccomp-filter-sandbox`, which turns off Chrome's system-call + filter for every process; the namespace sandbox stays on. Without it, + SteamVR refuses the session (details in + [docs/technical-notes.md](docs/technical-notes.md)). Use Chromium XR for VR + sites and keep another browser for everyday browsing. +- **Saved passwords aren't encrypted.** The launcher uses + `--password-store=basic` so startup doesn't stop at a keyring prompt, so + passwords you save are stored unencrypted in `~/.config/chromium-xr`. +- **No automatic updates.** It won't get Chromium security fixes until you + rebuild it. +- **No DRM video.** There's no Widevine, so paid streaming services that + need it won't play. +- **One window at a time per profile.** If Chromium XR is already open, + launching it again opens the page in the existing window. +- **Not a default browser.** It works as one (the desktop entry registers + for web links), but for the reasons above it's better kept for VR. + +## Remove it + +```sh +~/.local/share/chromium-xr/uninstall.sh # keeps your profile +~/.local/share/chromium-xr/uninstall.sh --remove-profile # also deletes settings and logins +``` + +This removes the build, the launcher, the menu entry and the Steam shortcut. + +## Updating + +The Chromium changes are still under review upstream, so this repo pins one +revision of them (`CL_REF` in [`build/build.sh`](build/build.sh), currently +patch set 44 of CL 8132979). To build a newer patch set, set `CL_REF` when +you run the build, for example +`CL_REF=refs/changes/79/8132979/45 build/build.sh`. The script fetches it, +syncs, re-applies the local patch and rebuilds. A newer patch set may need +the patch in [`patches/`](patches) updated. Then install the new tarball on +the Frame as above. + +## How it works + +- **Chromium changes.** [CL 8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) + (a sandboxed XR process on Linux) and + [CL 8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) + (the OpenXR device provider on Linux, patch set 44), tracked in Chromium + [issue 506004811](https://issues.chromium.org/issues/506004811). Neither + is merged upstream yet. The OpenXR device is behind + `--enable-features=OpenXR`, which the launcher passes. +- **SteamVR fix.** SteamVR's OpenXR runtime asks the kernel who is on the + other end of its socket (`getsockopt(SO_PEERCRED)`). The XR sandbox policy + blocks all `getsockopt` calls, which crashed the XR process. + [`patches/0001-…`](patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch) + allows only that one option. +- **Steam integration.** `frame/steam-shortcut.py` adds the shortcut through + the Steam client's local DevTools port, the same API the Steam UI uses. + Steam runs each app as its own panel, so Chromium gets one too. + +More detail, including why the seccomp filter is off, is in +[docs/technical-notes.md](docs/technical-notes.md). + +## Related work + +- [utzcoz/chromium-webxr-linux](https://github.com/utzcoz/chromium-webxr-linux): + a fuller patch series for WebXR over OpenXR on Linux desktops, including + the in-headset permission UI and crash fixes, tested with + [Monado](https://monado.dev/). If you're on an x86-64 Linux PC rather than + the Steam Frame, start there. +- [Chromium issue 506004811](https://issues.chromium.org/issues/506004811): + upstream tracking for WebXR on Linux. + +## License + +The scripts in this repo are under the [BSD 3-Clause License](LICENSE). The +patch in [`patches/`](patches) modifies Chromium and is under +[Chromium's license](https://chromium.googlesource.com/chromium/src/+/main/LICENSE). +Chromium is a trademark of Google LLC; this project is not affiliated with +Google or Valve. diff --git a/build/build.sh b/build/build.sh new file mode 100755 index 0000000..4099ea2 --- /dev/null +++ b/build/build.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Cross-compile arm64 Chromium with WebXR over OpenXR on Linux, for the +# Steam Frame. Runs on an x86-64 Linux machine, no root needed. +# +# build/build.sh # checkout, patch, build, package +# +# Needs about 90 GB free disk and several hours (about 9.5 h on a 6-core +# machine for the first build). Run it detached, e.g. in tmux: +# tmux new -d -s chromium-xr 'build/build.sh > ~/chromium-xr/build.log 2>&1' +# +# Progress lines go to $CHROMIUM_XR_DIR/stage. The result is +# $CHROMIUM_XR_DIR/chromium-xr-arm64.tar.xz. Re-running resumes: the +# existing checkout and out/XR are reused, so a rebuild takes minutes. +# Set CL_REF to build a different patch set of CL 8132979 (see README). +set -euo pipefail + +W=${CHROMIUM_XR_DIR:-$HOME/chromium-xr} +PATCHES=$(cd "$(dirname "$0")/../patches" && pwd) +# Chromium CL 8132979 (WebXR OpenXR provider on Linux), patch set 44. It sits +# on top of CL 8441736 (the XR process sandbox), so fetching it gets both. +CL_REF=${CL_REF:-refs/changes/79/8132979/44} + +mkdir -p "$W" +cd "$W" +stage() { echo "$(date -Is) $*" | tee -a "$W/stage"; } +# Stops the build before the disk fills up. +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 +} + +# Checks for a file, not the directory, so an interrupted clone is retried. +if [ ! -x depot_tools/gclient ]; then + rm -rf depot_tools + git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git +fi +export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0 + +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 + +# Fetch when there's no checkout yet (or the first fetch was interrupted), or +# when CL_REF names a different patch set than last time. +if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1 || + [ "$(cat "$W/cl-ref" 2>/dev/null)" != "$CL_REF" ]; then + stage "fetch 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" + # Drops the local patches; they're re-applied below. + git -C src checkout -q --force FETCH_HEAD + echo "$CL_REF" > "$W/cl-ref" +fi +guard +stage "src at $(git -C src log -1 --format='%h %s')" + +rev=$(git -C src rev-parse HEAD) +# Sync once per revision. After the patches below are 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 +# Local fixes on top of the CLs. Each is applied once; re-runs skip it. +for p in "$PATCHES"/*.patch; do + if ! git apply --reverse --check "$p" 2>/dev/null; then + git apply "$p" + stage "applied $(basename "$p")" + fi +done + +mkdir -p out/XR +# Rewritten on every run: change build settings here, not in out/XR/args.gn. +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" +enable_nacl = false +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).*out/XR"; exit 3; }; done ) & +GUARD=$! +trap 'kill $GUARD 2>/dev/null || true' EXIT +autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler + +stage "package" +cp chrome/app/theme/chromium/product_logo_256.png out/XR/product_logo_256.png +cd out/XR +files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales product_logo_256.png) +# 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/docs/technical-notes.md b/docs/technical-notes.md new file mode 100644 index 0000000..73a9207 --- /dev/null +++ b/docs/technical-notes.md @@ -0,0 +1,97 @@ +# Technical notes + +What we found getting WebXR working on the Steam Frame, for anyone picking +this up or taking it upstream. Tested with Chromium 156.0.8071.0 (arm64), +SteamOS 0.3.0 (build 20260922.6101926) and SteamVR 2.17.10. + +## Why stock Chromium on Linux has no immersive WebXR + +Chromium 154 was the first release to compile OpenXR on Linux +(`enable_openxr` includes Linux). But +`content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an +OpenXR device when `ENABLE_OPENXR && IS_WIN`. Nothing on Linux calls the +OpenXR code, so the linker drops it. Flathub's arm64 Chromium contains no +OpenXR loader code at all, and flags such as `--force-webxr-runtime=openxr` +can't bring it back. + +The two Gerrit changes this repo builds fill that gap: + +- [CL 8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) + creates the OpenXR device on Linux, using the Vulkan graphics binding + (`XR_KHR_vulkan_enable2`). `device::features::kOpenXR` stays off by + default, so it needs `--enable-features=OpenXR`. +- [CL 8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) + runs the XR device service in its own sandbox (`xr_compositing`, with the + `XrProcessPolicy` seccomp policy and a file broker), instead of requiring + `--no-sandbox`. + +The build uses patch set 44 of CL 8132979, which sits on top of CL 8441736. + +## The SO_PEERCRED crash + +With both CLs, the XR utility process crashed inside `xrCreateInstance` on +system call 0xd1 (`getsockopt` on arm64). SteamVR's IPC client calls +`getsockopt(fd, SOL_SOCKET, SO_PEERCRED, …)` to check which process is on the +other end of its socket, and `XrProcessPolicy` refuses every `getsockopt`. +[`patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch`](../patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch) +allows exactly `SOL_SOCKET`/`SO_PEERCRED` and still returns `EPERM` for +everything else. + +## Why the seccomp filter is still off + +With the patch, `xrCreateInstance` gets further but fails with +`Unable to init path manager: VRInitError_Init_Internal`. SteamVR's client +reads `/proc/self/status` to find its own process ID. Inside the seccomp +sandbox, file opens go through Chrome's broker process, so `/proc/self` is +the broker's, and SteamVR registers the broker's PID instead of the XR +process's. Granting the broker `/proc/self` doesn't help, because the broker +can only answer for itself. + +Fixing this properly needs a change in Chromium: for example, having the +broker client rewrite `/proc/self` to `/proc/`, or opening the +needed `/proc` files before the sandbox seals. Until then the launcher passes +`--disable-seccomp-filter-sandbox`. The namespace sandbox still works: +SteamOS allows unprivileged user namespaces, so the setuid `chrome_sandbox` +isn't needed. + +## What a working session looks like + +- The page's `isSessionSupported("immersive-vr")` resolves `true`. +- After the **Allow VR?** prompt, `requestSession("immersive-vr")` succeeds. + The first frame has a viewer pose with 2 views and a 2880 × 1440 framebuffer + (1440 × 1440 per eye). +- SteamVR's log (`~/.local/share/Steam/logs/vrserver.txt`) shows the app move + from `VRApplication_OpenXRInstance` to `VRApplication_OpenXRScene`, followed + by controller binding files being created for + `system.generated.openxr.chromium 156.chrome`. +- If nobody is wearing the headset, SteamVR keeps it in standby: the session + stays at `XR_SESSION_STATE_SYNCHRONIZED`, the page sees + `visibilityState: "hidden"`, and only the first frame runs. Put the headset + on to see it. + +## Graphics + +Chromium's GPU process uses ANGLE on OpenGL, which runs on zink over the +Turnip Vulkan driver (Adreno 750). Chromium's own Vulkan backend stays off; +that doesn't stop the session. The OpenXR runtime uses Vulkan. + +## Panels and the Steam library + +The Frame's compositor (gamescope) gives every Steam app ID its own SteamVR +overlay, named `valve.steam.desktopgame.`, which appears as a panel in +the headset. A non-Steam shortcut gets an app ID like any game, so launching +Chromium XR from the library gives it a panel. `frame/steam-shortcut.py` adds +the shortcut through the Steam client's DevTools port (`127.0.0.1:8080`, +page `SharedJSContext`) with `SteamClient.Apps.AddShortcut`, so Steam doesn't +need restarting. (`steam steam://addnonsteamgame/` adds nothing.) + +## Debugging + +- `chromium-xr --remote-debugging-port=9223 URL` opens DevTools on the Frame's + loopback. It has no password, so close the browser when you're done. If a + VPN such as userspace Tailscale forwards traffic to loopback, other devices + can reach it. +- `chrome://gpu` and `chrome://webxr-internals` show the graphics setup and + the XR runtime Chromium picked. +- SteamVR's logs are in `~/.local/share/Steam/logs/`. `vrserver.txt` shows + the session starting and which app SteamVR bound it to. diff --git a/frame/chromium-xr b/frame/chromium-xr new file mode 100755 index 0000000..7cac010 --- /dev/null +++ b/frame/chromium-xr @@ -0,0 +1,23 @@ +#!/bin/bash +# Start the WebXR Chromium build. frame/install.sh copies this to +# ~/.local/bin/chromium-xr; the Steam library shortcut and the desktop entry +# both run it. Arguments go to Chromium, so `chromium-xr URL` opens a page. +set -euo pipefail + +CHROME="${CHROMIUM_XR_HOME:-$HOME/chromium-xr}/chrome" +[[ -x "$CHROME" ]] || { echo "chromium-xr: no build at $CHROME (run frame/install.sh)" >&2; exit 1; } + +# --enable-features=OpenXR: the Linux OpenXR device is off by default. +# --ozone-platform=x11: gamescope's X display, where each app is a panel. +# --no-first-run and --password-store=basic: otherwise startup can stop at a +# first-run or keyring prompt you can't see. +# --disable-seccomp-filter-sandbox: with the XR process's seccomp policy on, +# SteamVR sees the wrong process ID and refuses the session (see +# docs/technical-notes.md). The namespace sandbox stays on. +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 \ + "$@" diff --git a/frame/install.sh b/frame/install.sh new file mode 100755 index 0000000..8409805 --- /dev/null +++ b/frame/install.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Run on the Steam Frame (Desktop Mode terminal or SSH), as the normal user: +# +# frame/install.sh chromium-xr-arm64.tar.xz +# +# Unpacks the build into ~/chromium-xr, installs the `chromium-xr` launcher +# in ~/.local/bin, adds a desktop menu entry, and adds "Chromium XR" to the +# Steam library. Safe to rerun, e.g. to install a newer build. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +tarball=${1:-} +[[ -n "$tarball" && -f "$tarball" ]] || { echo "usage: $0 chromium-xr-arm64.tar.xz" >&2; exit 2; } +[[ $(uname -m) == aarch64 ]] || { echo "This is for the Steam Frame (arm64); this machine is $(uname -m)." >&2; exit 1; } + +dest=$HOME/chromium-xr +state=$HOME/.local/share/chromium-xr +launcher=$HOME/.local/bin/chromium-xr + +echo "Unpacking into $dest" +rm -rf "$dest.new" +mkdir -p "$dest.new" +tar -xJf "$tarball" -C "$dest.new" +# Check the new build runs at all before replacing the old one. +"$dest.new/chrome" --version +rm -rf "$dest" +mv "$dest.new" "$dest" + +mkdir -p "$HOME/.local/bin" "$state" "$HOME/.local/share/applications" +install -m 755 "$here/chromium-xr" "$launcher" +# Keep the uninstaller, so removing works after the repo clone is deleted. +install -m 755 "$here/uninstall.sh" "$here/steam-shortcut.py" "$state/" + +icon='' +for candidate in "$dest/product_logo_256.png" \ + /var/lib/flatpak/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png \ + "$HOME/.local/share/flatpak/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png"; do + if [[ -f "$candidate" ]]; then icon=$candidate; break; fi +done + +cat > "$HOME/.local/share/applications/chromium-xr.desktop" <&2 + echo "Games > Add a Non-Steam Game to My Library, then pick Chromium XR." >&2 +fi + +echo "Done. Open Chromium XR from your Steam library, or run: chromium-xr URL" diff --git a/frame/steam-shortcut.py b/frame/steam-shortcut.py new file mode 100755 index 0000000..2a7bca3 --- /dev/null +++ b/frame/steam-shortcut.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Add, update or remove the "Chromium XR" shortcut in the Steam library. + +Talks to the Steam client's built-in DevTools port (127.0.0.1:8080, which +SteamOS starts Steam with), so Steam doesn't need restarting. Python stdlib +only. + + steam-shortcut.py ensure NAME EXE START_DIR ICON ID_FILE -> prints the app id + steam-shortcut.py remove NAME ID_FILE +""" +import base64, json, os, socket, struct, sys, urllib.request + +DEVTOOLS = 'http://127.0.0.1:8080/json' + + +def target_ws(): + for t in json.load(urllib.request.urlopen(DEVTOOLS, timeout=5)): + if t.get('title') == 'SharedJSContext': + return t['webSocketDebuggerUrl'] + sys.exit('SharedJSContext not found: is the Steam client running?') + + +class WS: + """Just enough RFC 6455 for one CDP request/response on loopback.""" + + def __init__(self, url): + host_port, path = url[len('ws://'):].split('/', 1) + host, port = host_port.split(':') + self.s = socket.create_connection((host, int(port)), timeout=20) + key = base64.b64encode(os.urandom(16)).decode() + self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n' + f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n' + 'Sec-WebSocket-Version: 13\r\n\r\n').encode()) + buf = b'' + while b'\r\n\r\n' not in buf: + chunk = self.s.recv(4096) + if not chunk: + raise EOFError('connection closed during the websocket handshake') + buf += chunk + if b' 101 ' not in buf.split(b'\r\n', 1)[0]: + sys.exit('websocket handshake failed') + self.rest = buf.split(b'\r\n\r\n', 1)[1] + + def _read(self, n): + while len(self.rest) < n: + chunk = self.s.recv(65536) + if not chunk: + raise EOFError + self.rest += chunk + out, self.rest = self.rest[:n], self.rest[n:] + return out + + def send(self, text): + data = text.encode() + mask = os.urandom(4) + n = len(data) + head = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else + bytes([0x80 | 126]) + struct.pack('>H', n) if n < 65536 else + bytes([0x80 | 127]) + struct.pack('>Q', n)) + self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + + def recv(self): + msg = b'' + while True: + b0, b1 = self._read(2) + n = b1 & 0x7f + if n == 126: + n = struct.unpack('>H', self._read(2))[0] + elif n == 127: + n = struct.unpack('>Q', self._read(8))[0] + msg += self._read(n) + if b0 & 0x80: + return msg.decode() + + +def evaluate(js): + ws = WS(target_ws()) + ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': { + 'expression': js, 'awaitPromise': True, 'returnByValue': True}})) + while True: + r = json.loads(ws.recv()) + if r.get('id') == 1: + break + res = r.get('result', {}) + if 'exceptionDetails' in res: + sys.exit('JS error: ' + json.dumps(res['exceptionDetails'])[:500]) + return res.get('result', {}).get('value') + + +def shortcuts(): + return evaluate('''(() => appStore.allApps.filter(a => a.app_type === 1073741824) + .map(a => ({appid: a.appid, name: a.display_name})))()''') + + +def read_id(path): + try: + with open(path) as f: + return int(f.read().strip()) + except (OSError, ValueError): + return None + + +def ensure(name, exe, start_dir, icon, id_file): + saved = read_id(id_file) + apps = shortcuts() + # Match the saved app id first, so renaming the shortcut in Steam + # doesn't make a rerun add a second one. + found = ([a['appid'] for a in apps if a['appid'] == saved] or + [a['appid'] for a in apps if a['name'] == name]) + if found: + appid = found[0] + evaluate(f'''(() => {{ + SteamClient.Apps.SetShortcutExe({appid}, {json.dumps(exe)}); + SteamClient.Apps.SetShortcutStartDir({appid}, {json.dumps(start_dir)}); + if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon({appid}, {json.dumps(icon)}); + }})()''') + else: + appid = evaluate(f'''(async () => {{ + const id = await SteamClient.Apps.AddShortcut({json.dumps(name)}, {json.dumps(exe)}, "", ""); + SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); + SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); + return id; + }})()''') + if not isinstance(appid, int) or appid <= 0: + sys.exit(f'Steam did not return a shortcut app id: {appid!r}') + os.makedirs(os.path.dirname(id_file), exist_ok=True) + with open(id_file, 'w') as f: + f.write(f'{appid}\n') + print(appid) + + +def remove(name, id_file): + saved = read_id(id_file) + apps = shortcuts() + # The saved app id, plus any shortcut with the name (a lost id file, or one + # added by hand). + found = [a['appid'] for a in apps if a['appid'] == saved or a['name'] == name] + for appid in found: + evaluate(f'SteamClient.Apps.RemoveShortcut({appid})') + print(f'removed Steam shortcut {appid}') + if not found: + print('no Steam shortcut to remove') + if saved is not None: + os.remove(id_file) + + +def main(): + if len(sys.argv) == 7 and sys.argv[1] == 'ensure': + action = lambda: ensure(*sys.argv[2:]) + elif len(sys.argv) == 4 and sys.argv[1] == 'remove': + action = lambda: remove(*sys.argv[2:]) + else: + sys.exit(__doc__) + try: + action() + except (OSError, ValueError, KeyError, EOFError) as e: + sys.exit(f"Couldn't update the Steam shortcut ({type(e).__name__}: {e}). " + "Is the Steam client running?") + + +if __name__ == '__main__': + main() diff --git a/frame/uninstall.sh b/frame/uninstall.sh new file mode 100755 index 0000000..eb447f0 --- /dev/null +++ b/frame/uninstall.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Run on the Steam Frame to remove what frame/install.sh added: the build, +# the launcher, the menu entry and the Steam shortcut. Your Chromium XR +# profile (~/.config/chromium-xr: settings, logins, history) is kept unless +# you pass --remove-profile. +# +# install.sh keeps a copy of this script in ~/.local/share/chromium-xr, so it +# works even after the repo clone is deleted. +set -euo pipefail + +remove_profile=false +[[ $# -le 1 ]] || { echo "usage: $0 [--remove-profile]" >&2; exit 2; } +case "${1:-}" in + '') ;; + --remove-profile) remove_profile=true ;; + *) echo "usage: $0 [--remove-profile]" >&2; exit 2 ;; +esac + +here=$(cd "$(dirname "$0")" && pwd) +state=$HOME/.local/share/chromium-xr + +python3 "$here/steam-shortcut.py" remove "Chromium XR" "$state/steam-appid" || + echo "Couldn't remove the Steam shortcut; delete Chromium XR from the library by hand." >&2 +rm -rf "$HOME/chromium-xr" "$state" +rm -f "$HOME/.local/bin/chromium-xr" "$HOME/.local/share/applications/chromium-xr.desktop" +if $remove_profile; then + rm -rf "$HOME/.config/chromium-xr" + echo "Chromium XR and its profile removed." +else + echo "Chromium XR removed. Your profile is still in ~/.config/chromium-xr." +fi diff --git a/patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch b/patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch new file mode 100644 index 0000000..4a745e1 --- /dev/null +++ b/patches/0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch @@ -0,0 +1,40 @@ +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 level(1); ++ const Arg 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). diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..daabfee --- /dev/null +++ b/patches/README.md @@ -0,0 +1,14 @@ +# Patches + +Local changes applied on top of Chromium +[CL 8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) +and [CL 8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) +by `build/build.sh`, in file-name order. + +- `0001-xr-sandbox-allow-getsockopt-SO_PEERCRED.patch`: lets the XR process + call `getsockopt(SOL_SOCKET, SO_PEERCRED)`, which SteamVR's OpenXR runtime + needs. See [docs/technical-notes.md](../docs/technical-notes.md). + +These patches modify Chromium source and are under +[Chromium's BSD-style license](https://chromium.googlesource.com/chromium/src/+/main/LICENSE), +Copyright The Chromium Authors.