From a02c33dac4a271e61bac5f8ef29daeef0da0782b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 15 Aug 2026 21:43:26 -0700 Subject: [PATCH] fix(overlay): keep macOS modal windows on fullscreen Spaces (#200) --- changes/fix-macos-fullscreen-modal-spaces.md | 5 + plugin/subminer/hover.lua | 7 +- plugin/subminer/session_bindings.lua | 6 +- plugin/subminer/ui.lua | 14 +- src/core/services/mpv-properties.ts | 4 +- src/core/services/mpv-protocol.test.ts | 22 ++ src/core/services/mpv-protocol.ts | 2 +- src/core/services/mpv.test.ts | 13 + .../services/overlay-window-config.test.ts | 26 ++ src/core/services/overlay-window-flags.ts | 1 + src/core/services/overlay-window-options.ts | 13 +- src/core/services/overlay-window.ts | 17 +- src/core/services/stats-window-runtime.ts | 11 + src/core/services/stats-window.test.ts | 25 ++ src/core/services/stats-window.ts | 10 +- src/core/services/subtitle-line-dedup-gate.ts | 2 +- src/main-entry-runtime.test.ts | 7 + src/main-entry-runtime.ts | 10 + src/main-entry.ts | 3 +- src/main.ts | 3 + src/main/overlay-runtime.test.ts | 357 ++++++++++++++---- src/main/overlay-runtime.ts | 108 +++++- 22 files changed, 568 insertions(+), 98 deletions(-) create mode 100644 changes/fix-macos-fullscreen-modal-spaces.md diff --git a/changes/fix-macos-fullscreen-modal-spaces.md b/changes/fix-macos-fullscreen-modal-spaces.md new file mode 100644 index 00000000..6159f0a5 --- /dev/null +++ b/changes/fix-macos-fullscreen-modal-spaces.md @@ -0,0 +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. +- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning. diff --git a/plugin/subminer/hover.lua b/plugin/subminer/hover.lua index 5d4ab106..d5287bc5 100644 --- a/plugin/subminer/hover.lua +++ b/plugin/subminer/hover.lua @@ -106,8 +106,11 @@ function M.create(ctx) local function get_subtitle_ass_property() local ass_text = mp.get_property("sub-text/ass") - if type(ass_text) == "string" and ass_text ~= "" then - return ass_text + if ass_text ~= nil then + if type(ass_text) == "string" and ass_text ~= "" then + return ass_text + end + return nil end ass_text = mp.get_property("sub-text-ass") if type(ass_text) == "string" and ass_text ~= "" then diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 8cb89fb8..8c40b8b3 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -232,7 +232,7 @@ function M.create(ctx) elseif action_id == "triggerFieldGrouping" then return { "--trigger-field-grouping" } elseif action_id == "triggerSubsync" then - return { "--trigger-subsync" } + return { "--session-action", '{"actionId":"triggerSubsync"}' } elseif action_id == "mineSentence" then return { "--mine-sentence" } elseif action_id == "mineSentenceMultiple" then @@ -251,7 +251,7 @@ function M.create(ctx) elseif action_id == "markWatched" then return { "--mark-watched" } elseif action_id == "openRuntimeOptions" then - return { "--open-runtime-options" } + return { "--session-action", '{"actionId":"openRuntimeOptions"}' } elseif action_id == "openJimaku" then return { "--open-jimaku" } elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then @@ -259,7 +259,7 @@ function M.create(ctx) elseif action_id == "openYoutubePicker" then return { "--open-youtube-picker" } elseif action_id == "openSessionHelp" then - return { "--open-session-help" } + return { "--session-action", '{"actionId":"openSessionHelp"}' } elseif action_id == "openCharacterDictionaryManager" then return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' } elseif action_id == "openControllerSelect" then diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index 9b3ebd97..e138332e 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -4,6 +4,7 @@ function M.create(ctx) local mp = ctx.mp local input = ctx.input local process = ctx.process + local state = ctx.state local subminer_log = ctx.log.subminer_log local show_osd = ctx.log.show_osd @@ -93,7 +94,18 @@ function M.create(ctx) if not ensure_binary_for_menu() then return end - process.run_control_command_async("open-session-help") + process.run_binary_command_async({ + state.binary_path, + "--session-action", + '{"actionId":"openSessionHelp"}', + }, function(ok, result, error) + if ok then + return + end + local reason = error or (result and result.stderr) or "unknown error" + subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason)) + show_osd("Session action failed") + end) end) end diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 8b5272f3..c20db48e 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'sub-scale-by-window', 'osd-height', 'osd-dimensions', - 'sub-text-ass', + 'sub-text/ass', 'sub-border-size', 'sub-shadow-offset', 'sub-ass-override', @@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ request_id: MPV_REQUEST_ID_SUBTEXT, }, { - command: ['get_property', 'sub-text-ass'], + command: ['get_property', 'sub-text/ass'], request_id: MPV_REQUEST_ID_SUBTEXT_ASS, }, { diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 97079b04..11991db3 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -129,6 +129,28 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); }); +test('dispatchMpvProtocolMessage emits ASS subtitle text from the current mpv property', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-text/ass', data: '{\\b1}字幕' }, + deps, + ); + + assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]); +}); + +test('dispatchMpvProtocolMessage emits ASS subtitle text from the legacy mpv property', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sub-text-ass', data: '{\\b1}字幕' }, + deps, + ); + + assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]); +}); + test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { const { deps, state } = createDeps({ emitSubtitleTrackChange: (payload) => state.events.push(payload), diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 2d29f806..c8c8a22f 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage( isOverlayVisible: overlayVisible, }); deps.setCurrentSubText(nextSubText); - } else if (msg.name === 'sub-text-ass') { + } else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') { deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); } else if (msg.name === 'sub-start') { deps.setCurrentSubStart((msg.data as number) || 0); diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index b665725e..e8bbae20 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -505,6 +505,17 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re (command as { command: unknown[] }).command[1] === 1 && (command as { command: unknown[] }).command[2] === 'sub-text', ); + const hasAssSubtitleSubscription = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command[0] === 'observe_property' && + (command as { command: unknown[] }).command[2] === 'sub-text/ass', + ); + const hasDeprecatedAssSubtitleProperty = commands.some( + (command) => + Array.isArray((command as { command: unknown[] }).command) && + (command as { command: unknown[] }).command.includes('sub-text-ass'), + ); const hasPathRequest = commands.some( (command) => Array.isArray((command as { command: unknown[] }).command) && @@ -514,6 +525,8 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re assert.equal(hasSecondaryVisibilityReset, true); assert.equal(hasTrackSubscription, true); + assert.equal(hasAssSubtitleSubscription, true); + assert.equal(hasDeprecatedAssSubtitleProperty, false); assert.equal(hasPathRequest, true); }); diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index 2942c4fa..d0f6c2e8 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com assert.equal(options.webPreferences?.backgroundThrottling, false); }); +test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => { + const visibleOptions = buildOverlayWindowOptions('visible', { + isDev: false, + platform: 'darwin', + yomitanSession: null, + }); + const modalOptions = buildOverlayWindowOptions('modal', { + isDev: false, + platform: 'darwin', + yomitanSession: null, + }); + + assert.equal(visibleOptions.type, undefined); + assert.equal(modalOptions.type, 'panel'); +}); + +test('non-macOS modal overlay remains a regular window', () => { + const options = buildOverlayWindowOptions('modal', { + isDev: false, + platform: 'linux', + yomitanSession: null, + }); + + assert.equal(options.type, undefined); +}); + test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); diff --git a/src/core/services/overlay-window-flags.ts b/src/core/services/overlay-window-flags.ts index d8c53372..91ca95cf 100644 --- a/src/core/services/overlay-window-flags.ts +++ b/src/core/services/overlay-window-flags.ts @@ -1 +1,2 @@ export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; +export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded'; diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index 6ace7328..2757fd5b 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -12,15 +12,17 @@ export function buildOverlayWindowOptions( options: { isDev: boolean; linuxX11FullscreenOverlay?: boolean; + platform?: NodeJS.Platform; yomitanSession?: Session | null; }, ): BrowserWindowConstructorOptions { - const showNativeDebugFrame = process.platform === 'win32' && options.isDev; - const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible'; + const platform = options.platform ?? process.platform; + const showNativeDebugFrame = platform === 'win32' && options.isDev; + const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible'; const isLinuxFullscreenOverlay = isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true; const shouldStartAlwaysOnTop = - !(process.platform === 'win32' && kind === 'visible') && + !(platform === 'win32' && kind === 'visible') && (!isLinuxVisibleOverlay || isLinuxFullscreenOverlay); const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay; @@ -41,7 +43,10 @@ export function buildOverlayWindowOptions( hasShadow: false, focusable: !isLinuxFullscreenOverlay, acceptFirstMouse: true, - ...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}), + // A macOS panel is a fullscreen auxiliary window, so modal surfaces stay on the + // active mpv Space instead of opening on SubMiner's last regular desktop. + ...(platform === 'darwin' && kind === 'modal' ? { type: 'panel' as const } : {}), + ...(platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}), webPreferences: { preload: path.join(__dirname, '..', '..', 'preload.js'), contextIsolation: true, diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index f3ad3258..4a51d78b 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -16,7 +16,10 @@ import { } 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'; +import { + OVERLAY_WINDOW_CONTENT_READY_FLAG, + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG, +} from './overlay-window-flags'; export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; const logger = createLogger('main:overlay-window'); @@ -133,6 +136,9 @@ export function createOverlayWindow( (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ OVERLAY_WINDOW_CONTENT_READY_FLAG ] = false; + (window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[ + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG + ] = false; if (!(process.platform === 'win32' && kind === 'visible')) { options.ensureOverlayWindowLevel(window); @@ -144,11 +150,20 @@ export function createOverlayWindow( }); window.webContents.on('did-finish-load', () => { + (window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[ + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG + ] = true; window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); options.onWindowDidFinishLoad?.(); }); + window.webContents.on('did-start-loading', () => { + (window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[ + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG + ] = false; + }); + window.webContents.on('page-title-updated', (event) => { event.preventDefault(); window.setTitle(OVERLAY_WINDOW_TITLES[kind]); diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index bc2d6c8e..4129f04e 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey: export function buildStatsWindowOptions(options: { preloadPath: string; bounds?: WindowGeometry | null; + platform?: NodeJS.Platform; }): BrowserWindowConstructorOptions { + const platform = options.platform ?? process.platform; return { title: STATS_WINDOW_TITLE, x: options.bounds?.x, @@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: { focusable: true, acceptFirstMouse: true, fullscreenable: false, + // Panels join fullscreen Spaces on macOS without moving the user back to the + // desktop where SubMiner last owned a regular application window. + ...(platform === 'darwin' ? { type: 'panel' as const } : {}), backgroundColor: '#24273a', show: false, webPreferences: { @@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: { }; } +export function shouldPresentStatsWindowAfterLoad( + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === 'darwin'; +} + export function resolveStatsWindowOuterBoundsForContent( window: StatsWindowBoundsController, target: WindowGeometry, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index 748926a1..b49ebe40 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -12,6 +12,7 @@ import { scheduleStatsWindowPostShowReconciles, showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, + shouldPresentStatsWindowAfterLoad, } from './stats-window-runtime'; test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => { @@ -40,6 +41,30 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w assert.equal(options.webPreferences?.sandbox, true); }); +test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => { + const options = buildStatsWindowOptions({ + preloadPath: '/tmp/preload-stats.js', + platform: 'darwin', + }); + + assert.equal(options.type, 'panel'); +}); + +test('buildStatsWindowOptions remains a regular window off macOS', () => { + const options = buildStatsWindowOptions({ + preloadPath: '/tmp/preload-stats.js', + platform: 'linux', + }); + + assert.equal(options.type, undefined); +}); + +test('stats panels present after document load on macOS', () => { + assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true); + assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false); + assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false); +}); + test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => { assert.equal( shouldHideStatsWindowForInput( diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 3024222d..3715c925 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -13,6 +13,7 @@ import { scheduleStatsWindowPostShowReconciles, showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, + shouldPresentStatsWindowAfterLoad, STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; @@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { options.onVisibilityChanged?.(false); } }); - statsWindow.once('ready-to-show', () => { + const showInitialStatsWindow = () => { if (!statsWindow) return; showStatsWindow(statsWindow, options); - }); + }; + if (shouldPresentStatsWindowAfterLoad()) { + statsWindow.webContents.once('did-finish-load', showInitialStatsWindow); + } else { + statsWindow.once('ready-to-show', showInitialStatsWindow); + } statsWindow.on('blur', () => { if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) { diff --git a/src/core/services/subtitle-line-dedup-gate.ts b/src/core/services/subtitle-line-dedup-gate.ts index b43ba157..f0d565b8 100644 --- a/src/core/services/subtitle-line-dedup-gate.ts +++ b/src/core/services/subtitle-line-dedup-gate.ts @@ -15,7 +15,7 @@ * layer that keeps the two views consistent by construction. * 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted) * fall back to timing alone. No authoring metadata is available live -- mpv delivers - * `sub-text-ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the + * `sub-text/ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the * previous event -- which puts this layer in the same position as the SRT path in * `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds. */ diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index a41a100e..7465c372 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -24,10 +24,17 @@ import { shouldForwardStartupArgvViaAppControl, applyBackgroundBootstrapCommandLineSwitches, applyEarlyLinuxCommandLineSwitches, + resolveAppControlHandoffTimeoutMs, resolveLinuxPasswordStoreValue, spawnDetachedApp, } from './main-entry-runtime'; +test('app-control handoffs allow for macOS application activation latency', () => { + assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000); + assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500); + assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500); +}); + test('detached app launch policy stays in the startup runtime utilities', () => { const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8'); const runtimeSource = fs.readFileSync( diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 34bcfe10..a344d6b8 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -14,6 +14,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const MAX_TRANSPORTED_APP_ARGS = 256; const APP_NAME = 'SubMiner'; +const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500; +const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000; const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([ '--alang', '--audio-file', @@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl( return hasExplicitCommand(args); } +export function resolveAppControlHandoffTimeoutMs( + platform: NodeJS.Platform = process.platform, +): number { + return platform === 'darwin' + ? MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS + : DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS; +} + function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; if (rawCount === undefined) { diff --git a/src/main-entry.ts b/src/main-entry.ts index 4b53efbc..09a9cbe7 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -9,6 +9,7 @@ import { normalizeLaunchMpvTargets, normalizeStartupArgv, applyEarlyLinuxCommandLineSwitches, + resolveAppControlHandoffTimeoutMs, sanitizeStartupEnv, sanitizeBackgroundEnv, sanitizeHelpEnv, @@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise { const result = await sendAppControlCommand(process.argv, { configDir: userDataPath, - timeoutMs: 500, + timeoutMs: resolveAppControlHandoffTimeoutMs(), }); if (result.ok) { app.exit(0); diff --git a/src/main.ts b/src/main.ts index 8ca54a75..990e9f23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5009,6 +5009,9 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void { function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); + if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) { + overlayModalRuntime.primeModalWindow(); + } appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( refreshCurrentSubtitleAfterKnownWordUpdate, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index c22169d6..c030adbb 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -16,6 +16,7 @@ type MockWindow = { loading: boolean; url: string; contentReady: boolean; + documentLoaded: boolean; loadCallbacks: Array<() => void>; readyToShowCallbacks: Array<() => void>; }; @@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & { getShowCount: () => number; getHideCount: () => number; show: () => void; + showInactive: () => void; hide: () => void; destroy: () => void; focus: () => void; @@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & { loading: false, url: 'file:///overlay/index.html?layer=modal', contentReady: true, + documentLoaded: true, loadCallbacks: [], readyToShowCallbacks: [], }; @@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & { state.visible = true; state.showCount += 1; }, + showInactive: () => { + state.visible = true; + state.showCount += 1; + }, hide: () => { state.visible = false; state.hideCount += 1; @@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & { state.focused = true; }, emitDidFinishLoad: () => { + state.documentLoaded = true; + ( + window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } + ).__subminerOverlayDocumentLoaded = true; const callbacks = state.loadCallbacks.splice(0); for (const callback of callbacks) { callback(); @@ -197,9 +208,22 @@ function createMockWindow(): MockWindow & { }, }); + Object.defineProperty(window, 'documentLoaded', { + get: () => state.documentLoaded, + set: (value: boolean) => { + state.documentLoaded = value; + ( + window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } + ).__subminerOverlayDocumentLoaded = value; + }, + }); + ( window as typeof window & { __subminerOverlayContentReady?: boolean } ).__subminerOverlayContentReady = state.contentReady; + ( + window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } + ).__subminerOverlayDocumentLoaded = state.documentLoaded; return window; } @@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () => assert.deepEqual(window.sent, [['jimaku:open']]); }); +for (const platform of ['darwin', 'win32'] as const) { + test(`primeModalWindow creates and warms a hidden modal on ${platform}`, () => { + const modalWindow = createMockWindow(); + modalWindow.loading = true; + modalWindow.url = ''; + modalWindow.contentReady = false; + modalWindow.documentLoaded = false; + let currentModal: ReturnType | null = null; + let createCalls = 0; + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => currentModal as never, + createModalWindow: () => { + createCalls += 1; + currentModal = modalWindow; + return modalWindow as never; + }, + getModalGeometry: () => ({ x: 1, y: 2, width: 300, height: 200 }), + setModalWindowBounds: () => {}, + }, + { platform }, + ); + + assert.equal(runtime.primeModalWindow(), true); + assert.equal(createCalls, 1); + assert.equal(modalWindow.isVisible(), false); + + modalWindow.loading = false; + modalWindow.url = 'file:///overlay/index.html?layer=modal'; + modalWindow.emitDidFinishLoad(); + modalWindow.emitReadyToShow(); + modalWindow.contentReady = true; + + assert.equal( + runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + restoreOnModalClose: 'session-help', + preferModalWindow: true, + }), + true, + ); + assert.equal(createCalls, 1); + assert.equal(modalWindow.isVisible(), true); + assert.deepEqual(modalWindow.sent, [['session-help:open']]); + }); +} + +test('primeModalWindow leaves Linux modal creation lazy', () => { + let createCalls = 0; + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => null, + createModalWindow: () => { + createCalls += 1; + return createMockWindow() as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform: 'linux' }, + ); + + assert.equal(runtime.primeModalWindow(), false); + assert.equal(createCalls, 0); +}); + test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => { const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co window.loading = false; window.url = 'file:///overlay/index.html?layer=modal'; window.emitDidFinishLoad(); - assert.deepEqual(window.sent, []); + assert.deepEqual(window.sent, [['runtime-options:open']]); window.contentReady = true; window.emitReadyToShow(); @@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co assert.equal(window.getShowCount(), 1); }); -test('handleOverlayModalClosed hides modal window only after all pending modals close', () => { +test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => { const window = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform: 'darwin' }, + ); runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', @@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals assert.equal(window.isDestroyed(), false); runtime.handleOverlayModalClosed('subsync'); - assert.equal(window.isDestroyed(), true); + assert.equal(window.isDestroyed(), false); + assert.equal(window.isVisible(), false); + assert.equal(window.ignoreMouseEvents, true); }); test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { @@ -464,6 +560,46 @@ test('modal window path restores visible main overlay before modal input deactiv assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']); }); +test('macOS maps a new modal panel before focusing SubMiner and hiding the subtitle overlay', () => { + const mainWindow = createMockWindow(); + mainWindow.visible = true; + const modalWindow = createMockWindow(); + const events: string[] = []; + const showInactive = modalWindow.showInactive; + modalWindow.showInactive = () => { + events.push('show-inactive'); + showInactive(); + }; + const hideMainWindow = mainWindow.hide; + mainWindow.hide = () => { + events.push('hide-main'); + hideMainWindow(); + }; + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => mainWindow as never, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { + platform: 'darwin', + focusApplication: () => events.push('focus-application'), + }, + ); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + preferModalWindow: true, + }); + runtime.notifyOverlayModalOpened('runtime-options'); + + assert.deepEqual(events, ['show-inactive', 'focus-application', 'hide-main']); + assert.equal(modalWindow.isVisible(), true); + assert.equal(mainWindow.isVisible(), false); +}); + test('modal window path runs final close handoff before modal input deactivates', () => { const mainWindow = createMockWindow(); mainWindow.visible = true; @@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted', assert.deepEqual(state, []); }); -test('handleOverlayModalClosed destroys modal window for single kiku modal', () => { +test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => { const window = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform: 'darwin' }, + ); runtime.sendToActiveOverlayWindow( 'kiku:field-grouping-open', @@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', () ); runtime.handleOverlayModalClosed('kiku'); - assert.equal(window.isDestroyed(), true); + assert.equal(window.isDestroyed(), false); + assert.equal(window.isVisible(), false); + assert.equal(window.ignoreMouseEvents, true); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); }); @@ -719,8 +860,10 @@ test('modal fallback reveal skips showing window when content is not ready', asy assert.equal(window.ignoreMouseEvents, false); }); -test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => { +test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => { const window = createMockWindow(); + window.loading = true; + window.url = ''; window.contentReady = false; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering assert.equal(sent, true); assert.deepEqual(window.sent, []); + window.loading = false; + window.url = 'file:///overlay/index.html?layer=modal'; window.emitDidFinishLoad(); - assert.deepEqual(window.sent, []); + assert.deepEqual(window.sent, [['runtime-options:open']]); window.contentReady = true; window.emitReadyToShow(); assert.deepEqual(window.sent, [['runtime-options:open']]); }); +test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => { + const window = createMockWindow(); + window.contentReady = false; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + throw new Error('modal window should not be created when already present'); + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }), + true, + ); + assert.deepEqual(window.sent, [['runtime-options:open']]); + + window.contentReady = true; + window.emitReadyToShow(); + assert.deepEqual(window.sent, [['runtime-options:open']]); +}); + +test('sendToActiveOverlayWindow does not infer document readiness from a pending file URL', () => { + const window = createMockWindow(); + window.contentReady = false; + window.documentLoaded = false; + window.loading = false; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + throw new Error('modal window should not be created when already present'); + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }), + true, + ); + assert.deepEqual(window.sent, []); + + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, [['runtime-options:open']]); +}); + +test('sendToActiveOverlayWindow rejects stale content readiness during document reload', () => { + const window = createMockWindow(); + window.contentReady = true; + window.documentLoaded = false; + window.loading = false; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + throw new Error('modal window should not be created when already present'); + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + restoreOnModalClose: 'session-help', + }), + true, + ); + assert.deepEqual(window.sent, []); + + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, [['session-help:open']]); +}); + test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => { const window = createMockWindow(); + window.loading = true; + window.url = ''; window.contentReady = false; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -773,61 +1000,64 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef ); assert.deepEqual(window.sent, []); + window.loading = false; + window.url = 'file:///overlay/index.html?layer=modal'; window.emitDidFinishLoad(); - assert.deepEqual(window.sent, []); + assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); window.contentReady = true; window.emitReadyToShow(); assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); }); -test('modal reopen creates a fresh window after close destroys the previous one', () => { - const firstWindow = createMockWindow(); - const secondWindow = createMockWindow(); - let currentModal: ReturnType | null = firstWindow; +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; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => currentModal as never, - createModalWindow: () => { - currentModal = secondWindow; - return secondWindow 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; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { platform }, + ); + + 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(firstWindow.isDestroyed(), true); - - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - - assert.equal(sent, true); - assert.equal(currentModal, secondWindow); - assert.equal(secondWindow.getShowCount(), 0); -}); - -test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => { - const firstWindow = createMockWindow(); - const secondWindow = createMockWindow(); - let currentModal: ReturnType | null = firstWindow; +test('modal reopen on the warm window notifies state change for each lifecycle', () => { + const modalWindow = createMockWindow(); const state: boolean[] = []; const runtime = createOverlayModalRuntimeService( { getMainWindow: () => null, - getModalWindow: () => currentModal as never, - createModalWindow: () => { - currentModal = secondWindow; - return secondWindow as never; - }, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), setModalWindowBounds: () => {}, }, @@ -835,6 +1065,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif onModalStateChange: (active: boolean): void => { state.push(active); }, + platform: 'darwin', }, ); @@ -845,7 +1076,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif runtime.handleOverlayModalClosed('runtime-options'); assert.deepEqual(state, [true, false]); - assert.equal(firstWindow.isDestroyed(), true); + assert.equal(modalWindow.isDestroyed(), false); runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', @@ -853,7 +1084,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif runtime.notifyOverlayModalOpened('runtime-options'); assert.deepEqual(state, [true, false, true]); - assert.equal(currentModal, secondWindow); + assert.equal(modalWindow.isVisible(), true); }); test('visible stale modal window is made interactive again before reopening', () => { diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index d35c98e6..d1f3bd9f 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -2,7 +2,10 @@ 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'; +import { + OVERLAY_WINDOW_CONTENT_READY_FLAG, + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG, +} from '../core/services/overlay-window-flags'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; // The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire @@ -39,6 +42,7 @@ export interface OverlayWindowResolver { } export interface OverlayModalRuntime { + primeModalWindow: () => boolean; sendToActiveOverlayWindow: ( channel: string, payload?: unknown, @@ -59,6 +63,8 @@ export interface OverlayModalRuntime { type RevealFallbackHandle = NonNullable[0]>; export interface OverlayModalRuntimeOptions { + platform?: NodeJS.Platform; + focusApplication?: () => void; onModalStateChange?: (isActive: boolean) => void; onFinalModalClosed?: () => void; scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle; @@ -79,6 +85,10 @@ export function createOverlayModalRuntimeService( let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; const modalWindowBoundsReconcileGenerations = new WeakMap(); + const modalWindowPrimeListenersRegistered = new WeakSet(); + const platform = options.platform ?? process.platform; + const keepModalWindowWarm = platform === 'darwin' || platform === 'win32'; + const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus; const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); const clearRevealFallback = (timeout: RevealFallbackHandle): void => @@ -134,7 +144,11 @@ export function createOverlayModalRuntimeService( } const overlayWindow = window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean; }; + if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) { + return false; + } if ( typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' && overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true @@ -145,6 +159,50 @@ export function createOverlayModalRuntimeService( return currentURL !== '' && currentURL !== 'about:blank'; }; + const isWindowLoadedForIpc = (window: BrowserWindow): boolean => { + if (window.isDestroyed() || window.webContents.isLoading()) { + return false; + } + const overlayWindow = window as BrowserWindow & { + [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean; + }; + if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) { + return false; + } + const currentURL = window.webContents.getURL(); + return currentURL !== '' && currentURL !== 'about:blank'; + }; + + const markModalWindowPrimed = (window: BrowserWindow): void => { + if (deps.getModalWindow() !== window || !isWindowLoadedForIpc(window)) { + return; + } + modalWindowPrimedForImmediateShow = true; + }; + + const primeModalWindow = (): boolean => { + if (!keepModalWindowWarm) { + return false; + } + const modalWindow = resolveModalWindow(); + if (!modalWindow) { + return false; + } + + deps.setModalWindowBounds(deps.getModalGeometry()); + if (isWindowReadyForIpc(modalWindow)) { + modalWindowPrimedForImmediateShow = true; + return true; + } + + if (!modalWindowPrimeListenersRegistered.has(modalWindow)) { + modalWindowPrimeListenersRegistered.add(modalWindow); + modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow)); + modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow)); + } + return true; + }; + const elevateModalWindow = (window: BrowserWindow): void => { if (window.isDestroyed()) return; window.setAlwaysOnTop(true, 'screen-saver', 3); @@ -205,16 +263,19 @@ export function createOverlayModalRuntimeService( } let delivered = false; - const deliverWhenReady = (): void => { - if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) { + const deliver = (isReady: () => boolean): void => { + if (delivered || window.isDestroyed() || !isReady()) { return; } delivered = true; sendNow(window); }; - window.webContents.once('did-finish-load', deliverWhenReady); - window.once('ready-to-show', deliverWhenReady); + // A hidden macOS panel may not emit ready-to-show until it is presented. The + // renderer can safely receive IPC as soon as its document has finished loading. + window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window))); + window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window))); + deliver(() => isWindowLoadedForIpc(window)); }; const showModalWindow = ( @@ -224,8 +285,15 @@ export function createOverlayModalRuntimeService( } = { passThroughMouseEvents: false }, ): void => { setWindowFocusable(window); - requestOverlayApplicationFocus(); - if (!window.isVisible()) { + const wasVisible = window.isVisible(); + if (!wasVisible && platform === 'darwin') { + // Mapping the panel first keeps it attached to mpv's active fullscreen Space. + window.showInactive(); + focusApplication(); + } else { + focusApplication(); + } + if (!wasVisible && platform !== 'darwin') { window.show(); } elevateModalWindow(window); @@ -245,11 +313,11 @@ export function createOverlayModalRuntimeService( const ensureModalWindowInteractive = (window: BrowserWindow): void => { setWindowFocusable(window); - requestOverlayApplicationFocus(); window.setIgnoreMouseEvents(false); elevateModalWindow(window); if (window.isVisible()) { + focusApplication(); window.focus(); window.webContents.focus(); const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window); @@ -447,9 +515,15 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); if (modalWindow && !modalWindow.isDestroyed()) { - modalWindow.destroy(); + if (keepModalWindowWarm) { + modalWindow.setIgnoreMouseEvents(true, { forward: true }); + modalWindow.hide(); + markModalWindowPrimed(modalWindow); + } else { + modalWindow.destroy(); + modalWindowPrimedForImmediateShow = false; + } } - modalWindowPrimedForImmediateShow = false; mainWindowMousePassthroughForcedByModal = false; setMainWindowVisibilityForModal(false); try { @@ -478,17 +552,16 @@ export function createOverlayModalRuntimeService( } const modalWindow = deps.getModalWindow(); + if (targetWindow.isVisible()) { + ensureModalWindowInteractive(targetWindow); + } else { + showModalWindow(targetWindow); + } + if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) { setMainWindowMousePassthroughForModal(true); setMainWindowVisibilityForModal(true); } - - if (targetWindow.isVisible()) { - ensureModalWindowInteractive(targetWindow); - return; - } - - showModalWindow(targetWindow); }; const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise => @@ -515,6 +588,7 @@ export function createOverlayModalRuntimeService( }); return { + primeModalWindow, sendToActiveOverlayWindow, openRuntimeOptionsPalette, openJimaku,