fix(desktop): say why read_window_below cannot see the windows

When enumeration was impossible the tool answered "could not determine the
window underneath (the desktop app did not answer, or window enumeration is
unavailable on this system)" — true, and a dead end. On Linux the two ways it
fails have opposite fixes and neither is guessable from that: a Wayland session
withholds window identity from applications outright, while an X11 session
needs xprop and xwininfo installed, because that is what the enumerator shells
out to.

Answer with the reason instead of nothing. A session with both WAYLAND_DISPLAY
and DISPLAY is XWayland, where xprop can still answer, so it gets the tooling
advice rather than being told to change session type.
This commit is contained in:
Brooklyn Nicholson
2026-08-08 22:17:38 -05:00
parent da933bf279
commit 04afc8d48e
5 changed files with 92 additions and 9 deletions

View File

@@ -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.

View File

@@ -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)
}
})
})

View File

@@ -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<GetWindowsModule> => {
* 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<WindowBelowResult | null> {
): Promise<WindowBelowResult | WindowBelowUnavailable> {
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

View File

@@ -158,9 +158,9 @@ export {
bindGeometryPersistence,
computeWindowOptions,
debounce,
GEOMETRY_EVENTS,
DEFAULT_HEIGHT,
DEFAULT_WIDTH,
GEOMETRY_EVENTS,
MIN_HEIGHT,
MIN_VISIBLE,
MIN_WIDTH,

View File

@@ -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": {