mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138)
This commit is contained in:
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
restoreOnModalClose: 'kiku',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
// Abandonment also asks the renderer hosting the modal to close its dialog.
|
||||
{
|
||||
channel: 'kiku:field-grouping-cancel',
|
||||
restoreOnModalClose: undefined,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(waitCalls, [
|
||||
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
assert.deepEqual(warnings, [
|
||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
assert.deepEqual(closed, ['kiku']);
|
||||
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
|
||||
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
|
||||
// the duplicate is a harmless no-op.
|
||||
assert.deepEqual(closed, ['kiku', 'kiku']);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
|
||||
// The field grouping modal must run the same prerequisites as every other modal
|
||||
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
|
||||
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
|
||||
const order: string[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
// The modal acknowledges open below, so the callback stays pending and arms its response
|
||||
// timeout; stub the timer so no real 90s handle leaks into the test runner.
|
||||
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
try {
|
||||
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
|
||||
sendToVisibleOverlay: (channel) => {
|
||||
order.push(`send:${channel}`);
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => {
|
||||
order.push('wait');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
|
||||
void runtime.createFieldGroupingCallback()({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Let the async send + modal-open ack chain run.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'prereqs',
|
||||
'windows-ready',
|
||||
'send:kiku:field-grouping-request',
|
||||
'wait',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
|
||||
interface WindowLike {
|
||||
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||
handleOverlayModalClosed?: (modal: T) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
/**
|
||||
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
|
||||
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
|
||||
* them the field grouping modal can open with no sibling overlay window present, and on
|
||||
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
|
||||
* modals do.
|
||||
*/
|
||||
ensureOverlayStartupPrereqs?: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
|
||||
sendToVisibleOverlay?: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
data: KikuFieldGroupingRequestData,
|
||||
): Promise<boolean> => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
const sendOpen = (): boolean =>
|
||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
const sendOpen = (): boolean => {
|
||||
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
|
||||
// and visible overlay window exist before handing off to the dedicated modal window.
|
||||
options.ensureOverlayStartupPrereqs?.();
|
||||
options.ensureOverlayWindowsReadyForVisibilityActions?.();
|
||||
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: kikuModal,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!options.waitForModalOpen) {
|
||||
return sendOpen();
|
||||
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
return opened;
|
||||
};
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
|
||||
// lives in the dedicated modal window this is redundant with the teardown below, but it
|
||||
// also covers the case where the request was routed into the visible overlay.
|
||||
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
|
||||
preferModalWindow: true,
|
||||
});
|
||||
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
|
||||
// dedicated modal window). This is what recovers the frozen overlay when a grouping
|
||||
// request times out or fails to reach a visible modal.
|
||||
options.handleOverlayModalClosed?.(kikuModal);
|
||||
};
|
||||
|
||||
const createFieldGroupingCallback = (): ((
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
sendKikuFieldGroupingRequest,
|
||||
dismissModalUi,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { createFieldGroupingCallback } from './field-grouping';
|
||||
|
||||
function makeRequestData(): KikuFieldGroupingRequestData {
|
||||
return {
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'a',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
|
||||
* sequence-guarded closure, so the value read back is never identity-equal to the
|
||||
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
|
||||
* never matched and leaked the resolver, wedging every later grouping attempt.
|
||||
*/
|
||||
function createWrappedResolverStore() {
|
||||
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
return {
|
||||
getResolver: () => stored,
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
stored = resolver ? (choice) => resolver(choice) : null;
|
||||
},
|
||||
respond: (choice: KikuFieldGroupingChoice) => {
|
||||
stored?.(choice);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let visible = false;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
};
|
||||
store.respond(choice);
|
||||
|
||||
assert.deepEqual(await pending, choice);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not reject the next request after a response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
const firstChoice = await first;
|
||||
assert.equal(firstChoice.cancelled, false);
|
||||
|
||||
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
|
||||
const second = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
|
||||
const secondChoice = await second;
|
||||
assert.equal(secondChoice.cancelled, false);
|
||||
assert.equal(secondChoice.keepNoteId, 2);
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback reports modal dismiss failures', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const errors: unknown[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
await callback(makeRequestData());
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await pending;
|
||||
assert.equal(dismissed, 0);
|
||||
});
|
||||
|
||||
test('field grouping callback rejects a concurrent request while one is pending', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let sends = 0;
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => {
|
||||
sends += 1;
|
||||
return true;
|
||||
},
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.equal(sends, 1);
|
||||
|
||||
const second = await callback(makeRequestData());
|
||||
assert.equal(second.cancelled, true);
|
||||
assert.equal(sends, 1);
|
||||
assert.equal(dismissed, 0);
|
||||
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await first;
|
||||
});
|
||||
@@ -1,21 +1,41 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
|
||||
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
|
||||
|
||||
export function createFieldGroupingCallback(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Tears down the modal UI when the request is abandoned without a renderer response
|
||||
* (send failure or response timeout). Without this the dedicated modal window, its
|
||||
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
|
||||
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
|
||||
*/
|
||||
dismissModalUi?: () => void;
|
||||
responseTimeoutMs?: number;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
const cancelledChoice = (): KikuFieldGroupingChoice => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
try {
|
||||
options.dismissModalUi?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
return new Promise((resolve) => {
|
||||
if (options.getResolver()) {
|
||||
resolve({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
resolve(cancelledChoice());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (options.getResolver() === finish) {
|
||||
options.setResolver(null);
|
||||
}
|
||||
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
|
||||
// resolver, so an identity check against `finish` never matches and would leak
|
||||
// the resolver — blocking every later grouping attempt with an instant cancel.
|
||||
options.setResolver(null);
|
||||
resolve(choice);
|
||||
|
||||
// When abandoned without a renderer response, tear down the modal window/state
|
||||
// that the request path spun up. A normal response already routes through the
|
||||
// renderer's close handler, so only the abandon paths need this.
|
||||
if (abandoned) {
|
||||
dismissModalUi();
|
||||
}
|
||||
|
||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(false);
|
||||
}
|
||||
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
|
||||
(sent) => {
|
||||
if (settled) return;
|
||||
if (!sent) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
}
|
||||
}, 90000);
|
||||
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
|
||||
},
|
||||
() => {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildHyprlandPlacementDispatches,
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
findHyprlandWindowForPlacement,
|
||||
hasHyprlandWindowPlacementBoundsMismatch,
|
||||
shouldAttemptHyprlandWindowPlacement,
|
||||
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
execFileSync: (() => {
|
||||
throw new Error('should not query the compositor when placement is not applicable');
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
|
||||
// The window has not been mapped by the compositor yet, so no client matches. Callers use
|
||||
// this to keep retrying until the modal is actually placed above fullscreen mpv.
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 999,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 456,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Overlay Modal',
|
||||
mapped: true,
|
||||
floating: false,
|
||||
pinned: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (args.join(' ') === '-j status') {
|
||||
return JSON.stringify({ configProvider: 'lua' });
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
|
||||
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement outcome for a single reconcile attempt.
|
||||
* - `applicable`: false when not running under Hyprland (nothing to retry).
|
||||
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
|
||||
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
|
||||
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
|
||||
* invisible.
|
||||
* - `dispatched`: whether any hyprctl dispatch was issued.
|
||||
*/
|
||||
export interface HyprlandPlacementStatus {
|
||||
applicable: boolean;
|
||||
clientFound: boolean;
|
||||
dispatched: boolean;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): boolean {
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pid?: number;
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): HyprlandPlacementStatus {
|
||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||
return false;
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: options.title,
|
||||
});
|
||||
if (!client) {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const configProvider = detectHyprlandConfigProvider(run);
|
||||
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||
}
|
||||
}
|
||||
return dispatches.length > 0;
|
||||
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
|
||||
} catch {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||
dismissModalUi?: () => void;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
dismissModalUi: options.dismissModalUi,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
options.sendKikuFieldGroupingRequest
|
||||
? options.sendKikuFieldGroupingRequest(data)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||
import { updateOverlayWindowBounds } from './overlay-window';
|
||||
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
|
||||
|
||||
export interface OverlayManager {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
@@ -9,7 +10,7 @@ export interface OverlayManager {
|
||||
setModalWindow: (window: BrowserWindow | null) => void;
|
||||
getOverlayWindow: () => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
let visibleOverlayVisible = false;
|
||||
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
||||
|
||||
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
|
||||
const updateWindowBounds = (
|
||||
geometry: WindowGeometry,
|
||||
window: BrowserWindow | null,
|
||||
): HyprlandPlacementStatus => {
|
||||
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
||||
applyOverlayBounds(geometry, window, { promote });
|
||||
return applyOverlayBounds(geometry, window, { promote });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
setOverlayWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, mainWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, modalWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
handleOverlayWindowBlurred,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
|
||||
import {
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
type HyprlandPlacementStatus,
|
||||
} from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
|
||||
options: {
|
||||
promote?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
if (!geometry || !window || window.isDestroyed()) return;
|
||||
): HyprlandPlacementStatus {
|
||||
if (!geometry || !window || window.isDestroyed()) {
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||
geometry,
|
||||
process.platform,
|
||||
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
|
||||
window,
|
||||
);
|
||||
window.setBounds(bounds);
|
||||
ensureHyprlandWindowFloatingByTitle({
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: window.getTitle(),
|
||||
bounds,
|
||||
promote: options.promote,
|
||||
|
||||
Reference in New Issue
Block a user