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
3 changes: 3 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand Down
84 changes: 72 additions & 12 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | 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);
}
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -307,6 +366,7 @@ export function createHudOverlayWindow(): BrowserWindow {
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
hudDragOrigin = null;
stopHudCursorPoll();
}
});

Expand Down
70 changes: 70 additions & 0 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const recorderState = vi.hoisted(() => ({
},
}));

let hudCursorListeners: Array<(x: number, y: number) => void> = [];
let selectedSourceChangedListeners: SelectedSourceChangedListener[] = [];
let sourceSelectorClosedListeners: Array<() => void> = [];

Expand Down Expand Up @@ -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);
};
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
beginHudOverlayDrag: vi.fn(),
dragHudOverlayTo: vi.fn(),
endHudOverlayDrag: vi.fn(),
Expand Down Expand Up @@ -273,6 +280,7 @@ function resetLaunchMocks() {
recorderState.value.webcamEnabled = false;
recorderState.value.setWebcamEnabled.mockClear();
micDevicesState.value = [];
hudCursorListeners = [];
selectedSourceChangedListeners = [];
sourceSelectorClosedListeners = [];
i18nState.value.systemLocaleSuggestion = null;
Expand Down Expand Up @@ -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";

Expand Down
19 changes: 19 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 12 additions & 6 deletions tests/e2e/windows-native-checklist.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading