fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138)

This commit is contained in:
2026-07-06 22:13:14 -07:00
committed by GitHub
parent 35ca2afc6f
commit a042b04357
27 changed files with 878 additions and 65 deletions
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = [];
const osdMessages: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
keepNoteId: number;
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
statuses.push(message);
},
showNotification: async () => undefined,
showOsdNotification: () => undefined,
showOsdNotification: (message: string) => {
osdMessages.push(message);
},
logError: () => undefined,
logInfo: () => undefined,
truncateSentence: (value: string) => value,
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
addedTags,
rememberedMerges,
statuses,
osdMessages,
mergeCalls,
setManualChoice: (choice: typeof manualChoice) => {
manualChoice = choice;
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
harness.deps.client.notesInfo = async () => [];
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
});
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) {
// handleManual owns all user-facing notifications; callers must not re-notify on false.
this.deps.showOsdNotification('Field grouping failed: original card not found');
return false;
}
+4 -2
View File
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
assert.deepEqual(harness.manualCalls, []);
});
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
const harness = createHarness({
kikuFieldGrouping: 'manual',
noteIds: [9],
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
expression: 'word-9',
},
]);
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
});
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
+3 -4
View File
@@ -156,15 +156,14 @@ export class FieldGroupingService {
);
return;
}
const handled = await this.deps.handleFieldGroupingManual(
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
// unavailable, failed) — re-notifying on a false return here duplicated them.
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
if (!handled) {
this.deps.showOsdNotification('Field grouping cancelled');
}
});
} catch (error) {
log.error('Error triggering field grouping:', (error as Error).message);
@@ -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;
}
+32 -2
View File
@@ -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,
});
};
+272
View File
@@ -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;
});
+42 -29
View File
@@ -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({
+31 -4
View File
@@ -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 };
}
}
+2
View File
@@ -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)
+8 -6
View File
@@ -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;
+10 -4
View File
@@ -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,
+3
View File
@@ -2324,6 +2324,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
ensureOverlayWindowsReadyForVisibilityActions(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(),
+153
View File
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
});
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
// The compositor never maps the window, so every reconcile reports pending.
const status = { applicable: true, clientFound: false, dispatched: false };
statuses.push(status);
return status;
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// The reconcile ladder re-asserts placement across all six delays while the client
// stays unmapped, instead of the old single post-show attempt.
assert.ok(
statuses.length >= 6,
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile stops retrying once the client is mapped', () => {
const window = createMockWindow();
const timers: Array<() => void> = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = ((cb: () => void) => {
timers.push(cb);
return { unref() {} };
}) as unknown as typeof globalThis.setTimeout;
let reconcileCount = 0;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {
reconcileCount += 1;
// Client is already mapped, so placement is settled on the first attempt.
return { applicable: true, clientFound: true, dispatched: true };
},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
let iterations = 0;
while (timers.length > 0 && iterations < 50) {
const next = timers.shift();
next?.();
iterations += 1;
}
// No 6-deep ladder: a settled placement should not keep rescheduling.
assert.ok(
reconcileCount < 6,
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
const window = createMockWindow();
type TimerEntry = { active: boolean; callback: () => void };
const timers: TimerEntry[] = [];
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
const runNextActiveTimer = () => {
const timer = timers.find((candidate) => candidate.active);
if (!timer) return;
timer.active = false;
timer.callback();
};
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
globalThis.setTimeout = ((cb: () => void) => {
const timer = { active: true, callback: cb, unref() {} };
timers.push(timer);
return timer;
}) as unknown as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
if (timeout) {
timeout.active = false;
}
}) as unknown as typeof globalThis.clearTimeout;
try {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => window as never,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
runtime.notifyOverlayModalOpened('kiku');
assert.equal(activeTimerCount(), 1);
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open',
{ test: true },
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
);
assert.equal(activeTimerCount(), 2);
runNextActiveTimer();
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
+42 -9
View File
@@ -1,10 +1,14 @@
import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
function requestOverlayApplicationFocus(): void {
try {
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
getModalWindow: () => BrowserWindow | null;
createModalWindow: () => BrowserWindow | null;
getModalGeometry: () => WindowGeometry;
setModalWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
}
export interface OverlayModalRuntime {
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
window.moveTop();
};
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
const modalWindow = deps.getModalWindow();
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
return;
}
deps.setModalWindowBounds(deps.getModalGeometry());
return deps.setModalWindowBounds(deps.getModalGeometry());
};
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
modalWindowBoundsReconcileGenerations.set(window, generation);
return generation;
};
const isCurrentModalWindowBoundsReconcileGeneration = (
window: BrowserWindow,
generation: number,
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
const scheduleModalWindowBoundsReconcile = (
window: BrowserWindow,
generation: number,
attempt = 0,
): void => {
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
return;
}
const timeout = setTimeout(() => {
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
return;
}
if (window.isDestroyed() || !window.isVisible()) {
return;
}
reconcileModalWindowBounds(window);
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
const status = reconcileModalWindowBounds(window);
// Keep retrying only while a Hyprland placement is applicable but the compositor has
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
if (status && status.applicable && !status.clientFound) {
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
}
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
timeout.unref?.();
};
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
if (!window.webContents.isFocused()) {
window.webContents.focus();
}
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
};
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
if (window.isVisible()) {
window.focus();
window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
reconcileModalWindowBounds(window);
scheduleModalWindowBoundsReconcile(window);
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
return;
}
@@ -36,3 +36,50 @@ test('field grouping overlay main deps builder maps window visibility and resolv
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
});
test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => {
// Regression: these are optional on the runtime options, so a missing forward compiled
// silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs —
// the reason the earlier recovery fixes never took effect.
const calls: string[] = [];
const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise<boolean> => {
calls.push(`wait:${modal}:${timeoutMs}`);
return true;
};
const handleOverlayModalClosed = (modal: 'kiku'): void => {
calls.push(`closed:${modal}`);
};
const logWarn = (message: string): void => {
calls.push(`warn:${message}`);
};
const ensureOverlayStartupPrereqs = (): void => {
calls.push('prereqs');
};
const ensureOverlayWindowsReadyForVisibilityActions = (): void => {
calls.push('windows-ready');
};
const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
waitForModalOpen,
handleOverlayModalClosed,
logWarn,
ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: () => true,
})();
assert.equal(deps.waitForModalOpen, waitForModalOpen);
assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed);
assert.equal(deps.logWarn, logWarn);
assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs);
assert.equal(
deps.ensureOverlayWindowsReadyForVisibilityActions,
ensureOverlayWindowsReadyForVisibilityActions,
);
});
@@ -28,6 +28,15 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
// These are optional on the runtime options, so a missing forward compiles silently — but
// dropping them left the field grouping modal with no modal-open ack/retry, no teardown on
// failure, and no warn logging, which is why the earlier recovery fixes never took effect.
waitForModalOpen: deps.waitForModalOpen,
handleOverlayModalClosed: deps.handleOverlayModalClosed,
logWarn: deps.logWarn,
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
@@ -26,8 +26,9 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
setModalWindowBounds: (geometry) =>
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`),
setModalWindowBounds: (geometry) => {
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
},
})();
assert.equal(deps.getMainWindow(), mainWindow);
+4
View File
@@ -201,6 +201,9 @@ const onKikuFieldGroupingRequestEvent =
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const onKikuFieldGroupingCancelEvent = createQueuedIpcListener(
IPC_CHANNELS.event.kikuFieldGroupingCancel,
);
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
IPC_CHANNELS.event.subtitleSet,
(payload) => payload as SubtitleData,
@@ -396,6 +399,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
onKikuFieldGroupingCancel: onKikuFieldGroupingCancelEvent,
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
+7
View File
@@ -565,6 +565,13 @@ function registerModalOpenHandlers(): void {
});
},
);
window.electronAPI.onKikuFieldGroupingCancel(() => {
runGuarded('kiku:field-grouping-cancel', () => {
// Main already settled the choice (timeout/failure); just close the dialog. Using the
// plain close path avoids sending a second, redundant response back to main.
kikuModal.closeKikuFieldGroupingModal();
});
});
}
function registerKeyboardCommandHandlers(): void {
+1
View File
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
secondarySubtitleMode: 'secondary-subtitle:mode',
subsyncOpenManual: 'subsync:open-manual',
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
kikuFieldGroupingCancel: 'kiku:field-grouping-cancel',
runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open',
+1
View File
@@ -471,6 +471,7 @@ export interface ElectronAPI {
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
onKikuFieldGroupingCancel: (callback: () => void) => void;
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;