From 56937f97e6de98baa35400da8f7b549d9e5c1824 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 09:25:39 +0200 Subject: [PATCH 1/3] fix(hud): stop being born click-through, wait for the renderer to ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD asked to be input-transparent twice: once here, at construction, and once from the renderer's mount effect. The first one is the whole bug in #266 — the app running, the bar painted, and every click, drag and button dead, forever, from the very first launch. On Windows `setIgnoreMouseEvents(true, { forward: true })` is a global WH_MOUSE_LL hook, and that hook is the only route back: Chromium delivers no pointermove to a window it has made input-transparent, so the renderer cannot ask to leave the state it is stuck in. Electron latches the install behind `forwarding_mouse_messages_` and only retries after a setIgnoreMouseEvents(false) — the call the dead hook prevents. So one hook that is refused, or that Windows revokes for overrunning the 300 ms LowLevelHooksTimeout, bricks the UI with no way out. Construction time is the worst possible moment to ask for it: that hook callback runs on the main thread, which is still booting the app. The renderer asks a frame or two later, over IPC, on a thread that is provably pumping messages — and if that ask never comes, the bar stays clickable instead of turning into a ghost. Costs an invisible rectangle that can swallow one desktop click in the two frames between show and mount. Refs #266. --- electron/windows.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/electron/windows.ts b/electron/windows.ts index 687a3a175..d6926f7a6 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", within a frame or two of the first paint + // (`show: false` holds this window back until ready-to-show). What that leaves + // open is an invisible rectangle that swallows a desktop click for those two + // frames, 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"); From 14ba51b98a162429e28fab386b2535a1236fe177 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 09:46:38 +0200 Subject: [PATCH 2/3] test(hud): pin that click-through is asked for, never born with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves, because both are load-bearing after #266: nothing may call setIgnoreMouseEvents while the HUD window is being constructed, and the renderer must still ask for it once it has mounted. The window is recreated through the app's own path (second-instance → showMainWindow → createHudOverlayWindow) with the native call taped, and the tape is read back synchronously — no await between arming it and snapshotting, so no renderer IPC can slip into what is meant to be construction only. The source selector is opened first purely to keep the window list non-empty while the HUD is destroyed: emptying it fires window-all-closed, which quits the app under the test. Ablated: restoring the deleted line turns duringConstruction into [[true, {forward: true}]] and the first assertion fails, so the test does cover the regression it claims to. --- tests/e2e/windows-native-checklist.spec.ts | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) 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; +} From 4c1e14a45bafdca3f56c532c9aaf1a453ffbcdd3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 10:22:16 +0200 Subject: [PATCH 3/3] docs(hud): replace the guessed exposure window with a measured one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "A frame or two" was an assumption. Timing the real app from ready-to-show to the renderer's first hud-overlay-ignore-mouse-events puts it at 83 and 90 ms over two clean runs — roughly forty times what the comment claimed, and the number a reviewer should be weighing against #266. (A third run read -403 ms: the tape catches setIgnoreMouseEvents on the prototype, so an IPC still in flight from the destroyed HUD's renderer lands on the new window and dates the ask before the window exists. The probe was throwaway; the two clean runs are the ones quoted.) --- electron/windows.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/electron/windows.ts b/electron/windows.ts index d6926f7a6..0b19d5b98 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -266,11 +266,11 @@ export function createHudOverlayWindow(): BrowserWindow { }); // Deliberately NOT born click-through: the renderer asks for it on mount, over - // "hud-overlay-ignore-mouse-events", within a frame or two of the first paint - // (`show: false` holds this window back until ready-to-show). What that leaves - // open is an invisible rectangle that swallows a desktop click for those two - // frames, 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 + // "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