diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts index 847d013c36..d06add9069 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts @@ -48,6 +48,7 @@ describe('loadIWERIfNeeded', () => { } else { delete (navigator as Navigator & { xr?: XRSystem }).xr; } + delete testWindow.xrDevice; delete testWindow.IWER; delete testWindow.IWER_DevUI; jest.restoreAllMocks(); @@ -79,7 +80,9 @@ describe('loadIWERIfNeeded', () => { const device = { installDevUI: jest.fn(), - installRuntime: jest.fn(), + installRuntime: jest.fn(() => { + isSessionSupported.mockResolvedValue(true); + }), }; testWindow.IWER = { XRDevice: jest.fn(() => device), @@ -107,6 +110,38 @@ describe('loadIWERIfNeeded', () => { expect(appendChild).toHaveBeenCalledTimes(2); }); + it('rejects a skipped runtime installation and allows a later retry', async () => { + const nativeXR = { isSessionSupported: jest.fn().mockResolvedValue(false) }; + Object.defineProperty(navigator, 'xr', { configurable: true, value: nativeXR }); + const device = { installRuntime: jest.fn() }; + testWindow.IWER = { XRDevice: jest.fn(() => device), metaQuest3: {} }; + jest.spyOn(document.head, 'appendChild').mockImplementation((node: T): T => { + if (node instanceof HTMLScriptElement) { + queueMicrotask(() => node.onload?.(new Event('load'))); + } + return node; + }); + + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: false, + iwerLoaded: false, + }); + expect(window.xrDevice).toBeUndefined(); + expect(navigator.xr).toBe(nativeXR); + + device.installRuntime.mockImplementation(() => { + Object.defineProperty(navigator, 'xr', { + configurable: true, + value: { isSessionSupported: jest.fn().mockResolvedValue(true) }, + }); + }); + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: true, + iwerLoaded: true, + }); + expect(window.xrDevice).toBe(device); + }); + it('shares one IWER installation across concurrent callers', async () => { let appendedScripts = 0; let constructedDevices = 0; @@ -116,6 +151,10 @@ describe('loadIWERIfNeeded', () => { installDevUI: jest.fn(), installRuntime: jest.fn(() => { runtimeInstalls++; + Object.defineProperty(navigator, 'xr', { + configurable: true, + value: { isSessionSupported: jest.fn().mockResolvedValue(true) }, + }); }), }; testWindow.IWER = { diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index a3fd119618..aba354de5d 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -24,8 +24,8 @@ declare global { } // Both versions are loaded from CDN; bump them together when upgrading IWER. -const IWER_version = '2.3.0'; -const IWER_DEVUI_version = '2.3.0'; +const IWER_version = '2.7.0'; +const IWER_DEVUI_version = '2.7.0'; let pendingIWERLoad: Promise | null = null; export interface IWERLoadResult { @@ -65,7 +65,7 @@ async function loadIWER(): Promise { const script = document.createElement('script'); script.src = `https://unpkg.com/iwer@${IWER_version}/build/iwer.min.js`; script.async = true; - script.integrity = 'sha384-m8Xcl9WwdP6j/5Fv7MAs7IvvW3PmWCbNZlbF8IsJAnujyeMZi0/xgUE4mM7Tbm/j'; + script.integrity = 'sha384-pcGii0Tzet8SSL0v/VOhYIzm2dynTF4sEDPQxzsbepa8wICqZkNQvx3xuRRjj6Lc'; script.crossOrigin = 'anonymous'; await new Promise(resolve => { @@ -84,7 +84,7 @@ async function loadIWER(): Promise { devUIScript.src = `https://unpkg.com/@iwer/devui@${IWER_DEVUI_version}/build/iwer-devui.min.js`; devUIScript.async = true; devUIScript.integrity = - 'sha384-TAu5gWlv92O6gkVclV8HYctTEswyA/0eoS6ULHjjLCShUVts4kDlUg6iLEKmL1r8'; + 'sha384-9jc9tp/DypE1hvhqLzE0xVpXYx64QqIRMDu6f3FkSIaeTqpwniy+t1ierx3y428X'; devUIScript.crossOrigin = 'anonymous'; await new Promise(devUIResolve => { @@ -110,17 +110,22 @@ async function loadIWER(): Promise { console.warn('IWER DevUI not found after script load, continuing without DevUI.'); } - // This fallback runs only after the native XRSystem reports no immersive - // support. IWER 2.3 otherwise preserves that unusable navigator.xr. + // Only override the native runtime after it reports no immersive support. await device.installRuntime({ forceInstall: true }); - window.xrDevice = device; - supportsImmersive = true; - iwerLoaded = true; - // The only log that's both unconditional (fires whether or not DevUI loaded, unlike - // "IWER DevUI initialized...") and guaranteed to follow installRuntime() actually - // succeeding (unlike "IWER DevUI initialized...", which logs before this await) - the - // one a caller (e.g. a test) should wait on to know navigator.xr is truly usable. - console.info('IWER runtime installed.'); + // installRuntime can return without replacing navigator.xr; do not + // enable Connect until the installed runtime confirms session support. + const [vr, ar] = await Promise.all([ + navigator.xr?.isSessionSupported('immersive-vr'), + navigator.xr?.isSessionSupported('immersive-ar'), + ]); + supportsImmersive = Boolean(vr || ar); + if (supportsImmersive) { + window.xrDevice = device; + iwerLoaded = true; + console.info('IWER runtime installed.'); + } else { + console.warn('IWER runtime installed without immersive session support.'); + } } catch (e) { console.warn('IWER runtime install failed:', e); supportsImmersive = false; diff --git a/deps/cloudxr/webxr_client/package.json b/deps/cloudxr/webxr_client/package.json index fff83564ee..3afbf6e1b2 100644 --- a/deps/cloudxr/webxr_client/package.json +++ b/deps/cloudxr/webxr_client/package.json @@ -83,7 +83,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "html-webpack-plugin": "^5.6.7", - "iwer": "^2.3.0", + "iwer": "^2.7.0", "jest": "^30", "jest-environment-jsdom": "^30.4.1", "prettier": "3.5.3",