Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions electron/ipc/register/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
Expand Down
31 changes: 31 additions & 0 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
Expand Down
44 changes: 35 additions & 9 deletions src/components/launch/hooks/useLaunchHudInteractionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)) {
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion src/components/launch/popovers/LaunchPopoverCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/components/launch/popovers/PopoverScaffold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down