Launcher: laser drags scroll like a touchscreen; allow VR on every site

gamescope turns the laser into mouse clicks for apps Steam launches, so
dragging selected text. When Steam starts it, the launcher now runs Chromium
as a systemd user service outside Steam's process tree: its windows get their
own gamescope panel and real touches. Steam still tracks it and Stop closes it.
CHROMIUM_XR_STEAM_PANEL=1 keeps the old behaviour. --touch-events=enabled
because Xwayland labels its touch device as a pointer.

Before each start (when Chromium isn't running) the launcher also makes Allow
the default VR permission and drops per-site Blocks, so there's no
"Allow VR?" prompt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex SouthwellandClaude Opus 5.5 committed 2026-09-28 11:00:15 +10:00
1 parent ec770bb520
commit 2b21324248
3 files changed
+206 -12

No files matched your search

+23 -4
View File
@@ -41,6 +41,7 @@ SteamVR 2.17.10) with Chromium **156.0.8071.0**, built for arm64:
| three.js [stereo 360 video](https://threejs.org/examples/webxr_vr_video.html) | plays in 3D | | 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; WebXR renders in the headset | | Launch from the Steam library | opens as its own panel, like any app; WebXR renders in the headset |
| Controllers inside WebXR pages | tracked pose every frame; squeeze events and button state reach the page (trigger, thumbstick and left controller not tested) | | Controllers inside WebXR pages | tracked pose every frame; squeeze events and button state reach the page (trigger, thumbstick and left controller not tested) |
| Laser pointer on the browser panel | reaches Chromium as a touchscreen, so trigger-and-drag scrolls the page (set up and checked on the Frame; the drag itself not yet tried in the headset) |
| Frame rate | 72 fps, every frame 13.9–14 ms over 16 s (simple scene); SteamVR dropped frames only at startup | | Frame rate | 72 fps, every frame 13.9–14 ms over 16 s (simple scene); SteamVR dropped frames only at startup |
This is an unofficial, experimental build. See [Limitations](#limitations) This is an unofficial, experimental build. See [Limitations](#limitations)
@@ -112,15 +113,18 @@ Library**, and pick Chromium XR.
the headset. the headset.
2. Go to a WebXR site, for example the 2. Go to a WebXR site, for example the
[WebXR Samples](https://immersive-web.github.io/webxr-samples/). [WebXR Samples](https://immersive-web.github.io/webxr-samples/).
3. Press the site's **Enter VR** button. 3. Press the site's **Enter VR** button. It opens in the headset straight
4. Chromium asks **Allow VR?** in the browser panel. Choose *Allow this time* away: the launcher sets Chromium's VR permission to Allow for every site,
or *Allow while visiting the site*. so there's no **Allow VR?** prompt.
5. To leave VR, use the site's exit button or the Steam button. 4. 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 The first time you launch it, Steam may show an **External Controller
Translation** notice. It's only information about controller button icons; Translation** notice. It's only information about controller button icons;
choose OK. choose OK.
Point at the panel and pull the trigger to click. Hold the trigger and drag
to scroll, as on a touchscreen.
From a terminal on the Frame, `chromium-xr https://example.com` opens a From a terminal on the Frame, `chromium-xr https://example.com` opens a
page directly. page directly.
@@ -140,6 +144,21 @@ page directly.
- **No controller vibration.** SteamVR reports no haptic actuators to the page. - **No controller vibration.** SteamVR reports no haptic actuators to the page.
- **No DRM video.** There's no Widevine, so paid streaming services that - **No DRM video.** There's no Widevine, so paid streaming services that
need it won't play. need it won't play.
- **Its panel isn't Steam's app panel.** gamescope sends the laser to apps
Steam launches as mouse clicks, so dragging would select text. The
launcher therefore runs Chromium outside Steam's process tree, where each
window gets a plain gamescope panel and the laser acts as a touchscreen.
Steam still shows Chromium XR as running, and stopping it there closes
Chromium. Chromium's output goes to the journal
(`journalctl --user -u 'chromium-xr-*'`). Start it with
`CHROMIUM_XR_STEAM_PANEL=1` in the shortcut's launch options (as
`CHROMIUM_XR_STEAM_PANEL=1 %command%`) to keep it in Steam's panel, with
mouse clicks.
- **Every site can start VR.** Each launch sets the VR permission's default
to Allow and removes any per-site Block, so any page can take over the
headset when you press its button (or, on some sites, without one). You
can still block a site in `chrome://settings/content/vr`, but only until
the next launch.
- **One window at a time per profile.** If Chromium XR is already open, - **One window at a time per profile.** If Chromium XR is already open,
launching it again opens the page in the existing window. launching it again opens the page in the existing window.
- **Not a default browser.** It works as one (the desktop entry registers - **Not a default browser.** It works as one (the desktop entry registers
+79 -1
View File
@@ -57,7 +57,9 @@ isn't needed.
## What a working session looks like ## What a working session looks like
- The page's `isSessionSupported("immersive-vr")` resolves `true`. - The page's `isSessionSupported("immersive-vr")` resolves `true`.
- After the **Allow VR?** prompt, `requestSession("immersive-vr")` succeeds. - `requestSession("immersive-vr")` succeeds. (Chromium asks **Allow VR?**
first unless the site's VR permission is Allow; the launcher makes Allow
the default, see below.)
The first frame has a viewer pose with 2 views and a 2880 × 1440 framebuffer The first frame has a viewer pose with 2 views and a 2880 × 1440 framebuffer
(1440 × 1440 per eye). (1440 × 1440 per eye).
- SteamVR's log (`~/.local/share/Steam/logs/vrserver.txt`) shows the app move - SteamVR's log (`~/.local/share/Steam/logs/vrserver.txt`) shows the app move
@@ -91,6 +93,82 @@ Measured with a session that clears to red, with `hand-tracking` and
- Requesting `hand-tracking` makes Chromium ask **Allow hand tracking?** in - Requesting `hand-tracking` makes Chromium ask **Allow hand tracking?** in
the browser panel before the session starts. the browser panel before the session starts.
## The laser pointer on the browser panel (2026-09-28)
gamescope (3.16.28 on the Frame) turns SteamVR's laser events for a panel
into input for the app. A trigger press is `VREvent_MouseButtonDown` with the
left button, which gamescope sends as a Wayland touch, then Xwayland passes
it on as an XInput 2 touch. What happens next depends on gamescope's *touch
click mode* for that panel (`GetTouchClickMode` in
`src/Backends/OpenVRBackend.cpp`):
- **Windows of an app Steam launched** (app id taken from the `STEAM_GAME`
property, the `app-steam-app<id>-<pid>.scope` cgroup, or a
`reaper SteamLaunch AppId=<id>` ancestor): forced to *left click*, in a
code path commented as a workaround for Steam not setting
`STEAM_TOUCH_CLICK_MODE` on the Frame. The touch becomes a left mouse
button, so holding the trigger and dragging selects text.
- **Windows with no app id**: *passthrough*. Each gets its own panel
(`gamescope.gamescope-0.window.<n>`) and receives real touches. The
Desktop Mode panel is one of these, which is why a browser there scrolls
when you drag.
The thumbstick sends `VREvent_ScrollSmooth`, which gamescope turns into mouse
wheel events in either mode.
Chromium registers itself in a systemd scope of its own
(`app-org.chromium.Chromium-<pid>.scope`), so gamescope finds the app id by
walking up to Steam's `reaper`. Forking can't escape that, because `reaper`
is a subreaper. When `SteamAppId` is set, the `chromium-xr` launcher starts
Chromium with `systemd-run --user` instead, and waits for it to exit.
Chromium's parent is then the user's systemd, and its windows get no app id.
Chromium's menus and bubbles are override-redirect windows, which gamescope
shows only over a focused window with the same app id. They come from the
same process, so they get app id 0 as well.
Verified on the Frame (SteamOS build 20260922.6101926), without wearing the
headset:
- Launched from Steam, the old launcher's window had app id 2349681812 and
panel `valve.steam.desktopgame.2349681812`. Setting `STEAM_GAME` to 0 on
it destroyed that panel and created `gamescope.gamescope-0.window.70`.
- With the new launcher, Chromium's parent is `systemd`, not `reaper`. Its
panel is `gamescope.gamescope-0.window.88`, `GAMESCOPE_FOCUSABLE_APPS` is
empty, and `SIGTERM` to the launcher, which is what Steam's Stop does,
closes Chromium and removes the service.
- Xwayland 24.1.9 gives every device the XI1 type `xwayland-pointer`,
including `xwayland-touch`. Chromium's X11 hotplug code only counts
`TOUCHSCREEN` or `xwayland-touch` as a touchscreen, so it reports
`navigator.maxTouchPoints` 0 and leaves out the touch API. Its XI2 touch
handling (`TouchFactory`) still accepts the device's touches. The launcher
passes `--touch-events=enabled`, and pages then see `ontouchstart`.
- Chromium's `--touch-devices=<id>` switch isn't a way around this. It
would make every button of the shared `xwayland-pointer` a touch,
including wheel and right-click.
Not yet checked in the headset: dragging a page, fling, and menus and
permission prompts showing on the new panel.
## The VR permission (2026-09-28)
Chromium's VR permission is the `vr` content setting, default Ask, with
Allow, Ask and Block all valid defaults
(`components/content_settings/core/browser/content_settings_registry.cc`).
No policy or command-line switch sets it, and managed policies would have to
go in `/etc/chromium`, which is on the Frame's read-only root. So before each
start, if Chromium isn't already running (its `SingletonLock` link names a
live process), the launcher sets `profile.default_content_setting_values.vr`
to 1 (Allow) in `Default/Preferences` and deletes `vr` exceptions whose
setting is 2 (Block). **Verified on the Frame:** aframe.io, which had no
saved exception, started an immersive session from `requestSession` with no
prompt, and the value was still 1 after Chromium quit and rewrote the file.
It doesn't cover **Allow hand tracking?** (the `hand_tracking` setting).
The same test showed WebXR still works with Chromium outside Steam's
process tree. The launcher keeps Steam's environment, including
`SteamAppId`, and SteamVR still bound the session to
`steam.app.2349681812`.
## Graphics ## Graphics
Chromium's GPU process uses ANGLE on OpenGL, which runs on zink over the Chromium's GPU process uses ANGLE on OpenGL, which runs on zink over the
+104 -7
View File
@@ -35,10 +35,107 @@ fi
# --disable-seccomp-filter-sandbox: with the XR process's seccomp policy on, # --disable-seccomp-filter-sandbox: with the XR process's seccomp policy on,
# SteamVR sees the wrong process ID and refuses the session (see # SteamVR sees the wrong process ID and refuses the session (see
# docs/technical-notes.md). The namespace sandbox stays on. # docs/technical-notes.md). The namespace sandbox stays on.
exec "$CHROME" \ # --touch-events=enabled: Xwayland labels its touch device as a pointer, so
--user-data-dir="$HOME/.config/chromium-xr" \ # Chromium doesn't offer pages the touch API on its own.
--enable-features=OpenXR \ profile=$HOME/.config/chromium-xr
--ozone-platform=x11 \
--no-first-run --no-default-browser-check --password-store=basic \ # Let every site enter VR without the "Allow VR?" prompt: make Allow the
--disable-seccomp-filter-sandbox \ # default for the VR permission, and drop per-site Block exceptions. There's
"$@" # no policy or switch for this, and Chromium only reads Preferences at start,
# so skip it while Chromium is running. flock stops two launches editing the
# file at once; the running check is inside it, so it's fresh for each edit.
mkdir -p "$profile"
flock "$profile/.chromium-xr-prefs.lock" python3 - "$profile" <<'EOF' ||
import json, os, sys, tempfile
profile = sys.argv[1]
try:
# SingletonLock is a symlink to "<host>-<pid>" while Chromium runs.
os.kill(int(os.readlink(os.path.join(profile, 'SingletonLock')).rsplit('-', 1)[1]), 0)
sys.exit(0) # Chromium is running
except (OSError, ValueError, IndexError):
pass # no lock, or a stale one: not running
path = os.path.join(profile, 'Default', 'Preferences')
try:
with open(path) as f:
prefs = json.load(f)
except FileNotFoundError:
prefs = {}
settings = prefs.setdefault('profile', {})
settings.setdefault('default_content_setting_values', {})['vr'] = 1 # Allow
vr = settings.get('content_settings', {}).get('exceptions', {}).get('vr', {})
for pattern in [p for p, v in vr.items() if isinstance(v, dict) and v.get('setting') == 2]:
del vr[pattern] # Block
os.makedirs(os.path.dirname(path), exist_ok=True)
# Private (0600) and unique, so the file never widens its permissions and two
# launches can't write into each other's copy.
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.Preferences.')
try:
with os.fdopen(fd, 'w') as f:
json.dump(prefs, f)
os.replace(tmp, path)
except BaseException:
os.unlink(tmp)
raise
EOF
echo "chromium-xr: couldn't set the VR permission to Allow" >&2
cmd=("$CHROME"
--user-data-dir="$profile"
--enable-features=OpenXR
--ozone-platform=x11
--no-first-run --no-default-browser-check --password-store=basic
--disable-seccomp-filter-sandbox
--touch-events=enabled
"$@")
# gamescope turns the controller's laser into mouse clicks for apps that Steam
# launched, so dragging selects text instead of scrolling. Windows outside
# Steam's process tree still get their own panel, but the laser reaches them
# as a touchscreen, as in Desktop Mode. So when Steam starts us, run Chromium
# as its own systemd user service and wait for it, which keeps it "running"
# in Steam. CHROMIUM_XR_STEAM_PANEL=1 keeps the old behaviour.
if [[ -z "${SteamAppId:-}" || "${CHROMIUM_XR_STEAM_PANEL:-}" == 1 ]] ||
! command -v systemd-run >/dev/null; then
exec "${cmd[@]}"
fi
# Pass our environment through by name (-E NAME copies its value), minus
# systemd's per-service bookkeeping.
env_args=()
for name in $(compgen -e); do
case $name in
INVOCATION_ID|JOURNAL_STREAM|MANAGERPID|SYSTEMD_EXEC_PID|NOTIFY_SOCKET|MEMORY_PRESSURE_*|LISTEN_*) ;;
*) env_args+=(-E "$name") ;;
esac
done
unit=chromium-xr-$$
# Steam's Stop, or the reaper ending us, closes Chromium too. Set before
# starting it, so a Stop during startup isn't lost (bash runs the trap once
# systemd-run returns).
stopping=
trap 'stopping=1; systemctl --user stop --no-block "$unit" 2>/dev/null' TERM INT HUP
if ! systemd-run --user --quiet --collect --same-dir --unit="$unit" \
--service-type=exec --expand-environment=no "${env_args[@]}" "${cmd[@]}"; then
# A Stop can make systemd-run fail too; don't start Chromium again then.
[[ -z "$stopping" ]] || exit 0
echo "chromium-xr: systemd-run failed; starting in Steam's panel instead" >&2
exec "${cmd[@]}"
fi
# MainPID is 0 once the service has ended (and been collected), e.g. when
# Chromium passed the URL to a window that was already open. A failed query
# is retried rather than taken to mean Chromium has gone.
pid=
for _ in 1 2 3 4 5; do
pid=$(systemctl --user show -p MainPID --value "$unit") && break
pid=
sleep 1
done
if [[ -z "$pid" ]]; then
echo "chromium-xr: can't query $unit; Chromium keeps running, but Steam won't track it" >&2
exit 1
fi
[[ "$pid" != 0 ]] || exit 0
while kill -0 "$pid" 2>/dev/null; do
tail --pid="$pid" -f /dev/null &
wait $! || true
done