diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 0ce77fcb..4ba6b35a 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -13,6 +13,7 @@ import { resolveLinuxWindowBounds, stopWindowBoundsCapture, } from "../cursor/bounds"; +import { reassertHudOverlayMousePassthrough } from "../../windows"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; @@ -487,11 +488,24 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} throw loadError } - setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) + // The highlight window appearing (even with focusable:false) can corrupt + // the WS_EX_TRANSPARENT flag on the HUD on Windows 11+, breaking hover + // detection until the user moves their mouse over the bar again. + // Re-assert passthrough immediately so click-through is restored at once. + reassertHudOverlayMousePassthrough(); + + const highlightCloseTimer = setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close() + }, 1700) + + highlightWin.on("closed", () => { + clearTimeout(highlightCloseTimer); + // Re-assert once more when the window is actually destroyed so the + // native flag is clean regardless of timing. + reassertHudOverlayMousePassthrough(); + }); - return { success: true } + return { success: true } } catch (error) { console.error('Failed to show source highlight:', error) return { success: false } diff --git a/electron/windows.ts b/electron/windows.ts index f1b448b6..550a4561 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -373,6 +373,7 @@ export function createHudOverlayWindow(): BrowserWindow { skipTaskbar: true, hasShadow: false, show: false, + focusable: false, webPreferences: { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, @@ -513,6 +514,36 @@ export function getHudOverlayWindow(): BrowserWindow | null { return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; } +/** + * Re-initialise the HUD overlay's mouse passthrough state. + * + * On Windows 11+, any new BrowserWindow appearing (even focusable:false ones + * like the source highlight overlay) can silently corrupt the + * WS_EX_TRANSPARENT flag that backs setIgnoreMouseEvents forwarding. Call + * this after any operation that creates or destroys a sibling window so that + * hover detection on the HUD is immediately restored without requiring the + * user to move their mouse over the bar. + */ +export function reassertHudOverlayMousePassthrough(): void { + if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { + return; + } + + const hud = getHudOverlayWindow(); + if (!hud) { + return; + } + + // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully + // re-initialised rather than merely re-asserted in a potentially broken state. + hud.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!hud.isDestroyed()) { + hud.setIgnoreMouseEvents(true, { forward: true }); + } + }, 50); +} + export function createUpdateToastWindow(): BrowserWindow { const initialBounds = getUpdateToastBounds(); const parentWindow = diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 80d9d222..49547069 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -515,6 +515,7 @@ function LaunchWindowContent() {
; webcamPreviewDragStartRef: RefObject; }) { - const anyPopoverOpenRef = useRef(false); const isMouseOverHudRef = useRef(false); + const timeoutRef = useRef(null); useEffect(() => { - anyPopoverOpenRef.current = openId !== null; if (openId !== null) { window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); } else { // Proactively check if we should ignore mouse when popover closes setTimeout(() => { - if (!isMouseOverHudRef.current && !anyPopoverOpenRef.current) { + if (!isMouseOverHudRef.current) { window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); } }, 150); } }, [openId]); + useEffect(() => { + const handleMouseOver = (e: globalThis.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target) return; + const isInteractive = !!target.closest( + ".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]" + ); + + if (isInteractive) { + isMouseOverHudRef.current = true; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + } else { + isMouseOverHudRef.current = false; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 300); + } + }; + + window.addEventListener("mouseover", handleMouseOver); + return () => window.removeEventListener("mouseover", handleMouseOver); + }, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + const beginInteractiveHudAction = useCallback(() => { isMouseOverHudRef.current = true; window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); @@ -39,8 +70,6 @@ export function useLaunchHudInteractionState({ window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); }, []); - const timeoutRef = useRef(null); - const handleHudMouseLeave = useCallback((event: MouseEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { @@ -56,11 +85,8 @@ export function useLaunchHudInteractionState({ !isHudDraggingRef.current && !isWebcamPreviewDraggingRef.current && !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current && - !anyPopoverOpenRef.current + !isMouseOverHudRef.current ) { - // If a popover is open, we can still ignore mouse if the mouse is truly gone, - // but we give a bit more breathing room (the 300ms timeout). window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); } }, 300); diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx index ead6cdc4..55aa5f47 100644 --- a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; interface LaunchPopoverCoordinatorValue { openId: string | null; @@ -22,6 +22,12 @@ export function LaunchPopoverCoordinatorProvider({ children }: { children: React const isOpen = useCallback((id: string) => openId === id, [openId]); + useEffect(() => { + const handleBlur = () => setOpenId(null); + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, []); + const value = useMemo( () => ({ openId, diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index 856de855..be349192 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -80,6 +80,7 @@ export function HudPopover({ {trigger}