fix: mouse passthrough on windows and other oses

This commit is contained in:
Alan Trebugeais
2026-05-09 23:42:49 +02:00
parent f6a40d7324
commit 13e3d2bc24
4 changed files with 90 additions and 12 deletions
+18 -4
View File
@@ -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();
return { success: true }
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 }
} catch (error) {
console.error('Failed to show source highlight:', error)
return { success: false }
+31
View File
@@ -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 =
@@ -11,23 +11,53 @@ export function useLaunchHudInteractionState({
isWebcamPreviewDraggingRef: RefObject<boolean>;
webcamPreviewDragStartRef: RefObject<unknown>;
}) {
const anyPopoverOpenRef = useRef(false);
const isMouseOverHudRef = useRef(false);
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-radix-popper-content-wrapper], [class*="menuCard"], [class*="recordingWebcamPreview"]'
);
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);
@@ -56,11 +86,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);
@@ -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,