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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user