diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e45788cadb..ea1961f88b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9140,6 +9140,7 @@ function startHudCursorFeed(win: BrowserWindow) { 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. diff --git a/apps/desktop/electron/window-below.test.ts b/apps/desktop/electron/window-below.test.ts index 581e71705a..4719940a42 100644 --- a/apps/desktop/electron/window-below.test.ts +++ b/apps/desktop/electron/window-below.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { type EnumeratedWindow, pickWindowBelow } from './window-below' +import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow } from './window-below' const win = (pid: number, x = 0, y = 0, width = 800, height = 600, app = `app-${pid}`): EnumeratedWindow => ({ app, @@ -84,3 +84,36 @@ describe('pickWindowBelow', () => { expect(below).toBeNull() }) }) + +describe('enumerationFailureNote', () => { + it('tells a Wayland user the session is the problem', () => { + for (const env of [{ XDG_SESSION_TYPE: 'wayland' }, { WAYLAND_DISPLAY: 'wayland-0' }]) { + expect(enumerationFailureNote('linux', env)).toMatch(/Wayland/) + } + }) + + it('tells an X11 user which commands are missing', () => { + const note = enumerationFailureNote('linux', { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' }) + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/Wayland/) + }) + + // XWayland can still answer through xprop, so the fix is the tooling, not + // switching session type. + it('treats Wayland with an X display as X11', () => { + const note = enumerationFailureNote('linux', { WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' }) + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/Wayland/) + }) + + it('does not offer Linux advice on other platforms', () => { + for (const platform of ['darwin', 'win32']) { + const note = enumerationFailureNote(platform, {}) + + expect(note).not.toMatch(/xprop|Wayland/) + expect(note.length).toBeGreaterThan(0) + } + }) +}) diff --git a/apps/desktop/electron/window-below.ts b/apps/desktop/electron/window-below.ts index 7c9e0f3ce4..5b9dff48ab 100644 --- a/apps/desktop/electron/window-below.ts +++ b/apps/desktop/electron/window-below.ts @@ -4,7 +4,8 @@ // `window.read.request` from the gateway, asks main over IPC, and answers // with this module's serialized result. Enumeration uses `get-windows` // (front-to-back z-order on macOS/Windows/Linux-X11); the picking logic is a -// pure function so the OS-specific part stays a thin provider. +// pure function so the OS-specific part stays a thin provider. Where that +// provider can't run at all, the answer is why — see `enumerationFailureNote`. // // Privacy contract (matches the tool schema): metadata only — app, title, // bounds. Never pixels. On macOS, window titles require the Screen Recording @@ -31,6 +32,45 @@ export interface WindowBelowResult { } | null } +export interface WindowBelowUnavailable { + error: string + platform: string +} + +/** + * Why enumeration just failed, in terms the user can act on. + * + * The generic "could not determine the window underneath" this replaces is a + * dead end on Linux, where the two ways it fails have opposite fixes and + * neither is guessable: a Wayland session withholds window identity from + * applications outright, and an X11 session needs `xprop`/`xwininfo` present + * because that is what the enumerator shells out to. + * + * A session with both `WAYLAND_DISPLAY` and `DISPLAY` is Wayland running + * XWayland, where `xprop` can still answer — so it is treated as X11 and gets + * the tooling advice rather than being told to change session type. + */ +export function enumerationFailureNote(platform: string, env: NodeJS.ProcessEnv): string { + if (platform !== 'linux') { + return 'Could not enumerate windows on this system.' + } + + const wayland = env.XDG_SESSION_TYPE === 'wayland' || (Boolean(env.WAYLAND_DISPLAY) && !env.DISPLAY) + + if (wayland) { + return ( + 'Could not enumerate windows: this is a Wayland session, and Wayland does ' + + 'not let an application see other applications\u2019 windows. Log in to an ' + + 'X11/Xorg session, or run Hermes under XWayland with DISPLAY set.' + ) + } + + return ( + 'Could not enumerate windows: this needs the xprop and xwininfo commands ' + + '(the x11-utils package on Debian/Ubuntu, xorg-x11-utils on Fedora).' + ) +} + const overlaps = (a: EnumeratedWindow['bounds'], b: EnumeratedWindow['bounds']): boolean => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height @@ -81,15 +121,21 @@ const loadGetWindows = (): Promise => { * Enumerate windows and serialize the one underneath `selfBounds`. * * `titlesAvailable` is the macOS Screen Recording grant (pass true on other - * platforms, where titles are free). Returns null only when enumeration - * itself is unavailable (Wayland, missing xprop, addon load failure) — the - * caller turns that into an empty tool answer. + * platforms, where titles are free). When enumeration itself is unavailable + * (Wayland, missing xprop, addon load failure) this answers with the reason + * rather than nothing, so the agent can tell the user what to fix instead of + * reporting a blank failure. */ export async function readWindowBelow( selfPid: number, selfBounds: EnumeratedWindow['bounds'], titlesAvailable: boolean -): Promise { +): Promise { + const unavailable = (): WindowBelowUnavailable => ({ + error: enumerationFailureNote(process.platform, process.env), + platform: process.platform + }) + let raw try { @@ -100,11 +146,11 @@ export async function readWindowBelow( : undefined ) } catch { - return null + return unavailable() } if (!Array.isArray(raw)) { - return null + return unavailable() } // get-windows documents openWindows() as front-to-back, and macOS/Windows diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index c35c107278..78c5364449 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -158,9 +158,9 @@ export { bindGeometryPersistence, computeWindowOptions, debounce, - GEOMETRY_EVENTS, DEFAULT_HEIGHT, DEFAULT_WIDTH, + GEOMETRY_EVENTS, MIN_HEIGHT, MIN_VISIBLE, MIN_WIDTH, diff --git a/tools/read_window_tool.py b/tools/read_window_tool.py index 2db6252b93..c5d3156db3 100644 --- a/tools/read_window_tool.py +++ b/tools/read_window_tool.py @@ -50,6 +50,9 @@ READ_WINDOW_BELOW_SCHEMA = { "withholds window titles (e.g. macOS without the Screen Recording " "permission — never prompted for, noted in `note`). Other Hermes " "windows are skipped: the nearest non-Hermes window is reported. " + "Returns {error, platform} instead where the OS cannot enumerate " + "windows at all (e.g. a Wayland session); `error` says what would fix " + "it, so relay it rather than retrying. " "Metadata only; this never captures pixels or content of other windows." ), "parameters": {