diff --git a/electron/windows.ts b/electron/windows.ts index 687a3a175..0b19d5b98 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -264,7 +264,22 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); - win.setIgnoreMouseEvents(true, { forward: true }); + + // Deliberately NOT born click-through: the renderer asks for it on mount, over + // "hud-overlay-ignore-mouse-events" (`show: false` holds this window back until + // 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. // Keep the recording controls out of the recording (see applyContentProtection). applyContentProtection(win, "HUD"); diff --git a/tests/e2e/windows-native-checklist.spec.ts b/tests/e2e/windows-native-checklist.spec.ts index 9c9c5710e..959b15f1d 100644 --- a/tests/e2e/windows-native-checklist.spec.ts +++ b/tests/e2e/windows-native-checklist.spec.ts @@ -319,4 +319,80 @@ 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. + test("the HUD asks for click-through instead of being born with it", async () => { + const app = await launchApp(); + + try { + const hudWindow = await app.firstWindow({ timeout: 60_000 }); + await hudWindow.waitForLoadState("domcontentloaded"); + await dismissLanguagePrompt(hudWindow); + + // A second window keeps the window list non-empty while the HUD is torn + // down below — emptying it fires window-all-closed, which quits the app. + await hudWindow.getByTestId("launch-source-selector-button").click(); + await app.waitForEvent("window", { + predicate: (w) => w.url().includes("windowType=source-selector"), + timeout: 15_000, + }); + + // Recreate the HUD through the app's own path (second-instance → + // showMainWindow → createHudOverlayWindow) with the native call taped. + // Nothing awaits between the tape going on and the snapshot coming off, + // so no IPC from the renderer can slip into it: what comes back is + // construction, and construction only. + const duringConstruction = await app.evaluate(({ app: electronApp, BrowserWindow }) => { + const tape: unknown[][] = []; + const original = BrowserWindow.prototype.setIgnoreMouseEvents; + globalThis.__hudTape = tape; + globalThis.__hudSetIgnoreMouseEvents = original; + BrowserWindow.prototype.setIgnoreMouseEvents = function patched( + this: InstanceType, + ...args: Parameters + ) { + tape.push(args); + return original.apply(this, args); + }; + + BrowserWindow.getAllWindows() + .find((w) => w.webContents.getURL().includes("windowType=hud-overlay")) + ?.destroy(); + electronApp.emit("second-instance"); + + return tape.slice(); + }); + + expect(duringConstruction).toEqual([]); + + // And the renderer does ask, once it has mounted. + await expect + .poll(() => app.evaluate(() => globalThis.__hudTape ?? []), { timeout: 20_000 }) + .toContainEqual([true, { forward: true }]); + } finally { + await app.evaluate(({ BrowserWindow }) => { + const original = globalThis.__hudSetIgnoreMouseEvents; + if (original) { + BrowserWindow.prototype.setIgnoreMouseEvents = original; + } + globalThis.__hudTape = undefined; + globalThis.__hudSetIgnoreMouseEvents = undefined; + }); + await closeApp(app); + } + }); }); + +declare global { + // Set inside the main process by the click-through test above, read back by a + // second evaluate — the only way to observe calls that land between two of them. + var __hudTape: unknown[][] | undefined; + var __hudSetIgnoreMouseEvents: + | ((ignore: boolean, options?: { forward?: boolean }) => void) + | undefined; +}