Files
aiturk-hermes-ide/apps/desktop/electron/hyprland.ts
T

178 lines
6.4 KiB
TypeScript

// hyprland.ts — window enumeration for Hyprland, via the compositor's own IPC.
//
// `read_window_below` normally enumerates through `get-windows`, which on Linux
// shells out to `xprop` and reads `_NET_CLIENT_LIST_STACKING`. That is an X11
// protocol, and Wayland deliberately refuses to tell one application about
// another's windows — so on a Wayland session the tool has nothing to work
// with. Under XWayland it is arguably worse than nothing: it enumerates the few
// legacy X11 clients and silently misses every native Wayland window, which on
// a Hyprland desktop is most of them.
//
// Hyprland answers the question directly. `j/clients` returns every window with
// its class, title, position, size, pid and focus history, which is richer than
// anything the X11 path provides. This module is the provider for that; the
// picking logic stays in window-below.ts, unchanged and shared.
//
// Sway and the other wlroots compositors expose the same information through
// `swaymsg -t get_tree`. That is a different shape (a tree, not a list) and is
// deliberately not attempted here rather than shipped untested.
import net from 'node:net'
import type { EnumeratedWindow } from './window-below'
// Hyprland evaluates this socket synchronously and freezes for a five-second
// timeout on an unclosed connection, so every path below destroys the socket.
// One request per tool call, never polled.
const REQUEST_TIMEOUT_MS = 1000
/**
* Path to the running instance's command socket, or null when Hyprland is not
* the compositor.
*
* The instance signature is what makes this per-instance — two Hyprlands on one
* machine have two sockets. The `/run/user/<uid>` fallback mirrors Hyprland's
* own client code for sessions that don't export `XDG_RUNTIME_DIR`.
*/
export function hyprlandSocketPath(env: NodeJS.ProcessEnv, uid: number): null | string {
const signature = env.HYPRLAND_INSTANCE_SIGNATURE
if (!signature) {
return null
}
const runtime = env.XDG_RUNTIME_DIR ? `${env.XDG_RUNTIME_DIR}/hypr` : `/run/user/${uid}/hypr`
return `${runtime}/${signature}/.socket.sock`
}
interface HyprlandClient {
address?: string
at?: [number, number]
class?: string
pid?: number
size?: [number, number]
title?: string
workspace?: { id?: number }
}
/**
* `j/clients` payload → the windows that could be underneath us, most recently
* used first.
*
* Three corrections turn Hyprland's answer into the one `pickWindowBelow`
* wants:
*
* - Order. The list arrives in the compositor's internal order, not z-order.
* `focusHistoryID` is 0 for the focused window, 1 for the one before it, and
* so on, which is the ordering the caller actually means by "underneath":
* the app the user was last working in.
* - Ourselves. Dropped, and this is load-bearing rather than tidiness. On X11
* the list is true stacking order, so walking past our own window and
* taking the next one is what "below" means. Focus history is not stacking
* order: the HUD floats always-on-top while the user works in the app
* beneath it, so the app we want to report is the focused one and WE are
* further down the list. Slicing after ourselves would skip straight past
* the answer and name something the user last touched ten minutes ago.
* Leaving ourselves out puts `pickWindowBelow` on its overlap-only path,
* which reads a focus-ordered list correctly.
* - Workspace. `clients` spans every workspace, and windows on a workspace
* you cannot see occupy the same coordinates as the ones you can. Left in,
* they win the overlap test against our bounds and the tool confidently
* reports a window on another desktop. Our own window pins which workspace
* is the visible one — the one use we have for it before dropping it; if we
* can't find ourselves we keep everything, which is no worse than the X11
* path.
*/
export function parseHyprlandClients(payload: string, selfPid: number): EnumeratedWindow[] {
let raw: unknown
try {
raw = JSON.parse(payload)
} catch {
return []
}
if (!Array.isArray(raw)) {
return []
}
const clients = raw as Array<HyprlandClient & { focusHistoryID?: number }>
const ours = clients.find(c => c.pid === selfPid)
const workspace = ours?.workspace?.id
const visible = workspace === undefined ? clients : clients.filter(c => c.workspace?.id === workspace)
return visible
.filter(c => c.pid !== selfPid && (c.size?.[0] ?? 0) > 0 && (c.size?.[1] ?? 0) > 0)
.sort((a, b) => (a.focusHistoryID ?? Number.MAX_SAFE_INTEGER) - (b.focusHistoryID ?? Number.MAX_SAFE_INTEGER))
.map(c => ({
app: c.class ?? '',
bounds: {
x: c.at?.[0] ?? 0,
y: c.at?.[1] ?? 0,
width: c.size?.[0] ?? 0,
height: c.size?.[1] ?? 0
},
// Addresses are pointers rendered as hex; they only travel back to the
// model as an opaque handle, so a failed parse costs nothing.
id: Number.parseInt(c.address ?? '', 16) || 0,
pid: c.pid ?? 0,
title: c.title ?? ''
}))
}
/** One request on the command socket, always closed, never left hanging. */
export function hyprlandRequest(socketPath: string, command: string): Promise<null | string> {
return new Promise(resolve => {
let body = ''
let settled = false
const socket = net.createConnection(socketPath)
const finish = (result: null | string) => {
if (settled) {
return
}
settled = true
socket.destroy()
resolve(result)
}
socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(null))
socket.on('error', () => finish(null))
socket.on('connect', () => socket.write(command))
socket.on('data', chunk => {
body += chunk.toString('utf8')
})
socket.on('end', () => finish(body))
})
}
/**
* Every window Hyprland can see, front-to-back, or null when this isn't a
* Hyprland session or the compositor didn't answer — which is the caller's cue
* to fall back to the X11 enumerator.
*/
export async function readHyprlandWindows(
selfPid: number,
env: NodeJS.ProcessEnv = process.env,
uid: number = process.getuid?.() ?? 0
): Promise<EnumeratedWindow[] | null> {
const socketPath = hyprlandSocketPath(env, uid)
if (!socketPath) {
return null
}
const payload = await hyprlandRequest(socketPath, 'j/clients')
if (!payload) {
return null
}
const windows = parseHyprlandClients(payload, selfPid)
return windows.length > 0 ? windows : null
}