diff --git a/changes/hyprland-recovery-dialog.md b/changes/hyprland-recovery-dialog.md new file mode 100644 index 00000000..7cd63c11 --- /dev/null +++ b/changes/hyprland-recovery-dialog.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 5039d836..f36f642c 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -378,6 +378,10 @@ windowrule = no_blur on, match:class SubMiner If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay - the `opaque`/`opacity` and `no_blur` fields above override it. +**Application Not Responding dialog covered by the overlay** + +SubMiner keeps visible Hyprland system dialogs above its windows on the same workspace when updating overlay placement. This lets you click the recovery dialog even while the overlay accepts mouse input. If the whole SubMiner process is frozen, use Hyprland's window-focus bindings to reach the dialog; SubMiner cannot update window order until it resumes. + **Global shortcuts not working** On Hyprland, Electron cannot register global shortcuts on its own. You must explicitly pass keybindings to SubMiner using `pass` rules: diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 79e194ac..ea6fc36e 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -156,6 +156,107 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win ); }); +test('Hyprland placement keeps a recovery dialog above the input-catching overlay', () => { + for (const configProvider of ['hyprlang', 'lua']) { + for (const retryBounds of [false, true]) { + // Bottom to top, as when Hyprland opens its recovery dialog over playback. + const stack = ['0xmpv', '0xoverlay', '0xdialog']; + const clients = [ + { + address: '0xoverlay', + pid: 456, + title: 'SubMiner Overlay', + floating: true, + workspace: { id: 1 }, + at: [10, 20], + size: [100, 100], + }, + { + address: '0xdialog', + class: 'hyprland-dialog', + mapped: true, + hidden: false, + workspace: { id: 1 }, + }, + ]; + let clientReads = 0; + const status = ensureHyprlandWindowFloatingByTitleWithStatus({ + title: 'SubMiner Overlay', + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' }, + pid: 456, + bounds: retryBounds ? { x: 0, y: 0, width: 1280, height: 720 } : undefined, + execFileSync: (_command, args) => { + if (args.join(' ') === '-j clients') { + clientReads += 1; + return JSON.stringify(clients); + } + if (args.join(' ') === '-j status') return JSON.stringify({ configProvider }); + if (args.join(' ').match(/alterzorder|alter_zorder/)) { + const address = args.join(' ').match(/address:(0x\w+)/)?.[1]; + assert.ok(address); + stack.splice(stack.indexOf(address), 1); + stack.push(address); + } + return ''; + }, + }); + assert.equal(status.dispatched, true); + assert.equal(clientReads, retryBounds ? 2 : 1); + assert.deepEqual(stack, ['0xmpv', '0xoverlay', '0xdialog'], configProvider); + } + } +}); + +test('Hyprland placement only promotes mapped dialogs on the placed window workspace', () => { + const calls: string[] = []; + const dialog = { + class: 'hyprland-dialog', + mapped: true, + hidden: false, + workspace: { id: 1 }, + }; + const clients = [ + { + address: '0xoverlay', + pid: 456, + title: 'SubMiner Overlay', + floating: true, + workspace: { id: 1 }, + }, + { ...dialog, address: '0xhidden', hidden: true }, + { ...dialog, address: '0xunmapped', mapped: false }, + { ...dialog, address: '0xother', workspace: { id: 2 } }, + { ...dialog, address: '0xordinary', class: 'terminal' }, + { ...dialog, address: '0xinitial', class: '', initialClass: 'hyprland-dialog' }, + ]; + for (const promote of [true, false]) { + calls.length = 0; + ensureHyprlandWindowFloatingByTitleWithStatus({ + title: 'SubMiner Overlay', + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' }, + pid: 456, + promote, + execFileSync: (_command, args) => { + if (args.join(' ') === '-j clients') return JSON.stringify(clients); + if (args.join(' ') === '-j status') return JSON.stringify({ configProvider: 'hyprlang' }); + calls.push(args.join(' ')); + return ''; + }, + }); + assert.deepEqual( + calls, + promote + ? [ + 'dispatch alterzorder top,address:0xoverlay', + 'dispatch alterzorder top,address:0xinitial', + ] + : [], + ); + } +}); + test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => { const buildDispatches = buildHyprlandPlacementDispatches as ( client: Parameters[0], diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index 16fd217a..6a4bf0a5 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -3,14 +3,17 @@ import { execFileSync } from 'node:child_process'; export interface HyprlandPlacementClient { address?: string; at?: [number, number]; + class?: string; floating?: boolean; hidden?: boolean; + initialClass?: string; initialTitle?: string; mapped?: boolean; pid?: number; pinned?: boolean; size?: [number, number]; title?: string; + workspace?: { id: number }; } export interface HyprlandPlacementBounds { @@ -25,7 +28,11 @@ export interface HyprlandPlacementDispatchOptions { promote?: boolean; } -type ExecFileSync = typeof execFileSync; +type ExecFileSync = ( + file: string, + args: string[], + options: NonNullable[2]>, +) => ReturnType; export type HyprlandConfigProvider = 'hyprlang' | 'lua'; export function shouldAttemptHyprlandWindowPlacement( @@ -154,6 +161,33 @@ function luaWindowDispatch(name: string, windowAddress: string, fields: string[] ]; } +// Compositor recovery dialogs must remain clickable even when an overlay still owns input. +function buildHyprlandDialogPromotionDispatches( + clients: HyprlandPlacementClient[], + placedClient: HyprlandPlacementClient, + configProvider: HyprlandConfigProvider, +): string[][] { + if (typeof placedClient.workspace?.id !== 'number') return []; + return clients.flatMap((client) => { + if ( + !client.address || + client.address === placedClient.address || + client.mapped === false || + client.hidden === true || + client.workspace?.id !== placedClient.workspace?.id || + (client.class !== 'hyprland-dialog' && client.initialClass !== 'hyprland-dialog') + ) { + return []; + } + const windowAddress = `address:${client.address}`; + return [ + configProvider === 'lua' + ? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"']) + : ['dispatch', 'alterzorder', `top,${windowAddress}`], + ]; + }); +} + function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] { return luaWindowDispatch('set_prop', windowAddress, [ `prop = ${luaString(prop)}`, @@ -331,12 +365,16 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: { configProvider, promote: options.promote, }); + if (options.promote !== false) { + dispatches.push(...buildHyprlandDialogPromotionDispatches(clients, client, configProvider)); + } for (const args of dispatches) { run('hyprctl', args, { stdio: 'ignore' }); } if (shouldVerifyBounds) { try { - const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), { + const refreshedClients = readHyprlandPlacementClients(run); + const refreshedClient = findHyprlandWindowForPlacement(refreshedClients, { pid: options.pid ?? process.pid, title: options.title, }); @@ -345,10 +383,20 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: { targetBounds && clientMatchesPlacementBounds(refreshedClient, targetBounds) === false ) { - for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, { + const retryDispatches = buildHyprlandPlacementDispatches(refreshedClient, targetBounds, { configProvider, promote: options.promote, - })) { + }); + if (options.promote !== false) { + retryDispatches.push( + ...buildHyprlandDialogPromotionDispatches( + refreshedClients, + refreshedClient, + configProvider, + ), + ); + } + for (const args of retryDispatches) { run('hyprctl', args, { stdio: 'ignore' }); } }