Electron + WebXR

Bringing WebXR apps to native platforms

James Baicoianu, Apr 26 2021

Now that WebXR has been shipping in Chrome for a few releases, and after some conversations with others in the WebXR community, it seemed like it was time to revisit the idea of bundling WebXR apps with Electron so they can be distributed on the Oculus and Steam stores. Official Electron builds don't yet support WebXR, but they're using a Chromium version that should. So I decided to see what I could do, and the XREngine team graciously offerred to sponsor my efforts.

The Journey

The first thing I noticed is that Electron is explicitly disabling the checkout_webxr flag in the DEPS file, which is where it defines the compile-time options which affect the Chromium build process. Since none of the other WebXR backends are enabled either, this ends up causing enable_vr to be set to false. Seems like a simple fix - we can set 'checkout_webxr': True in the DEPS file, and rebuild. Here's what that changeset looks like:
GitHub diff of Electron patch

Half a day later, we've got a build of Electron which has successfully linked against the OpenXR library. Great! We're all set now, right?

No, of course not. It's never that easy.

WebXR supported, but no devices found

I expected that I'd just load this up, point my Electron app at some WebXR content, and everything would just work. Instead, I ended up with a build which reports that WebXR is available and ready to use, but no matter what I did, it just refused to show any devices. Chrome, Edge, Brave - they all work just fine out of the box these days, it's a thing of wonder - as long as your headset supports OpenXR (Rift, Quest+Link, Vive, Index, and many others - even Varjo just announced support in the past week) then it should show up and be usable by any WebXR experience, with a single click. But with our builds? Nothing.

So what was different? Procmon showed that my own builds never even tried to read the OpenXR registry settings, while official Chrome builds did that pretty much as soon as I loaded some WebXR content and called navigator.xr.isSessionSupported('immersive-vr'). Something was failing pretty early on in the process. Time to bust out the debugger.

Setting up the dev environment

Setting up a compile environment for Chromium was enough of a pain. For anyone who's interested, I followed the Electron build instructions - we're dealing with Windows here, but it's the same process in Linux or OSX. I ran into several problems along the way, mainly to do with needing to install the right versions of dependencies like python and nodejs, setting up paths, uninstalling GTA V to free up enough disk space, closing Chrome to free up enough memory for the linker, etc. It took me a couple days to get my system to the point where I could reliably compile from beginning to end, which takes about 5-6 hours on my system. It took another couple days for me to figure out how to get the debugger set up with symbols from my build - the Chromium debugging on Windows instructions proved useful here. Once the symbols are loaded, there's some extra work needed to attach our debugger to the child processes, and finally our breakpoints are being hit!

isSessionSupported breakpoint in VSCode

But tracing through the code still doesn't really reveal why we aren't seeing any VR devices. I can step in to see that it's calling the right functions, it's hitting our OpenXR codepath in isSessionSupported(), but it's still just immediately reporting no headsets found. This is when I started to suspect something else was preventing the WebXR codepaths from activating properly. It struck me as strange that the only browsers I could get WebXR to work in were "official" releases - Chrome, Brave, and Edge, and in the case of Brave and Chromium, when I compiled them myself or downloaded nightlies, WebXR wouldn't work with them either. I started wondering if there was some code signing aspect, a difference between official and unofficial builds which was preventing unofficial builds from accessing all the features - mostly this applies to things like proprietary codecs, DRM, etc, so it didn't really make sense for OpenXR, but was there some other dependency I wasn't aware of?

The Breakthrough

But then suddenly, something unexpected happened. I was trying some new debug arguments to make the process of attaching chromium processes to the debugger easier, when I realized - my "Enter VR" button had suddenly become active!

The VR button awakens

In disbelief, I clicked it, and sure enough, Oculus' software started up, I put on my Rift, and I was in the virtual world! It was running uncomfortably slow, around 2fps, but this was a huge clue! I tried the same arguments on a build without debug symbols, and everything worked perfectly at full framerate. Success! But why had it suddenly started working, was this reproducible?

It works!

The Sandbox

It turns out that the new debug process I was following had instructed me to add --no-sandbox to the arguments which are passed when the Chromium process is launched. So I verified a few times - with sandbox enabled (the default) I couldn't see any VR headsets, whereas with the sandbox disabled I could detect and display to them. I now had VR working in my Electron app at 90fps! Excellent!

Only, disabling the sandbox isn't really acceptable for a production app. Chromium's sandbox is an essential tool for keeping your system secure from malicious code running on random websites you might visit, so disabling the sandbox is generally considered a bad idea. But still, it's was an important clue. The code was working just fine, but for some reason the official browser releases were able to cross the boundary from the sandbox to the OpenXR runtime, while my own builds were not. I know OpenXR just recently added support for the XR_EXT_win32_appcontainer_compatible extension which is necessary to work with Chrome's sandbox, but the official release runtimes were able to use it, so the version of the OpenXR runtime I had installed clearly must be implementing that extension. Something ELSE is preventing my appcontainer from accessing the OpenXR runtime. But what?

Reaching Out

At this point I was stumped, so I turned to Twitter. I reached out to the one person I knew could help me, the person I knew with the most experience working with Chromium at that level - @Tojiro. If he didn't know what was going on, I couldn't think of anyone else who would.

