5ef1409f50
`read_window_below` answers "could not enumerate windows on this system" on macOS and Windows whatever went wrong, and the three failure paths behind it discarded their errors — so a report where the HUD could see nothing had no way to distinguish the module failing to load, the helper failing to spawn, and the OS answering with nothing. Three different fixes, one sentence. Enumeration now returns the reason, the tool's error carries it, and the HUD's game-overlay watch logs it once before it gives up (it retries twice and then goes quiet forever, which is the other half of why the log said nothing). Linux keeps its environment-derived advice, which is more actionable than the raw exception.
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
// window-below.ts — which OS window sits directly underneath a Hermes window.
|
|
//
|
|
// Backs the desktop-gated `read_window_below` tool: the renderer receives
|
|
// `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. 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
|
|
// permission; we pass titles through only when that permission is ALREADY
|
|
// granted and never trigger the prompt for it.
|
|
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import { app } from 'electron'
|
|
|
|
import { readHyprlandWindows } from './hyprland'
|
|
|
|
export interface EnumeratedWindow {
|
|
app: string
|
|
bounds: { x: number; y: number; width: number; height: number }
|
|
id: number
|
|
pid: number
|
|
title: string
|
|
}
|
|
|
|
export interface WindowBelowResult {
|
|
frontmost: { app: string; title: string } | null
|
|
note?: string
|
|
platform: string
|
|
window: {
|
|
app: string
|
|
bounds: { x: number; y: number; width: number; height: number }
|
|
id: number
|
|
title: string
|
|
} | 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.
|
|
*
|
|
* macOS and Windows have no environmental fork like that, so their note is
|
|
* whatever the enumerator actually said. That `detail` is the whole point: a
|
|
* bare "could not enumerate windows on this system" is what a real report came
|
|
* back with (macOS 26, packaged app), and neither the tool result nor the
|
|
* desktop log said whether the module failed to load, the helper failed to
|
|
* spawn, or the OS answered with nothing — three failures with three different
|
|
* fixes, all collapsed into one sentence.
|
|
*/
|
|
export function enumerationFailureNote(platform: string, env: NodeJS.ProcessEnv, detail?: string): string {
|
|
if (platform !== 'linux') {
|
|
return detail ? `Could not enumerate windows: ${detail}` : 'Could not enumerate windows on this system.'
|
|
}
|
|
|
|
// Hyprland is asked over its own IPC, so reaching here means the socket
|
|
// didn't answer — telling a Hyprland user to go and install xprop, or to
|
|
// abandon Wayland, would send them in exactly the wrong direction.
|
|
if (env.HYPRLAND_INSTANCE_SIGNATURE) {
|
|
return (
|
|
'Could not enumerate windows: Hyprland did not answer on its IPC socket. ' +
|
|
'Check that `hyprctl clients` works from the same session Hermes is ' +
|
|
'running in.'
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
/**
|
|
* Pick the window directly underneath ours from a front-to-back window list.
|
|
*
|
|
* Walks past every window owned by our own process (all Hermes windows share
|
|
* the main process pid), then takes the first other-process window whose
|
|
* bounds overlap ours — "underneath" means visually behind, not merely next
|
|
* in z-order on some other display. `frontmost` is the first other-process
|
|
* window regardless of overlap: the app the user was last working in.
|
|
*/
|
|
export function pickWindowBelow(
|
|
windows: EnumeratedWindow[],
|
|
selfPid: number,
|
|
selfBounds: EnumeratedWindow['bounds']
|
|
): { below: EnumeratedWindow | null; frontmost: EnumeratedWindow | null } {
|
|
const others = windows.filter(w => w.pid !== selfPid)
|
|
const frontmost = others[0] ?? null
|
|
|
|
const selfIndex = windows.findIndex(w => w.pid === selfPid)
|
|
const behind = selfIndex === -1 ? others : windows.slice(selfIndex + 1)
|
|
const below = behind.find(w => w.pid !== selfPid && overlaps(w.bounds, selfBounds)) ?? null
|
|
|
|
return { below, frontmost }
|
|
}
|
|
|
|
type GetWindowsModule = {
|
|
openWindows: (options?: { accessibilityPermission?: boolean; screenRecordingPermission?: boolean }) => Promise<
|
|
Array<{
|
|
bounds?: { height?: number; width?: number; x?: number; y?: number }
|
|
id?: number
|
|
owner?: { name?: string; processId?: number }
|
|
title?: string
|
|
}>
|
|
>
|
|
}
|
|
|
|
/** Enumeration couldn't run at all, and why. Distinct from an empty list,
|
|
* which is a real answer meaning "nothing else is on screen". */
|
|
export interface EnumerationFailure {
|
|
reason: string
|
|
}
|
|
|
|
export const enumerationFailed = <T>(result: EnumerationFailure | T): result is EnumerationFailure =>
|
|
typeof result === 'object' && result !== null && 'reason' in result
|
|
|
|
const describeError = (error: unknown): string =>
|
|
error instanceof Error ? error.message : String(error ?? 'unknown error')
|
|
|
|
let getWindowsModule: Promise<GetWindowsModule | EnumerationFailure> | null = null
|
|
|
|
const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
|
|
// get-windows is an optionalDependency: `npm ci` can skip it when its native
|
|
// install fails, including Linux and Windows ARM64 where 9.3.0 has no
|
|
// prebuilt. A missing module is therefore a normal state on those targets,
|
|
// so the lazy import resolves to null instead of rejecting; enumeration then
|
|
// degrades to the failure note instead of an uncaught error.
|
|
//
|
|
// The STAGED copy is tried first, and it is not a dev-only nicety.
|
|
// `import('get-windows')` resolves out of node_modules, whose lib/windows.js
|
|
// locates its binding through `preGyp.find()` — by HOST platform. When the
|
|
// tree was installed on a different OS than Electron is running on (a
|
|
// WSL-hosted dev run driving a win32 Electron is the everyday case here),
|
|
// pre-gyp picks the host's slot, ignores the win32 binding sitting beside it,
|
|
// and upstream's fail-soft path hands back no-op stubs. Enumeration then
|
|
// reports "unavailable" on a machine that answers perfectly well, which is
|
|
// what silently disabled both read_window_below and the HUD's game overlay.
|
|
// scripts/stage-native-deps.mjs writes a staged lib/windows.js that requires
|
|
// the binding directly, so it is the more reliable of the two everywhere.
|
|
getWindowsModule ??= (async () => {
|
|
const staged = path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
|
|
let stagedError = 'not staged in this build'
|
|
|
|
if (fs.existsSync(staged)) {
|
|
try {
|
|
return (await import(pathToFileURL(staged).href)) as GetWindowsModule
|
|
} catch (error) {
|
|
stagedError = describeError(error)
|
|
}
|
|
}
|
|
|
|
try {
|
|
return (await import('get-windows')) as GetWindowsModule
|
|
} catch (error) {
|
|
return {
|
|
reason:
|
|
'the get-windows module could not be loaded ' +
|
|
`(staged copy: ${stagedError}; node_modules copy: ${describeError(error)})`
|
|
}
|
|
}
|
|
})()
|
|
|
|
return getWindowsModule
|
|
}
|
|
|
|
/**
|
|
* Every window `get-windows` can see, front-to-back, or why it could not look.
|
|
*
|
|
* `titlesAvailable` is the macOS Screen Recording grant (pass true on other
|
|
* platforms, where titles are free). The three ways this can fail — the module
|
|
* not loading, the enumerator throwing, the enumerator answering with
|
|
* something that isn't a list — each say so, because they have three different
|
|
* fixes and the caller has no other way to tell them apart.
|
|
*/
|
|
async function enumerateViaGetWindows(titlesAvailable: boolean): Promise<EnumeratedWindow[] | EnumerationFailure> {
|
|
const getWindows = await loadGetWindows()
|
|
|
|
if (enumerationFailed(getWindows)) {
|
|
return getWindows
|
|
}
|
|
|
|
let raw
|
|
|
|
try {
|
|
raw = await getWindows.openWindows(
|
|
process.platform === 'darwin'
|
|
? { accessibilityPermission: false, screenRecordingPermission: titlesAvailable }
|
|
: undefined
|
|
)
|
|
} catch (error) {
|
|
// On macOS this is the helper binary failing to spawn — a missing or
|
|
// non-executable `main`, or the OS refusing to run it — which is invisible
|
|
// from the outside and used to surface as the generic note.
|
|
return { reason: `the window enumerator failed: ${describeError(error)}` }
|
|
}
|
|
|
|
if (!Array.isArray(raw)) {
|
|
return { reason: 'the window enumerator returned no window list' }
|
|
}
|
|
|
|
// get-windows documents openWindows() as front-to-back, and macOS/Windows
|
|
// honor that (CGWindowList / EnumWindows order). Its lib/linux.js, however,
|
|
// iterates `_NET_CLIENT_LIST_STACKING` in raw xprop order, which EWMH
|
|
// defines as bottom-to-top — so the Linux list arrives back-to-front and
|
|
// must be reversed to match. (Verified against get-windows 9.3.0.)
|
|
const ordered = process.platform === 'linux' ? [...raw].reverse() : raw
|
|
|
|
return ordered.map(w => ({
|
|
app: w.owner?.name ?? '',
|
|
bounds: {
|
|
x: w.bounds?.x ?? 0,
|
|
y: w.bounds?.y ?? 0,
|
|
width: w.bounds?.width ?? 0,
|
|
height: w.bounds?.height ?? 0
|
|
},
|
|
id: w.id ?? 0,
|
|
pid: w.owner?.processId ?? 0,
|
|
title: w.title ?? ''
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Front-to-back window enumeration, or why the platform could not answer.
|
|
*
|
|
* Hyprland first, and only ever on Hyprland — its own IPC sees native Wayland
|
|
* windows, which the X11 enumerator cannot, and it answers null everywhere
|
|
* else so the established path stays the default. Shared by the
|
|
* read_window_below tool and the HUD's game-overlay watch, so the two can
|
|
* never disagree about what the screen looks like.
|
|
*/
|
|
export async function enumerateWindowsFrontToBack(
|
|
selfPid: number,
|
|
titlesAvailable: boolean
|
|
): Promise<EnumeratedWindow[] | EnumerationFailure> {
|
|
return (await readHyprlandWindows(selfPid)) ?? (await enumerateViaGetWindows(titlesAvailable))
|
|
}
|
|
|
|
export async function readWindowBelow(
|
|
selfPid: number,
|
|
selfBounds: EnumeratedWindow['bounds'],
|
|
titlesAvailable: boolean
|
|
): Promise<WindowBelowResult | WindowBelowUnavailable> {
|
|
const windows = await enumerateWindowsFrontToBack(selfPid, titlesAvailable)
|
|
|
|
if (enumerationFailed(windows)) {
|
|
return {
|
|
error: enumerationFailureNote(process.platform, process.env, windows.reason),
|
|
platform: process.platform
|
|
}
|
|
}
|
|
|
|
const { below, frontmost } = pickWindowBelow(windows, selfPid, selfBounds)
|
|
|
|
const result: WindowBelowResult = {
|
|
frontmost: frontmost ? { app: frontmost.app, title: frontmost.title } : null,
|
|
platform: process.platform,
|
|
window: below ? { app: below.app, bounds: below.bounds, id: below.id, title: below.title } : null
|
|
}
|
|
|
|
if (process.platform === 'darwin' && !titlesAvailable) {
|
|
result.note =
|
|
'Window titles are hidden: macOS reveals other apps\u2019 titles only with the ' +
|
|
'Screen Recording permission, which Hermes does not request for this.'
|
|
}
|
|
|
|
return result
|
|
}
|