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..3fdb7caae 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,68 @@ 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 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((_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(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"; 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;