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.
This commit is contained in:
committed by
brooklyn!
parent
d08f9e14f6
commit
7e500d2ead
@@ -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<string, unknown> = {}) => ({
|
||||
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)
|
||||
})
|
||||
@@ -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<null | string>
|
||||
|
||||
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<HyprlandDispatchReply> {
|
||||
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<void> {
|
||||
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<void>
|
||||
uid?: number
|
||||
}): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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<null | string> {
|
||||
export function hyprlandRequest(socketPath: string, command: string): Promise<null | string> {
|
||||
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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user