mirror of
https://github.com/saphid/chromium-webxr-steam-frame.git
synced 2026-10-06 03:00:17 +02:00
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:
1 parent
ec770bb520
commit
2b21324248
3 files changed
+206
-12
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user