From 7e500d2ead2251aa10f745bfdb32975dd81e1dba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 24 Aug 2026 11:52:07 -0500 Subject: [PATCH] fix(desktop): float and pin the HUD on Hyprland Omarchy tiles the HUD like any other toplevel, so always-on-top and xdg_toplevel.move never apply. Ask Hyprland to float+pin after map, trying classic dispatch then Lua for 0.55+ configs. --- apps/desktop/electron/hud-hyprland.test.ts | 253 +++++++++++++++++++++ apps/desktop/electron/hud-hyprland.ts | 217 ++++++++++++++++++ apps/desktop/electron/hyprland.ts | 4 +- apps/desktop/electron/main.ts | 7 + website/docs/user-guide/desktop.md | 6 +- 5 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/electron/hud-hyprland.test.ts create mode 100644 apps/desktop/electron/hud-hyprland.ts diff --git a/apps/desktop/electron/hud-hyprland.test.ts b/apps/desktop/electron/hud-hyprland.test.ts new file mode 100644 index 0000000000..bd5505cb5f --- /dev/null +++ b/apps/desktop/electron/hud-hyprland.test.ts @@ -0,0 +1,253 @@ +/** + * Unit tests for the Hyprland HUD overlay promote. The socket itself needs a + * live compositor; what is covered here is the grammar (classic vs Lua), the + * client lookup, and which dispatchers fire for a tiled vs already-floating bar. + */ + +import assert from 'node:assert/strict' + +import { afterEach, test } from 'vitest' + +import { + classifyHyprlandDispatchReply, + hudOverlayCommands, + parseHudHyprlandClient, + promoteHudOnHyprland, + resetHyprlandDispatchSyntax +} from './hud-hyprland' + +const TITLE = 'Hermes HUD' +const ADDRESS = '0x55d1' +const SOCKET_ENV = { HYPRLAND_INSTANCE_SIGNATURE: 'abc123', XDG_RUNTIME_DIR: '/run/user/1000' } +const LUA_REJECT = + 'error: [string "return hl.dispatch(setfloating address:0x55d1)"]:1: \')\' expected near \'address\'\n\n → Note: dispatch in lua is a shorthand for hl.dispatch(...), your syntax might need to be updated.' + +afterEach(() => { + resetHyprlandDispatchSyntax() +}) + +const hudClient = (over: Record = {}) => ({ + address: ADDRESS, + floating: false, + pinned: false, + title: TITLE, + ...over +}) + +test('classifies Hyprland dispatch replies the way 0.54 and 0.56 actually write them', () => { + assert.equal(classifyHyprlandDispatchReply('ok'), 'ok') + assert.equal(classifyHyprlandDispatchReply('ok\n'), 'ok') + assert.equal(classifyHyprlandDispatchReply('Invalid dispatcher'), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply(LUA_REJECT), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply('No such window found'), 'failed') + assert.equal( + classifyHyprlandDispatchReply('warning: =[C]:-1: hl.focus: window not found'), + 'failed' + ) +}) + +test('finds the HUD by exact title among other windows of the same pid', () => { + const found = parseHudHyprlandClient( + JSON.stringify([ + { address: '0x111', pid: 42, title: 'Hermes', floating: true, pinned: false }, + hudClient({ pid: 42 }) + ]), + TITLE + ) + + assert.deepEqual(found, { address: ADDRESS, floating: false, pinned: false }) +}) + +test('prefixes a bare hex address so the selector is well-formed', () => { + const found = parseHudHyprlandClient(JSON.stringify([hudClient({ address: '55d1' })]), TITLE) + + assert.equal(found?.address, ADDRESS) +}) + +test('ignores a payload that is not the client list or has the wrong title', () => { + assert.equal(parseHudHyprlandClient('not json', TITLE), null) + assert.equal(parseHudHyprlandClient('{}', TITLE), null) + assert.equal(parseHudHyprlandClient(JSON.stringify([hudClient({ title: 'other' })]), TITLE), null) +}) + +test('legacy float is setfloating (idempotent), not togglefloating', () => { + const commands = hudOverlayCommands(ADDRESS, 'float') + + assert.equal(commands.legacy, 'dispatch setfloating address:0x55d1') + assert.equal( + commands.lua, + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })' + ) +}) + +test('lua pin uses enable so a second promote does not unpin', () => { + const commands = hudOverlayCommands(ADDRESS, 'pin') + + assert.equal(commands.legacy, 'dispatch pin address:0x55d1') + assert.equal( + commands.lua, + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ) +}) + +test('skips compositors that are not Hyprland', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: {}, + uid: 1000, + request: async (_socket, command) => { + calls.push(command) + return 'ok' + } + }) + + assert.equal(promoted, false) + assert.deepEqual(calls, []) +}) + +test('floats then pins a tiled HUD on a classic hyprlang session', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch setfloating address:0x55d1', + 'dispatch pin address:0x55d1' + ]) +}) + +test('falls back to Lua dispatch when the classic session grammar is rejected', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch setfloating address:0x55d1', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('caches Lua syntax so the next promote does not probe classic first', async () => { + const request = async (_socket: string, command: string) => { + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + return 'ok' + } + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request + }) + + const calls: string[] = [] + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + return request(_socket, command) + } + }) + + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('does not pin a window that is already pinned, and does not float one that already is', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + if (command === 'j/clients') { + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, ['j/clients']) +}) + +test('retries until Hyprland lists the HUD', async () => { + let lookups = 0 + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 3, + delayMs: 0, + sleep: async () => undefined, + request: async (_socket, command) => { + if (command === 'j/clients') { + lookups += 1 + if (lookups < 3) { + return JSON.stringify([{ address: '0x111', title: 'Hermes', floating: true }]) + } + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.equal(lookups, 3) +}) diff --git a/apps/desktop/electron/hud-hyprland.ts b/apps/desktop/electron/hud-hyprland.ts new file mode 100644 index 0000000000..0f0fde15fa --- /dev/null +++ b/apps/desktop/electron/hud-hyprland.ts @@ -0,0 +1,217 @@ +/** + * Hyprland (Omarchy, etc.) tiles a new Electron toplevel by default. + * `alwaysOnTop` is compositor-owned on native Wayland, and `xdg_toplevel.move` + * — the native HUD drag path — is ignored on a tiled window. The bar then + * sits in the layout instead of over the user's work, which is the whole HUD. + * + * We already speak Hyprland's command socket for `read_window_below`. After + * the HUD maps, ask that same socket to float and pin this title. Pin is the + * always-on-top analogue (all workspaces, above tiled clients). + * + * Hyprland 0.55+ on a Lua config evaluates `dispatch` as Lua, so the classic + * `setfloating` / `pin` strings fail there and the `hl.dsp.*` forms fail on a + * hyprlang session. Which grammar a session speaks is only discoverable by + * trying; we probe once and cache it. + */ + +import { hyprlandRequest, hyprlandSocketPath } from './hyprland' + +export type HyprlandDispatchReply = 'ok' | 'wrong-syntax' | 'failed' + +export type HudHyprlandClient = { + address: string + floating: boolean + pinned: boolean +} + +type HyprlandDispatchSyntax = 'legacy' | 'lua' + +export type HyprlandRequestFn = (socketPath: string, command: string) => Promise + +const DEFAULT_ATTEMPTS = 8 +const DEFAULT_DELAY_MS = 50 + +let cachedSyntax: null | HyprlandDispatchSyntax = null + +export function resetHyprlandDispatchSyntax(): void { + cachedSyntax = null +} + +/** + * Hyprland's textual reply to a `dispatch`. "ok" applied. A Lua parse error or + * "Invalid dispatcher" means we used the other grammar. Anything else is the + * right grammar aimed at a missing window — retry the lookup, not the syntax. + * + * Reply strings are what Hyprland 0.54 (hyprlang) and 0.56 (Lua config) actually + * write; they are the probe, not a guess. + */ +export function classifyHyprlandDispatchReply(reply: string): HyprlandDispatchReply { + const text = reply.trim() + + if (text === 'ok') { + return 'ok' + } + + if (text.startsWith('Invalid dispatcher') || text.startsWith('error:')) { + return 'wrong-syntax' + } + + return 'failed' +} + +function windowAddress(raw: string): string { + return raw.startsWith('0x') || raw.startsWith('0X') ? raw : `0x${raw}` +} + +/** First mapped client whose title is exactly the HUD's. */ +export function parseHudHyprlandClient(payload: string, title: string): HudHyprlandClient | null { + let raw: unknown + + try { + raw = JSON.parse(payload) + } catch { + return null + } + + if (!Array.isArray(raw)) { + return null + } + + for (const entry of raw) { + if (!entry || typeof entry !== 'object') { + continue + } + + const client = entry as { address?: unknown; floating?: unknown; pinned?: unknown; title?: unknown } + + if (client.title !== title || typeof client.address !== 'string' || client.address.length === 0) { + continue + } + + return { + address: windowAddress(client.address), + floating: client.floating === true, + pinned: client.pinned === true + } + } + + return null +} + +export function hudOverlayCommands( + address: string, + action: 'float' | 'pin' +): { legacy: string; lua: string } { + const selector = `address:${address}` + + if (action === 'float') { + return { + legacy: `dispatch setfloating ${selector}`, + lua: `dispatch hl.dsp.window.float({ action = "enable", window = "${selector}" })` + } + } + + return { + legacy: `dispatch pin ${selector}`, + lua: `dispatch hl.dsp.window.pin({ action = "enable", window = "${selector}" })` + } +} + +async function dispatchWithFallback( + request: HyprlandRequestFn, + socketPath: string, + pair: { legacy: string; lua: string } +): Promise { + const order: HyprlandDispatchSyntax[] = + cachedSyntax === 'lua' ? ['lua', 'legacy'] : ['legacy', 'lua'] + + for (const syntax of order) { + const command = syntax === 'legacy' ? pair.legacy : pair.lua + const reply = await request(socketPath, command) + + if (reply == null) { + return 'failed' + } + + const kind = classifyHyprlandDispatchReply(reply) + + if (kind === 'wrong-syntax') { + continue + } + + cachedSyntax = syntax + return kind + } + + return 'wrong-syntax' +} + +async function defaultSleep(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Float and pin the HUD on Hyprland. No-op on every other compositor. + * Fire-and-forget from the HUD reveal path; retries because `j/clients` can + * lag the first frame. + */ +export async function promoteHudOnHyprland(options: { + title: string + attempts?: number + delayMs?: number + env?: NodeJS.ProcessEnv + request?: HyprlandRequestFn + sleep?: (ms: number) => Promise + uid?: number +}): Promise { + const env = options.env ?? process.env + const uid = options.uid ?? process.getuid?.() ?? 0 + const socketPath = hyprlandSocketPath(env, uid) + + if (!socketPath) { + return false + } + + const request = options.request ?? hyprlandRequest + const sleep = options.sleep ?? defaultSleep + const attempts = options.attempts ?? DEFAULT_ATTEMPTS + const delayMs = options.delayMs ?? DEFAULT_DELAY_MS + + for (let attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0 && delayMs > 0) { + await sleep(delayMs) + } + + const payload = await request(socketPath, 'j/clients') + + if (!payload) { + continue + } + + const client = parseHudHyprlandClient(payload, options.title) + + if (!client) { + continue + } + + if (!client.floating) { + const floated = await dispatchWithFallback(request, socketPath, hudOverlayCommands(client.address, 'float')) + + if (floated !== 'ok') { + continue + } + } + + if (!client.pinned) { + const pinned = await dispatchWithFallback(request, socketPath, hudOverlayCommands(client.address, 'pin')) + + if (pinned !== 'ok') { + continue + } + } + + return true + } + + return false +} diff --git a/apps/desktop/electron/hyprland.ts b/apps/desktop/electron/hyprland.ts index 6aa50881e9..92353a12bb 100644 --- a/apps/desktop/electron/hyprland.ts +++ b/apps/desktop/electron/hyprland.ts @@ -122,7 +122,7 @@ export function parseHyprlandClients(payload: string, selfPid: number): Enumerat } /** One request on the command socket, always closed, never left hanging. */ -function request(socketPath: string, command: string): Promise { +export function hyprlandRequest(socketPath: string, command: string): Promise { return new Promise(resolve => { let body = '' let settled = false @@ -165,7 +165,7 @@ export async function readHyprlandWindows( return null } - const payload = await request(socketPath, 'j/clients') + const payload = await hyprlandRequest(socketPath, 'j/clients') if (!payload) { return null diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 675291a8a3..3bd334f4f2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -205,6 +205,7 @@ import { import { cursorPointInWindow } from './hud-cursor' import { startHudGameOverlayWatch } from './hud-game-overlay' import { applyHudResetBounds, defaultHudBounds } from './hud-geometry' +import { promoteHudOnHyprland } from './hud-hyprland' import { hudInputPolicy } from './hud-input-policy' import { registerHudIpc } from './hud-ipc' import { snapHudBounds } from './hud-snap' @@ -11983,6 +11984,12 @@ function spawnHudWindow(sessionId, profile) { if (hudRestoreMainWindow && mainWindow && !mainWindow.isDestroyed()) { mainWindow.hide() } + + // Hyprland tiles new toplevels. alwaysOnTop is compositor-owned on + // native Wayland, and xdg_toplevel.move is ignored on a tiled window, + // so the HUD never becomes an overlay and cannot be dragged. Same + // socket as read_window_below; no-op everywhere else. + void promoteHudOnHyprland({ title: HUD_WINDOW_TITLE }) } }) diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 36aeb0cbac..b3d1286c44 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -139,7 +139,11 @@ Talk to Hermes and hear it back, the same [voice mode](./features/voice-mode.md) #### Linux / Wayland -Electron 20+ already runs as a native Wayland client on a Wayland session. Drag, click-through, and resize work on that path. A few compositors (notably COSMIC) ignore `always-on-top` for native Wayland windows. To restore pinning, run the app under XWayland: +Electron 20+ already runs as a native Wayland client on a Wayland session. Drag, click-through, and resize work on that path. + +On **Hyprland** (including Omarchy) the HUD is floated and pinned through the compositor's IPC after it maps — otherwise Hyprland tiles it like any other window, `always-on-top` is ignored, and compositor drag does nothing. No extra window rule is required. + +A few compositors (notably COSMIC) ignore `always-on-top` for native Wayland windows. To restore pinning there, run the app under XWayland: ```yaml desktop: