Skip to content
Merged
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
17 changes: 16 additions & 1 deletion electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
76 changes: 76 additions & 0 deletions tests/e2e/windows-native-checklist.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof BrowserWindow>,
...args: Parameters<typeof original>
) {
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;
}
Loading