He got back to me the next morning.

Oof

"Oof." He didn't know what to tell me, it should just work! Devastating. I started to collect more debug info about my system - runtime versions, device info, did it work with other headsets? Felt like I was back to square one.

But wait!

Hey

He asked around at Google, and someone told him something interesting. Apparently Chrome's installer - and presumably Brave's and Edge's - set some ACLs on the install directory which allow Chrome to talk to the OpenXR runtime. And there's a script called run_xr_browser_tests.py which is used by the automated test runner which can do this automatically! Yes! Finally, we're on to something! It finally makes sense why everything seemed ok in the codebase and in the debugger - the code and my system were both fine, but the OS was blocking our access!

I pulled these magical keys out of the script he referenced, and then hurried to figure out how to manually set ACLs in Windows. That looks like this:

icacls . /grant *S-1-15-3-1024-2302894289-466761758-1166120688-1039016420-2430351297-4240214049-4028510897-3317428798:(OI)(CI)(RX)
icacls . /grant *S-1-15-3-1024-3424233489-972189580-2057154623-747635277-1604371224-316187997-3786583170-1043257646:(OI)(CI)(RX)

Obviously. I mean, how could I have missed this, right? 🤣

Once these ACLs were set, I removed --no-sandbox from the app launch arguments, fired it up...and it worked perfectly! My headset was detected and displaying to it worked seamlessly, without having to sacrifice the security of the sandbox. Sweet relief!

Findings

So to summarize what we need to do to get a working Electron build with WebXR support, it boils down to two steps:

I'm hopeful that we can get the first of these changes merged upstream, but there's probably some discussion that needs to happen with the Electron team. I've been told that there are concerns with bundle size when linking with OpenXR which is a feature that relatively few Electron apps will use, so it's worth doing the leg work here to see how much of an impact that actually has.

For the second part of this, we'll need a better way for users - and developers - to handle the ACLs. For browsers that ship official release builds, it makes sense that they'd have signed installers which can set the directory's permissions at install time, and never have to worry about it again. For smaller projects and for developers though, it's something you'll have to remember to do any time you run an Electron app from a new location.

One way of handling this is to make your Electron app check at launch whether the ACLs have been set for the directory, and if not, set them. A basic version of this might look something like the following code. Keep in mind that this ends up being a blocking operation on first run, and can take a while if you have a lot of files in your working directory, so it might be worthwhile to communicate to the user that something is happening via a splash screen or status message.

const ACL_STRINGS = [
  'S-1-15-3-1024-2302894289-466761758-1166120688-1039016420-2430351297-4240214049-4028510897-3317428798:(OI)(CI)(RX)',
  'S-1-15-3-1024-3424233489-972189580-2057154623-747635277-1604371224-316187997-3786583170-1043257646:(OI)(CI)(RX)',
];

function initACLs() {
  return new Promise(resolve => {
    if (process.platform == 'win32') {
      // Check working directory's existing ACLs against our list of ACL strings
      child_process.exec('icacls .', null, (err, output) => {
        let existing_acls = output.toString();
        let missing_acls = ACL_STRINGS.filter(acl => existing_acls.indexOf(acl) == -1);
        if (missing_acls.length > 0) {
          // ACLs not found, set them now
          let cmd = 'icacls .';
          missing_acls.forEach(acl => cmd += ` /grant *${acl}`);
          child_process.exec(cmd, null, resolve);
        } else {
          // ACLs already set, continue
          resolve();
        }
      });
    } else {
      // Not on Windows, nothing to do!
      resolve();
    }
  });
}



initACLs().then(() => {
  // Launch your app here
});

Next Steps

So, we've now got a nice standalone app that launches into our 3d experience. The user can click a button to activate VR at any time, and our app can be experienced on any desktop-connected VR headset that supports OpenXR. Our app can be distributed and run without needing any app store approval - no 30% cut, no gatekeepers, we can push updates on our own schedule and we can target an audience as niche or as widespread as we want. I'd like to see more software like this, which just treats VR and AR as an upgraded immersive mode you can drop in and out of at any time, rather than as an all-in kind of experience - casually integrating XR as a powerful tool in our productivity and entertainment pipelines. These XR devices should be tools that build upon our existing abilities, rather than trying to replace what we already do today with something new.

JanusXR Electron App 2D mode

Unfortunately, this isn't the mental model that these platforms have been built for, so that's not necessarily how people expect to use their devices. Generally speaking, if someone wants to load something on their Rift, their Index, or their Quest, each platform provides its own VR shell, and users put their headset on and get dropped into their platform's app store / library / home experience, which gives them an in-VR way of picking which experience to launch. Currently, our app doesn't work that way - Chromium's WebXR implementation requires users to perform an explicit user gesture like clicking a button to enter VR, and these VR shells don't yet seamlessly handle 2d apps launched from the app store, so when we launch our app the 2d client appears on our monitor, but nothing happens in VR. We'll need to figure out how to automatically start a WebXR session at launch.

I'll be covering that work in a follow-up article - as with anything involving the Chromium codebase, it's trickier than it sounds!

-- @bai0