From f7b61e19ad64b5d5e4492406287da997d707d6ff Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Sun, 3 May 2026 09:53:59 -0400 Subject: [PATCH] fix(xr): move Enter XR into View menu, kill iwer emulator overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating Enter XR pill on desktop was actually @pmndrs/xr's bundled iwer device emulator, which auto-injects on localhost via the default `emulate: 'metaQuest3'`. It overlapped the toolbar and looked like ours. - Disable the iwer emulator (`emulate: false`) on the shared xrStore so no DOM button is injected. - Move XR entry/exit into the View menu as Enter VR / Enter AR / Exit XR, parallelling the RayTracingSubmenu pattern. Always rendered; disabled when the browser's `navigator.xr.isSessionSupported` says the mode is unavailable, so users can see the capability exists. - Drop the now-orphan EnterXRButton.tsx + its Viewport overlay slot. Side fix: the previous floating button used `useXR()` from @react-three/xr, which only works inside the provider tree — that's the "XR features can only be used inside the component" error desktop users were hitting. The new menu component reads from the support store + `useXRPresenting` hook, both safe outside the tree. --- packages/app/src/components/Header.tsx | 33 +++++++++++ packages/app/src/components/Viewport.tsx | 2 - .../app/src/components/xr/EnterXRButton.tsx | 55 ------------------- packages/app/src/stores/xr-store.ts | 4 ++ 4 files changed, 37 insertions(+), 57 deletions(-) delete mode 100644 packages/app/src/components/xr/EnterXRButton.tsx diff --git a/packages/app/src/components/Header.tsx b/packages/app/src/components/Header.tsx index 9f906fe4..e82fb44d 100644 --- a/packages/app/src/components/Header.tsx +++ b/packages/app/src/components/Header.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { BookOpen } from "@phosphor-icons/react/dist/ssr/BookOpen"; import { Mouse } from "@phosphor-icons/react/dist/ssr/Mouse"; +import { Goggles } from "@phosphor-icons/react/dist/ssr/Goggles"; import { Sparkle } from "@phosphor-icons/react/dist/ssr/Sparkle"; import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight"; import { Export } from "@phosphor-icons/react/dist/ssr/Export"; @@ -37,6 +38,7 @@ import { useAppCommands } from "@/hooks/useAppCommands"; import { COMMAND_ICONS } from "@/lib/command-icons"; import { useCapabilities } from "@/lib/capabilities"; import { useNativeMenu } from "@/hooks/useNativeMenu"; +import { xrStore, useXRPresenting, useXRSupportStore } from "@/stores/xr-store"; // Lazy so the prefs dialog (and its wasm-backed keybinding hook) doesn't // bloat the Header bundle until the user opens it. @@ -307,6 +309,36 @@ function RayTracingSubmenu() { ); } +/** XR entries for the View menu. Always shown — disabled when the browser + * doesn't report support, so users can see the capability exists. */ +function XRMenuItems() { + const vr = useXRSupportStore((s) => s.vr); + const ar = useXRSupportStore((s) => s.ar); + const presenting = useXRPresenting(); + + if (presenting) { + return ( + xrStore.getState().session?.end()} + > + Exit XR + + ); + } + + return ( + <> + xrStore.enterVR()}> + Enter VR + + xrStore.enterAR()}> + Enter AR + + + ); +} + export function Header({ onAboutOpen, onProductOpen, onSave, onOpen, onShareOpen, onVersionHistoryOpen, children }: HeaderProps) { const user = useAuthStore((s) => s.user); const isAnonymous = useAuthStore((s) => s.isAnonymous); @@ -565,6 +597,7 @@ export function Header({ onAboutOpen, onProductOpen, onSave, onOpen, onShareOpen > {t("menu.view.input_preferences")} + diff --git a/packages/app/src/components/Viewport.tsx b/packages/app/src/components/Viewport.tsx index 016ac448..f874e06b 100644 --- a/packages/app/src/components/Viewport.tsx +++ b/packages/app/src/components/Viewport.tsx @@ -7,7 +7,6 @@ import { DrawingView } from "./DrawingView"; import { TriangleInspectionPanel } from "./TriangleInspector"; import { RayTracedViewportOverlay } from "./RayTracedViewport"; import { FollowModeToggle } from "./FollowModeToggle"; -import { EnterXRButton } from "./xr/EnterXRButton"; import { xrStore, useXRPresenting } from "@/stores/xr-store"; import { useUiStore, @@ -350,7 +349,6 @@ export function Viewport() { drags in the gaps; the toggle itself re-enables them. */} {!electronicsActive && (
-
)} diff --git a/packages/app/src/components/xr/EnterXRButton.tsx b/packages/app/src/components/xr/EnterXRButton.tsx deleted file mode 100644 index 4f750f9a..00000000 --- a/packages/app/src/components/xr/EnterXRButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useXR } from "@react-three/xr"; -import { xrStore, useXRSupportStore } from "@/stores/xr-store"; - -/** - * Floating Enter-XR control. Renders nothing if neither VR nor AR is - * supported, so desktop users never see it. - */ -export function EnterXRButton() { - const checked = useXRSupportStore((s) => s.checked); - const vr = useXRSupportStore((s) => s.vr); - const ar = useXRSupportStore((s) => s.ar); - // `mode` becomes a string (e.g. "immersive-vr") while a session is active. - const mode = useXR((s) => s.mode); - - if (!checked || (!vr && !ar)) return null; - const presenting = mode != null; - - if (presenting) { - return ( - - ); - } - - return ( -
- {ar && ( - - )} - {vr && ( - - )} -
- ); -} diff --git a/packages/app/src/stores/xr-store.ts b/packages/app/src/stores/xr-store.ts index 5c5c4892..6885b63e 100644 --- a/packages/app/src/stores/xr-store.ts +++ b/packages/app/src/stores/xr-store.ts @@ -12,6 +12,10 @@ export const xrStore = createXRStore({ hand: true, controller: true, foveation: 0, + // Disable the bundled iwer device emulator. It would otherwise auto-inject a + // floating "Enter XR" DOM button on localhost, which clashes with our + // in-menubar XR entry. Real headsets are unaffected. + emulate: false, }); type SupportState = {