diff --git a/changes/fix-macos-fullscreen-modal-spaces.md b/changes/fix-macos-fullscreen-modal-spaces.md index 6159f0a5..d8ef0f17 100644 --- a/changes/fix-macos-fullscreen-modal-spaces.md +++ b/changes/fix-macos-fullscreen-modal-spaces.md @@ -1,5 +1,5 @@ type: fixed area: overlay -- Dedicated overlay modals are prewarmed and reused on macOS and Windows so shortcuts open them promptly on the first press. On macOS, these modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change. +- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change. - Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning. diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index c030adbb..78c121f8 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -1010,44 +1010,85 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); }); -for (const platform of ['darwin', 'win32'] as const) { - test(`modal reopen reuses the warm window and shows it immediately on ${platform}`, () => { - const modalWindow = createMockWindow(); - let createCalls = 0; +test('modal reopen reuses the warm window and shows it immediately on macOS', () => { + const modalWindow = createMockWindow(); + let createCalls = 0; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => { - createCalls += 1; - return modalWindow as never; - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => modalWindow as never, + createModalWindow: () => { + createCalls += 1; + return modalWindow as never; }, - { platform }, - ); + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform: 'darwin' }, + ); - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - runtime.notifyOverlayModalOpened('runtime-options'); - runtime.handleOverlayModalClosed('runtime-options'); - - assert.equal(modalWindow.isDestroyed(), false); - assert.equal(modalWindow.isVisible(), false); - - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - - assert.equal(sent, true); - assert.equal(createCalls, 0); - assert.equal(modalWindow.isVisible(), true); - assert.equal(modalWindow.getShowCount(), 2); + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', }); -} + runtime.notifyOverlayModalOpened('runtime-options'); + runtime.handleOverlayModalClosed('runtime-options'); + + assert.equal(modalWindow.isDestroyed(), false); + assert.equal(modalWindow.isVisible(), false); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(sent, true); + assert.equal(createCalls, 0); + assert.equal(modalWindow.isVisible(), true); + assert.equal(modalWindow.getShowCount(), 2); +}); + +test('modal reopen on Windows uses a fresh prewarmed interactive window', () => { + const firstWindow = createMockWindow(); + const replacementWindow = createMockWindow(); + let currentModal = firstWindow; + let createCalls = 0; + + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => currentModal as never, + createModalWindow: () => { + createCalls += 1; + currentModal = replacementWindow; + return replacementWindow as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform: 'win32' }, + ); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.notifyOverlayModalOpened('runtime-options'); + runtime.handleOverlayModalClosed('runtime-options'); + + assert.equal(firstWindow.isDestroyed(), true); + assert.equal(currentModal, replacementWindow); + assert.equal(replacementWindow.isVisible(), false); + assert.equal(createCalls, 1); + + const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + restoreOnModalClose: 'session-help', + }); + + assert.equal(sent, true); + assert.equal(createCalls, 1); + assert.equal(replacementWindow.isVisible(), true); + assert.equal(replacementWindow.ignoreMouseEvents, false); + assert.deepEqual(replacementWindow.sent, [['session-help:open']]); +}); test('modal reopen on the warm window notifies state change for each lifecycle', () => { const modalWindow = createMockWindow(); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index d1f3bd9f..5f3a3562 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -87,7 +87,8 @@ export function createOverlayModalRuntimeService( const modalWindowBoundsReconcileGenerations = new WeakMap(); const modalWindowPrimeListenersRegistered = new WeakSet(); const platform = options.platform ?? process.platform; - const keepModalWindowWarm = platform === 'darwin' || platform === 'win32'; + const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32'; + const reuseModalWindowAfterClose = platform === 'darwin'; const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus; const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); @@ -181,7 +182,7 @@ export function createOverlayModalRuntimeService( }; const primeModalWindow = (): boolean => { - if (!keepModalWindowWarm) { + if (!shouldPrimeModalWindow) { return false; } const modalWindow = resolveModalWindow(); @@ -515,13 +516,19 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); if (modalWindow && !modalWindow.isDestroyed()) { - if (keepModalWindowWarm) { + if (reuseModalWindowAfterClose) { modalWindow.setIgnoreMouseEvents(true, { forward: true }); modalWindow.hide(); markModalWindowPrimed(modalWindow); } else { modalWindow.destroy(); modalWindowPrimedForImmediateShow = false; + // Reusing a transparent click-through BrowserWindow can leave later modal sessions + // non-interactive on Windows. Recycle the renderer after every close, then warm its + // replacement so the next shortcut still opens promptly. + if (platform === 'win32') { + primeModalWindow(); + } } } mainWindowMousePassthroughForcedByModal = false; diff --git a/src/main/runtime/sync-launcher-client.test.ts b/src/main/runtime/sync-launcher-client.test.ts index 3c08640d..c6a030a6 100644 --- a/src/main/runtime/sync-launcher-client.test.ts +++ b/src/main/runtime/sync-launcher-client.test.ts @@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command return { spawn, children, commands }; } +async function waitForResult(promise: Promise, timeoutMs = 3000): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('Timed out waiting for result.')), timeoutMs); + }), + ]); + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => { const { spawn, children, commands } = makeSpawn(); const events: SyncProgressEvent[] = []; @@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () => // so `close` never fires. child.emit('exit', 1, null); - const result = await handle.done; + // Keep the isolated Bun test process alive while the production drain timer + // remains unref'ed, and fail instead of hanging if the result never settles. + const result = await waitForResult(handle.done); assert.equal(result.ok, false); assert.match(result.error ?? '', /remote refused/); }); diff --git a/src/main/runtime/sync-launcher-client.ts b/src/main/runtime/sync-launcher-client.ts index d7a1f771..1eecdd2a 100644 --- a/src/main/runtime/sync-launcher-client.ts +++ b/src/main/runtime/sync-launcher-client.ts @@ -66,7 +66,7 @@ export function runSyncLauncher(options: { spawn?: SyncLauncherSpawn; timeoutMs?: number; }): SyncLauncherRunHandle { - const spawn = + const spawn: SyncLauncherSpawn = options.spawn ?? ((command, args) => { // The child must boot as a full Electron app (its entry handles