diff --git a/README.md b/README.md index 753e091..e59b813 100644 --- a/README.md +++ b/README.md @@ -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 | | 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) | +| 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 | This is an unofficial, experimental build. See [Limitations](#limitations) @@ -112,15 +113,18 @@ Library**, and pick Chromium XR. 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. +3. Press the site's **Enter VR** button. It opens in the headset straight + away: the launcher sets Chromium's VR permission to Allow for every site, + so there's no **Allow VR?** prompt. +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 Translation** notice. It's only information about controller button icons; 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 page directly. @@ -140,6 +144,21 @@ page directly. - **No controller vibration.** SteamVR reports no haptic actuators to the page. - **No DRM video.** There's no Widevine, so paid streaming services that 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, launching it again opens the page in the existing window. - **Not a default browser.** It works as one (the desktop entry registers diff --git a/docs/technical-notes.md b/docs/technical-notes.md index c7b5514..c583734 100644 --- a/docs/technical-notes.md +++ b/docs/technical-notes.md @@ -57,7 +57,9 @@ 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. +- `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 (1440 × 1440 per eye). - 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 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-.scope` cgroup, or a + `reaper SteamLaunch AppId=` 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.`) 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-.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=` 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 Chromium's GPU process uses ANGLE on OpenGL, which runs on zink over the diff --git a/frame/chromium-xr b/frame/chromium-xr index 53ea623..bd05599 100755 --- a/frame/chromium-xr +++ b/frame/chromium-xr @@ -35,10 +35,107 @@ fi # --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 \ - "$@" +# --touch-events=enabled: Xwayland labels its touch device as a pointer, so +# Chromium doesn't offer pages the touch API on its own. +profile=$HOME/.config/chromium-xr + +# Let every site enter VR without the "Allow VR?" prompt: make Allow the +# 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 "-" 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