178 lines
6.4 KiB
TypeScript
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
|
|
}
|