From da933bf279ba1c2985aa4df2c3910ad958633679 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 22:15:24 -0500 Subject: [PATCH] fix(desktop): keep the HUD clickable on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click-through decides whether to swallow the mouse by hit-testing the document under the cursor, and it learns where the cursor is from mousemove. Those keep arriving while the window ignores the mouse only because of `setIgnoreMouseEvents(true, { forward: true })`, and `forward` is `@platform darwin,win32`. On Linux the moves stop the instant the HUD turns click-through, so it never sees the pointer return to the bar: the bar is visible, and clicking it hits whatever is behind. Main can still see the cursor, so on Linux it polls and pushes the position to the renderer, which runs its usual hit test on it. The decision and its rules stay in one place — only the courier for that one input changes — and off-window is sent as null, which is already how the renderer hands the mouse back. --- apps/desktop/electron/hud-cursor.test.ts | 50 +++++++++++++++++ apps/desktop/electron/hud-cursor.ts | 65 +++++++++++++++++++++++ apps/desktop/electron/main.ts | 57 ++++++++++++++++++++ apps/desktop/electron/preload.ts | 10 ++++ apps/desktop/src/app/hud/click-through.ts | 16 +++++- apps/desktop/src/global.d.ts | 1 + 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/hud-cursor.test.ts create mode 100644 apps/desktop/electron/hud-cursor.ts diff --git a/apps/desktop/electron/hud-cursor.test.ts b/apps/desktop/electron/hud-cursor.test.ts new file mode 100644 index 0000000000000..9eb0b5fdd92d0 --- /dev/null +++ b/apps/desktop/electron/hud-cursor.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for the HUD's Linux cursor conversion. These cover the two things + * that decide whether the bar is clickable: landing on the right point, and + * admitting when the cursor has left. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { cursorPointInWindow } from './hud-cursor' + +// A HUD parked 300px right and 200px down, 620×320 as it is created. +const BOUNDS = { x: 300, y: 200, width: 620, height: 320 } + +test('screen cursor becomes a point relative to the window', () => { + assert.deepEqual(cursorPointInWindow({ x: 300, y: 200 }, BOUNDS, 1), { x: 0, y: 0 }) + assert.deepEqual(cursorPointInWindow({ x: 460, y: 240 }, BOUNDS, 1), { x: 160, y: 40 }) +}) + +test('a zoomed page reports CSS pixels, not device-independent ones', () => { + // At 0.9 the page is wider than the window in CSS pixels, so a point half way + // across the window is further than half way across the document. + assert.deepEqual(cursorPointInWindow({ x: 610, y: 380 }, BOUNDS, 0.9), { + x: 310 / 0.9, + y: 180 / 0.9 + }) +}) + +test('a cursor outside the window is reported as lost', () => { + for (const outside of [ + { x: 299, y: 300 }, + { x: 500, y: 199 }, + { x: 920, y: 300 }, + { x: 500, y: 520 } + ]) { + assert.equal(cursorPointInWindow(outside, BOUNDS, 1), null) + } +}) + +test('the far edges belong to the window on entry, not on exit', () => { + assert.deepEqual(cursorPointInWindow({ x: 919, y: 519 }, BOUNDS, 1), { x: 619, y: 319 }) + assert.equal(cursorPointInWindow({ x: 920, y: 520 }, BOUNDS, 1), null) +}) + +test('a nonsense zoom factor is treated as unzoomed rather than poisoning the point', () => { + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.deepEqual(cursorPointInWindow({ x: 460, y: 240 }, BOUNDS, bad), { x: 160, y: 40 }) + } +}) diff --git a/apps/desktop/electron/hud-cursor.ts b/apps/desktop/electron/hud-cursor.ts new file mode 100644 index 0000000000000..a37c7d5a1cab0 --- /dev/null +++ b/apps/desktop/electron/hud-cursor.ts @@ -0,0 +1,65 @@ +/** + * Cursor→page coordinate math for the HUD's Linux click-through fallback. + * + * The HUD decides whether to swallow the mouse by hit-testing the document + * under the cursor, which needs a cursor position in CSS pixels. On macOS and + * Windows that arrives for free: `setIgnoreMouseEvents(true, { forward: true })` + * keeps mousemove flowing to the page even while the window is ignoring, so the + * renderer can always see where the pointer is and re-arm when it returns to + * the bar. `forward` is `@platform darwin,win32`. On Linux the moves stop the + * instant the window starts ignoring, the renderer's last known point freezes, + * and the HUD can never decide to be solid again — the bar goes permanently + * untouchable. + * + * Main can still see the cursor, so on Linux it polls and pushes the position + * in. This is the conversion that push needs, kept pure and separate because + * the two unit mismatches in it are exactly what silently makes a hit test miss + * by a hand's width. + */ + +interface Point { + x: number + y: number +} + +interface Bounds { + x: number + y: number + width: number + height: number +} + +/** + * Screen-space cursor → the window's CSS pixel coordinates, or null when the + * cursor is outside the window. + * + * Two conversions, both load-bearing: + * + * - Screen to window: Electron reports the cursor in screen space and the + * window has its own origin, so the window's position has to come off + * first. + * - DIP to CSS: `getCursorScreenPoint()` and `getBounds()` both speak + * device-independent pixels, and `elementFromPoint` speaks CSS pixels. Those + * are the same number only at zoom 1. Anyone who has pressed ⌘− is reading + * a smaller page than the window is wide, and an unscaled point lands + * progressively further from the cursor the further it is from the origin. + * + * Returning null outside the window matters as much as the arithmetic: it is + * the renderer's signal that it has lost the cursor, which is what hands the + * window back to the app underneath instead of leaving it solid on a stale + * point. + */ +export function cursorPointInWindow(cursor: Point, bounds: Bounds, zoomFactor: number): Point | null { + const dx = cursor.x - bounds.x + const dy = cursor.y - bounds.y + + if (dx < 0 || dy < 0 || dx >= bounds.width || dy >= bounds.height) { + return null + } + + // A zero or bogus factor would divide the point into infinity; treat anything + // non-positive as unzoomed rather than poisoning the hit test. + const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1 + + return { x: dx / scale, y: dy / scale } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a1d3b705d2e4d..e45788cadb261 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -134,6 +134,7 @@ import { resolveTimeoutMs, TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' +import { cursorPointInWindow } from './hud-cursor' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { @@ -9101,6 +9102,60 @@ function persistHudState() { const schedulePersistHudState = debounce(persistHudState, 250) +// How often Linux gets told where the cursor is. Fast enough that the bar is +// solid before a click lands after the pointer arrives, cheap enough to leave +// running for as long as the HUD is open — it is one `getCursorScreenPoint()` +// and, when the answer has not changed, nothing else. +const HUD_CURSOR_POLL_MS = 60 + +/** + * Feed the HUD renderer the cursor position on Linux. + * + * Everywhere else the renderer learns this from mousemove, which keeps arriving + * while the window ignores the mouse because we pass `{ forward: true }`. That + * option is macOS/Windows only. Without it a Linux HUD stops hearing the + * pointer the moment it turns click-through, so it can never notice the pointer + * coming back and stays transparent — the bar is there, and clicking it hits + * whatever is behind. Main can still see the cursor, so it says so. + * + * Deliberately the same decision, just a different source for one input: the + * renderer runs its usual hit test on the point it is handed. Re-deciding + * anything here would put a second, drifting copy of the click-through rules in + * the main process. + */ +function startHudCursorFeed(win: BrowserWindow) { + if (process.platform !== 'linux') { + return + } + + let last: string | null = null + + const timer = setInterval(() => { + if (win.isDestroyed() || !win.isVisible()) { + return + } + + const point = cursorPointInWindow( + screen.getCursorScreenPoint(), + win.getBounds(), + win.webContents.getZoomFactor() + ) + // Off-window is a real answer (it is what hands the mouse back), so it is + // sent — once. Only an unchanged answer is dropped, to keep an idle cursor + // from waking the renderer 16 times a second. + const key = point ? `${Math.round(point.x)},${Math.round(point.y)}` : 'out' + + if (key === last) { + return + } + + last = key + win.webContents.send('hermes:hud:cursor', point) + }, HUD_CURSOR_POLL_MS) + + win.on('closed', () => clearInterval(timer)) +} + function hudBounds() { // Remembered spot first — validated against the LIVE displays so a HUD // parked on an unplugged monitor comes back on-screen instead of lost. @@ -9223,6 +9278,8 @@ function spawnHudWindow(sessionId) { // times mid-drag). bindGeometryPersistence(win, schedulePersistHudState) + startHudCursorFeed(win) + wireWindowReveal(win, { show: () => { win.show() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 792ee07ba05f3..0f18998117172 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -69,6 +69,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:hud:changed', listener) return () => ipcRenderer.removeListener('hermes:hud:changed', listener) + }, + // Linux only, and silent elsewhere: where the cursor is, in page + // coordinates, or null when it has left the window. Stands in for the + // mousemove that `setIgnoreMouseEvents(true, { forward: true })` delivers on + // macOS and Windows but not here. + onCursor: callback => { + const listener = (_event, point) => callback(point) + ipcRenderer.on('hermes:hud:cursor', listener) + + return () => ipcRenderer.removeListener('hermes:hud:cursor', listener) } }, // Quick Entry: the global-hotkey mini composer window. Main owns the OS diff --git a/apps/desktop/src/app/hud/click-through.ts b/apps/desktop/src/app/hud/click-through.ts index 9990658e2b55c..9ab21e5c3eeaa 100644 --- a/apps/desktop/src/app/hud/click-through.ts +++ b/apps/desktop/src/app/hud/click-through.ts @@ -58,7 +58,9 @@ export function hudIgnoresMouse( * interactive: wherever the cursor is over something the HUD paints, and * whenever a portalled overlay holds focus. `forward: true` keeps mousemove * flowing while ignoring, which is what lets it re-arm when the cursor comes - * back to the bar. + * back to the bar. That option is macOS/Windows only, so on Linux main polls + * the cursor and pushes it in through `onCursor` — the same point, the same + * decision, a different courier. * * It follows that nothing in HUD mode may declare `-webkit-app-region: drag` * at all: a draggable region swallows the page's mouse events, so the moves @@ -102,6 +104,17 @@ export function useHudClickThrough(rootRef: RefObject): void apply() } + // Linux's stand-in for mousemove, pushed from main because `forward` is not + // supported there and the moves stop the moment the window starts ignoring + // — leaving the bar permanently click-through. Same decision, same hit + // test; only where the point came from differs, and on macOS and Windows + // this never fires. `null` is the cursor leaving the window, which is the + // `onLost` answer. + const offCursor = window.hermesDesktop?.hud?.onCursor?.(next => { + point = next + apply() + }) + // Whenever we stop knowing where the cursor is, hand the window back. Solid // is only ever right under a cursor we can still see: the last point we saw // is usually the bar, and holding that answer means the whole rectangle @@ -123,6 +136,7 @@ export function useHudClickThrough(rootRef: RefObject): void return () => { setIgnoreMouse(false) + offCursor?.() window.removeEventListener('mousemove', onMove) window.removeEventListener('blur', onLost) window.removeEventListener('focus', apply) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 7a7ba89b84e2f..c03c37b1df0e4 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -75,6 +75,7 @@ declare global { setSession: (sessionId: null | string) => void onGoto: (callback: (sessionId: string) => void) => () => void onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void + onCursor: (callback: (point: { x: number; y: number } | null) => void) => () => void } // Quick Entry: a global-hotkey mini composer window. Main owns the OS // shortcut registration + the persisted preference (it must restore the