From b448a700790361186f46235195aa96c2642f740c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 13:07:03 +0200 Subject: [PATCH 1/2] fix(hud): give click-through a way out that Windows cannot revoke The HUD asks to be input-transparent on mount, and every route back out -- pointerenter/pointerdown on the bar, pointermove on the root, the popover effect -- needs a DOM mouse event. Chromium delivers none to a window it has made input-transparent, so the only supply was Electron's `{ forward: true }` WH_MOUSE_LL hook: installed unchecked (SetWindowsHookEx's return value is discarded), latched behind `forwarding_mouse_messages_` so it re-arms only after a setIgnoreMouseEvents(false) the renderer can no longer request, and silently revoked by Windows for any callback that overruns LowLevelHooksTimeout -- "there is no way for the application to know whether the hook is removed". One hook that never installs or quietly dies and the HUD is painted, inert, forever, with the tray icon as the only way to quit. That is #266, and #385 after it, on 1.9.5 -- a build that already carries the #266 fix. That fix moved *when* the hook is installed, from construction onto an IPC message, and left the trapdoor exactly where it was: the renderer still cannot ask to leave a state that stops it receiving the event it would have to ask with. So the escape no longer runs on anything Windows can take away. getCursorScreenPoint() is a plain positional read the main process can always make; it is polled only while the window is click-through -- the state the poll exists to escape -- and the window-relative point is pushed to the renderer, which hit-tests it with elementFromPoint().closest("[data-hud-interactive]"), the same predicate handleRootPointerMove already used against the same layout. Every tick re-derives the answer from scratch, so no dropped message, dead hook or stale flag can strand it. `forward` is gone, and the e2e test now pins it off rather than pinning it on. The point is deduped window-relative rather than by cursor position, because "hud-overlay-set-size" re-anchors the window on every content change: the bar can arrive under a cursor that never moved, and that changes the answer too. Verified against the built app, driving the real main process and moving the window under a stationary cursor rather than the mouse: tape after mount: [[true]] tape with the empty reserve under the cursor: [[true]] tape after placing the bar under the cursor: [[true],[false]] -- entered with no `forward` argument, held click-through over the transparent reserve so desktop clicks still pass through, and released it with no pointer event of any kind. The new unit test fails on the unpatched renderer. Not addressed here, and reported separately: the opaque black surround. On anything below Windows 11 22H2, Electron 41's setContentProtection(true) runs `SetLayered()` -- WS_EX_LAYERED with SetLayeredWindowAttributes and UpdateLayeredWindow never called. Removing it would put the HUD back into recordings, which is a product call, not a bug fix. Fixes #385 --- electron/electron-env.d.ts | 3 + electron/preload.ts | 5 ++ electron/windows.ts | 84 ++++++++++++++++++--- src/components/launch/LaunchWindow.test.tsx | 50 ++++++++++++ src/components/launch/LaunchWindow.tsx | 19 +++++ tests/e2e/windows-native-checklist.spec.ts | 18 +++-- 6 files changed, 161 insertions(+), 18 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9e1fb7d0d..789eb7e59 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -395,6 +395,9 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; + /** Window-relative cursor position, pushed while the HUD is click-through and + * therefore receiving no pointer events of its own. Returns an unsubscribe. */ + onHudOverlayCursor: (callback: (x: number, y: number) => void) => () => void; /** Pins the overlay's current position as the origin for `dragHudOverlayTo`. */ beginHudOverlayDrag: () => void; /** Total pointer travel since `beginHudOverlayDrag`, not a per-frame delta. */ diff --git a/electron/preload.ts b/electron/preload.ts index 8e018ed8e..66fe2af66 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -80,6 +80,11 @@ contextBridge.exposeInMainWorld("electronAPI", { setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); }, + onHudOverlayCursor: (callback: (x: number, y: number) => void) => { + const listener = (_e: Electron.IpcRendererEvent, x: number, y: number) => callback(x, y); + ipcRenderer.on("hud-overlay-cursor", listener); + return () => ipcRenderer.removeListener("hud-overlay-cursor", listener); + }, beginHudOverlayDrag: () => { ipcRenderer.send("hud-overlay-drag-start"); }, diff --git a/electron/windows.ts b/electron/windows.ts index 0b19d5b98..4b5ceb7fe 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -104,9 +104,75 @@ ipcMain.on("hud-overlay-hide", () => { } }); +// The cursor, sampled here and pushed to the renderer, because while the HUD is +// click-through nothing else can tell it where the pointer is. +// +// Chromium delivers no pointer event of any kind to a window it has made +// input-transparent — including the pointermove the renderer needs to ask for input +// back. Electron's `{ forward: true }` covered that with a global WH_MOUSE_LL hook +// that re-posts WM_MOUSEMOVE, and that hook was the ONLY route out: its install is +// unchecked (SetWindowsHookEx's return value is discarded), latched behind Electron's +// `forwarding_mouse_messages_` so it re-arms only after a setIgnoreMouseEvents(false) +// the renderer can no longer request, and Windows silently revokes any low-level hook +// whose callback overruns LowLevelHooksTimeout — "there is no way for the application +// to know whether the hook is removed". One hook that never installs or quietly dies +// and the HUD is painted, inert, forever, with the tray icon as the only way to quit +// the app. That is issue #266, and issue #385 after it: #266 was closed by moving +// *when* the hook is installed, which left the trapdoor exactly where it was. +// +// So the escape no longer runs on anything Windows can take away. getCursorScreenPoint +// is a plain positional read the main process can always make, the poll exists only +// while the window is click-through — the state it is there to escape — and the +// renderer re-derives the answer from scratch on every tick, so no dropped message, +// dead hook or stale flag can strand it. +const HUD_CURSOR_POLL_MS = 32; +let hudCursorPoll: ReturnType | null = null; +let hudLastPoint: { x: number; y: number } | null = null; + +function stopHudCursorPoll() { + if (hudCursorPoll) clearInterval(hudCursorPoll); + hudCursorPoll = null; + hudLastPoint = null; +} + +function pollHudCursor() { + const win = hudOverlayWindow; + if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return; + + // getBounds() and getCursorScreenPoint() are both in DIP, and so is a renderer CSS + // pixel (the HUD is frameless, so the client area is the whole window). + const bounds = win.getBounds(); + const cursor = screen.getCursorScreenPoint(); + const x = cursor.x - bounds.x; + const y = cursor.y - bounds.y; + if (x < 0 || y < 0 || x >= bounds.width || y >= bounds.height) return; + + // Deduped on the WINDOW-RELATIVE point, not the cursor: "hud-overlay-set-size" + // re-anchors the window on every content change, so the bar can arrive under a + // cursor that never moved — and that changes the answer just as much. + if (hudLastPoint && hudLastPoint.x === x && hudLastPoint.y === y) return; + hudLastPoint = { x, y }; + + win.webContents.send("hud-overlay-cursor", x, y); +} + ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true }); + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + // No `forward`: the poll above replaces it, and leaving it on would keep the app + // depending on a hook it cannot check for a transition it no longer needs. + hudOverlayWindow.setIgnoreMouseEvents(ignore); + + if (!ignore) { + // Input is live again; the document's own pointer events are cheaper and + // finer-grained than anything sampled at 32 ms. + stopHudCursorPoll(); + return; + } + if (!hudCursorPoll) { + hudCursorPoll = setInterval(pollHudCursor, HUD_CURSOR_POLL_MS); } }); @@ -270,16 +336,9 @@ export function createHudOverlayWindow(): BrowserWindow { // ready-to-show, so the two are ~85 ms apart — measured, not assumed). What that // leaves open is an invisible rectangle that can swallow one desktop click in // those 85 ms, right after the user launched the app — against what doing it here - // cost them: the whole app (issue #266). On Windows the `forward` option is a global - // WH_MOUSE_LL hook, and that hook is the only way out of the state, because - // Chromium sends no pointermove to a window it has made input-transparent — so - // the renderer can never ask to leave it on its own. Electron latches - // the install behind `forwarding_mouse_messages_` and retries only after a - // setIgnoreMouseEvents(false) — the very call a dead hook prevents. One refused - // or revoked hook (Windows drops any whose callback overruns the 300 ms - // LowLevelHooksTimeout — on this thread, still busy booting the app) and the HUD - // is painted, inert, forever. Asking later moves the install onto an IPC message, - // i.e. onto a main thread that is provably pumping. + // cost them: the whole app (issue #266). A window nothing ever asks for — a + // renderer that dies before mount — then stays clickable instead of becoming a + // ghost. See the "hud-overlay-cursor" poll above for the way back out. // Keep the recording controls out of the recording (see applyContentProtection). applyContentProtection(win, "HUD"); @@ -307,6 +366,7 @@ export function createHudOverlayWindow(): BrowserWindow { if (hudOverlayWindow === win) { hudOverlayWindow = null; hudDragOrigin = null; + stopHudCursorPoll(); } }); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 76b6a6fe9..1b0e31101 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -63,6 +63,7 @@ const recorderState = vi.hoisted(() => ({ }, })); +let hudCursorListeners: Array<(x: number, y: number) => void> = []; let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; let sourceSelectorClosedListeners: Array<() => void> = []; @@ -209,6 +210,12 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo })), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), + onHudOverlayCursor: vi.fn((callback) => { + hudCursorListeners.push(callback); + return () => { + hudCursorListeners = hudCursorListeners.filter((listener) => listener !== callback); + }; + }), beginHudOverlayDrag: vi.fn(), dragHudOverlayTo: vi.fn(), endHudOverlayDrag: vi.fn(), @@ -273,6 +280,7 @@ function resetLaunchMocks() { recorderState.value.webcamEnabled = false; recorderState.value.setWebcamEnabled.mockClear(); micDevicesState.value = []; + hudCursorListeners = []; selectedSourceChangedListeners = []; sourceSelectorClosedListeners = []; i18nState.value.systemLocaleSuggestion = null; @@ -409,6 +417,48 @@ describe("LaunchWindow record button", () => { expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); }); + // The #385 regression, and #266 before it. A HUD that has gone click-through + // receives no pointer event of any kind, so every DOM route back — pointerenter, + // pointerdown, pointermove — is unreachable by construction. This test therefore + // fires NO pointer events at all: it delivers only the cursor position the main + // process pushes, which is the one signal that survives input-transparency, and + // requires that to be enough to make the bar clickable again. + it("leaves click-through on a pushed cursor position alone, with no pointer event", async () => { + platformState.value = "win32"; + + renderLaunchWindow(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(true); + }); + expect(hudCursorListeners).not.toHaveLength(0); + + // jsdom has no layout and does not implement elementFromPoint at all, so it is + // defined here to return what a point over the bar resolves to in a browser. The + // assertion is that the pushed cursor drives the hit test, not that jsdom can hit-test. + const bar = document.querySelector("[data-hud-interactive='true']"); + expect(bar).not.toBeNull(); + const elementFromPoint = vi.fn(() => bar); + Object.defineProperty(document, "elementFromPoint", { + value: elementFromPoint, + configurable: true, + }); + + try { + for (const listener of hudCursorListeners) listener(410, 540); + + expect(elementFromPoint).toHaveBeenCalledWith(410, 540); + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + + // And a point over the transparent reserve must NOT claim the window back. + elementFromPoint.mockReturnValue(document.body); + for (const listener of hudCursorListeners) listener(10, 10); + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + } finally { + Reflect.deleteProperty(document, "elementFromPoint"); + } + }); + it("keeps the HUD interactive on Linux so the drag handle can receive pointer events", async () => { platformState.value = "linux"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 71d9eda27..7639936f6 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -420,6 +420,25 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isPopoverOpen); }, [isPopoverOpen, setHudMouseEventsEnabled]); + // The way back out of click-through. Every other route below — pointerenter and + // pointerdown on the bar, pointermove on the root — needs an event this document + // stops receiving the moment the window goes input-transparent, which is what left + // the HUD painted and permanently dead in #266 and again in #385. So the main + // process samples the OS cursor and pushes it here instead, and the hit test is the + // one `handleRootPointerMove` already runs, against the same layout: elementFromPoint + // honours pointer-events, so a point over the transparent reserve resolves to the + // root and correctly stays click-through. + // + // Only ever turns click-through OFF. Turning it back on is the DOM handlers' job, + // and they are reliable by then — the window is receiving real input again. + useEffect(() => { + return window.electronAPI?.onHudOverlayCursor?.((x, y) => { + if (document.elementFromPoint(x, y)?.closest("[data-hud-interactive='true']")) { + setHudMouseEventsEnabled(true); + } + }); + }, [setHudMouseEventsEnabled]); + const defaultSourceName = t("sourceSelector.defaultSourceName"); const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index fcda17a2a..22b378196 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -321,11 +321,17 @@ test.describe("Windows native checklist smoke tests", () => { }); // The HUD must reach click-through by *asking* for it from the renderer, never - // by being born that way. On Windows the `forward` option is a global - // WH_MOUSE_LL hook, and it is the only route back out: a HUD that is already - // input-transparent when the hook fails to install can never be clicked again, - // which is what bricked the app in issue #266. Both halves matter — that nothing - // asks during construction, and that the renderer still does after mount. + // by being born that way: a window born input-transparent whose renderer never + // mounts can never be clicked again, which is what bricked the app in issue #266. + // Both halves matter — that nothing asks during construction, and that the + // renderer still does after mount. + // + // The second assertion also pins `forward` OFF. It used to be the only route back + // out of click-through, via a global WH_MOUSE_LL hook that Windows can refuse or + // silently revoke — which is how #385 reproduced a dead HUD on a build that already + // carried the #266 fix. The way out is now the "hud-overlay-cursor" poll in + // electron/windows.ts, and asking for `forward` again would restore the dependency + // without restoring the need. // // Note what this test therefore cannot do, and what no test in this file can. // Only a real OS cursor move drives a WH_MOUSE_LL hook; CDP-injected input @@ -383,7 +389,7 @@ test.describe("Windows native checklist smoke tests", () => { // And the renderer does ask, once it has mounted. await expect .poll(() => app.evaluate(() => globalThis.__hudTape ?? []), { timeout: 20_000 }) - .toContainEqual([true, { forward: true }]); + .toContainEqual([true]); } finally { await app.evaluate(({ BrowserWindow }) => { const original = globalThis.__hudSetIgnoreMouseEvents; From a4a164db9089f49d6d57a1c0d9a72aa59b87fcfa Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 18 Aug 2026 13:28:47 +0200 Subject: [PATCH 2/2] test(hud): make the click-through negative case able to fail CodeRabbit was right on both counts. The "transparent reserve must not claim the window back" assertion ran AFTER the bar had already claimed it, which made it vacuous: the renderer dedupes on hudIgnoreMouseEventsRef, so a point that wrongly enabled input would have sent no IPC at all and "still false" held either way. Moved it before the bar, while the window is still click-through -- there a wrong answer IS an IPC, so the assertion can fail. Confirmed by mutation: dropping the closest() guard from the cursor handler now fails with "expected vi.fn() to not be called at all, but actually been called 1 times", where before it passed. Adds the unmount test AGENTS.md asks for -- the effect returns the unsubscribe handed back by onHudOverlayCursor, and nothing covered it. Also mutation-checked: dropping the `return` fails with "expected [ [Function] ] to have a length of +0". No production change. --- src/components/launch/LaunchWindow.test.tsx | 38 ++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 1b0e31101..3fdb7caae 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -434,31 +434,51 @@ describe("LaunchWindow record button", () => { expect(hudCursorListeners).not.toHaveLength(0); // jsdom has no layout and does not implement elementFromPoint at all, so it is - // defined here to return what a point over the bar resolves to in a browser. The - // assertion is that the pushed cursor drives the hit test, not that jsdom can hit-test. + // defined here to return what each point resolves to in a browser. The assertion + // is that the pushed cursor drives the hit test, not that jsdom can hit-test. const bar = document.querySelector("[data-hud-interactive='true']"); expect(bar).not.toBeNull(); - const elementFromPoint = vi.fn(() => bar); + const elementFromPoint = vi.fn((_x: number, _y: number): Element | null => document.body); Object.defineProperty(document, "elementFromPoint", { value: elementFromPoint, configurable: true, }); + const setIgnore = vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents); try { + // The transparent reserve goes FIRST, while the window is still click-through. + // Do it after the bar has claimed input back and the assertion is vacuous: the + // renderer dedupes, so a point that wrongly enabled input would send no IPC at + // all and "still false" would hold either way. Here a wrong answer is an IPC. + setIgnore.mockClear(); + for (const listener of hudCursorListeners) listener(10, 10); + expect(setIgnore).not.toHaveBeenCalled(); + + // And the bar hands input back. + elementFromPoint.mockReturnValue(bar); for (const listener of hudCursorListeners) listener(410, 540); expect(elementFromPoint).toHaveBeenCalledWith(410, 540); - expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); - - // And a point over the transparent reserve must NOT claim the window back. - elementFromPoint.mockReturnValue(document.body); - for (const listener of hudCursorListeners) listener(10, 10); - expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(false); + expect(setIgnore).toHaveBeenCalledWith(false); } finally { Reflect.deleteProperty(document, "elementFromPoint"); } }); + it("unsubscribes from the pushed cursor when the HUD unmounts", async () => { + platformState.value = "win32"; + + const { unmount } = renderLaunchWindow(); + + await waitFor(() => { + expect(hudCursorListeners).not.toHaveLength(0); + }); + + unmount(); + + expect(hudCursorListeners).toHaveLength(0); + }); + it("keeps the HUD interactive on Linux so the drag handle can receive pointer events", async () => { platformState.value = "linux";