Merge pull request #486 from ExtraBinoss/fix/mouse-passthrough

fix: Mouse passthrough bug, v 1.3.0beta
This commit is contained in:
webadderall
2026-05-11 18:53:19 +10:00
committed by GitHub
6 changed files with 93 additions and 14 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 =
+1
View File
@@ -515,6 +515,7 @@ function LaunchWindowContent() {
<div
ref={recordingWebcamPreviewContainerRef}
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag} pointer-events-auto`}
data-hud-interactive
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
@@ -11,23 +11,54 @@ export function useLaunchHudInteractionState({
isWebcamPreviewDraggingRef: RefObject<boolean>;
webcamPreviewDragStartRef: RefObject<unknown>;
}) {
const anyPopoverOpenRef = useRef(false);
const isMouseOverHudRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(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<NodeJS.Timeout | null>(null);
const handleHudMouseLeave = useCallback((event: MouseEvent<HTMLDivElement>) => {
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);
@@ -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,
@@ -80,6 +80,7 @@ export function HudPopover({
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
data-hud-interactive
unstyled
side="top"
align={align}