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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user