mirror of
https://github.com/saphid/chromium-webxr-steam-frame.git
synced 2026-10-04 22:00:10 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
commit
60237408b3
11 files changed
+789
No files matched your search
@@ -0,0 +1,3 @@
|
||||
*.tar.xz
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+126
@@ -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")"
|
||||
@@ -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/<caller pid>`, 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.<appid>`, 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/<path>` 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.
|
||||
Executable
+23
@@ -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 \
|
||||
"$@"
|
||||
Executable
+61
@@ -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" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Chromium XR
|
||||
Comment=Chromium with WebXR (immersive VR) through SteamVR
|
||||
Exec=$launcher %U
|
||||
Icon=${icon:-web-browser}
|
||||
Terminal=false
|
||||
Categories=Network;WebBrowser;
|
||||
MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https;
|
||||
EOF
|
||||
|
||||
echo "Adding Chromium XR to the Steam library"
|
||||
if appid=$(python3 "$here/steam-shortcut.py" ensure "Chromium XR" "$launcher" "$HOME" "$icon" "$state/steam-appid"); then
|
||||
echo "Steam shortcut ready (app id $appid)"
|
||||
else
|
||||
echo "Couldn't add the Steam shortcut automatically. In Desktop Mode, open Steam and use" >&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"
|
||||
Executable
+163
@@ -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()
|
||||
Executable
+31
@@ -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
|
||||
@@ -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<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).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user