fix(overlay): keep macOS modal windows on fullscreen Spaces (#200)

This commit is contained in:
2026-08-15 21:43:26 -07:00
committed by GitHub
parent 2174e689a2
commit a02c33dac4
22 changed files with 568 additions and 98 deletions
@@ -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.
+3
View File
@@ -106,9 +106,12 @@ function M.create(ctx)
local function get_subtitle_ass_property() local function get_subtitle_ass_property()
local ass_text = mp.get_property("sub-text/ass") local ass_text = mp.get_property("sub-text/ass")
if ass_text ~= nil then
if type(ass_text) == "string" and ass_text ~= "" then if type(ass_text) == "string" and ass_text ~= "" then
return ass_text return ass_text
end end
return nil
end
ass_text = mp.get_property("sub-text-ass") ass_text = mp.get_property("sub-text-ass")
if type(ass_text) == "string" and ass_text ~= "" then if type(ass_text) == "string" and ass_text ~= "" then
return ass_text return ass_text
+3 -3
View File
@@ -232,7 +232,7 @@ function M.create(ctx)
elseif action_id == "triggerFieldGrouping" then elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" } return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then elseif action_id == "triggerSubsync" then
return { "--trigger-subsync" } return { "--session-action", '{"actionId":"triggerSubsync"}' }
elseif action_id == "mineSentence" then elseif action_id == "mineSentence" then
return { "--mine-sentence" } return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then elseif action_id == "mineSentenceMultiple" then
@@ -251,7 +251,7 @@ function M.create(ctx)
elseif action_id == "markWatched" then elseif action_id == "markWatched" then
return { "--mark-watched" } return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" } return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
elseif action_id == "openJimaku" then elseif action_id == "openJimaku" then
return { "--open-jimaku" } return { "--open-jimaku" }
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
@@ -259,7 +259,7 @@ function M.create(ctx)
elseif action_id == "openYoutubePicker" then elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" } return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then elseif action_id == "openSessionHelp" then
return { "--open-session-help" } return { "--session-action", '{"actionId":"openSessionHelp"}' }
elseif action_id == "openCharacterDictionaryManager" then elseif action_id == "openCharacterDictionaryManager" then
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' } return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
elseif action_id == "openControllerSelect" then elseif action_id == "openControllerSelect" then
+13 -1
View File
@@ -4,6 +4,7 @@ function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local input = ctx.input local input = ctx.input
local process = ctx.process local process = ctx.process
local state = ctx.state
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
@@ -93,7 +94,18 @@ function M.create(ctx)
if not ensure_binary_for_menu() then if not ensure_binary_for_menu() then
return return
end 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)
end end
+2 -2
View File
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'sub-scale-by-window', 'sub-scale-by-window',
'osd-height', 'osd-height',
'osd-dimensions', 'osd-dimensions',
'sub-text-ass', 'sub-text/ass',
'sub-border-size', 'sub-border-size',
'sub-shadow-offset', 'sub-shadow-offset',
'sub-ass-override', 'sub-ass-override',
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
request_id: MPV_REQUEST_ID_SUBTEXT, 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, request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
}, },
{ {
+22
View File
@@ -129,6 +129,28 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); 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 () => { test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({ const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload), emitSubtitleTrackChange: (payload) => state.events.push(payload),
+1 -1
View File
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
isOverlayVisible: overlayVisible, isOverlayVisible: overlayVisible,
}); });
deps.setCurrentSubText(nextSubText); 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) || '' }); deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
} else if (msg.name === 'sub-start') { } else if (msg.name === 'sub-start') {
deps.setCurrentSubStart((msg.data as number) || 0); deps.setCurrentSubStart((msg.data as number) || 0);
+13
View File
@@ -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[1] === 1 &&
(command as { command: unknown[] }).command[2] === 'sub-text', (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( const hasPathRequest = commands.some(
(command) => (command) =>
Array.isArray((command as { command: unknown[] }).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(hasSecondaryVisibilityReset, true);
assert.equal(hasTrackSubscription, true); assert.equal(hasTrackSubscription, true);
assert.equal(hasAssSubtitleSubscription, true);
assert.equal(hasDeprecatedAssSubtitleProperty, false);
assert.equal(hasPathRequest, true); assert.equal(hasPathRequest, true);
}); });
@@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.webPreferences?.backgroundThrottling, false); 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', () => { test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
@@ -1 +1,2 @@
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
+9 -4
View File
@@ -12,15 +12,17 @@ export function buildOverlayWindowOptions(
options: { options: {
isDev: boolean; isDev: boolean;
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
platform?: NodeJS.Platform;
yomitanSession?: Session | null; yomitanSession?: Session | null;
}, },
): BrowserWindowConstructorOptions { ): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev; const platform = options.platform ?? process.platform;
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible'; const showNativeDebugFrame = platform === 'win32' && options.isDev;
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay = const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true; isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop = const shouldStartAlwaysOnTop =
!(process.platform === 'win32' && kind === 'visible') && !(platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay); (!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay; const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
@@ -41,7 +43,10 @@ export function buildOverlayWindowOptions(
hasShadow: false, hasShadow: false,
focusable: !isLinuxFullscreenOverlay, focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true, 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: { webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'), preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true, contextIsolation: true,
+16 -1
View File
@@ -16,7 +16,10 @@ import {
} from './hyprland-window-placement'; } from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options'; import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; 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'; export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
const logger = createLogger('main:overlay-window'); const logger = createLogger('main:overlay-window');
@@ -133,6 +136,9 @@ export function createOverlayWindow(
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false; ] = false;
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
] = false;
if (!(process.platform === 'win32' && kind === 'visible')) { if (!(process.platform === 'win32' && kind === 'visible')) {
options.ensureOverlayWindowLevel(window); options.ensureOverlayWindowLevel(window);
@@ -144,11 +150,20 @@ export function createOverlayWindow(
}); });
window.webContents.on('did-finish-load', () => { 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]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.(); 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) => { window.webContents.on('page-title-updated', (event) => {
event.preventDefault(); event.preventDefault();
window.setTitle(OVERLAY_WINDOW_TITLES[kind]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
+11
View File
@@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
export function buildStatsWindowOptions(options: { export function buildStatsWindowOptions(options: {
preloadPath: string; preloadPath: string;
bounds?: WindowGeometry | null; bounds?: WindowGeometry | null;
platform?: NodeJS.Platform;
}): BrowserWindowConstructorOptions { }): BrowserWindowConstructorOptions {
const platform = options.platform ?? process.platform;
return { return {
title: STATS_WINDOW_TITLE, title: STATS_WINDOW_TITLE,
x: options.bounds?.x, x: options.bounds?.x,
@@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: {
focusable: true, focusable: true,
acceptFirstMouse: true, acceptFirstMouse: true,
fullscreenable: false, 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', backgroundColor: '#24273a',
show: false, show: false,
webPreferences: { webPreferences: {
@@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: {
}; };
} }
export function shouldPresentStatsWindowAfterLoad(
platform: NodeJS.Platform = process.platform,
): boolean {
return platform === 'darwin';
}
export function resolveStatsWindowOuterBoundsForContent( export function resolveStatsWindowOuterBoundsForContent(
window: StatsWindowBoundsController, window: StatsWindowBoundsController,
target: WindowGeometry, target: WindowGeometry,
+25
View File
@@ -12,6 +12,7 @@ import {
scheduleStatsWindowPostShowReconciles, scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog, showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
} from './stats-window-runtime'; } from './stats-window-runtime';
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => { 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); 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', () => { test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
assert.equal( assert.equal(
shouldHideStatsWindowForInput( shouldHideStatsWindowForInput(
+8 -2
View File
@@ -13,6 +13,7 @@ import {
scheduleStatsWindowPostShowReconciles, scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog, showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
STATS_WINDOW_TITLE, STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
@@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
options.onVisibilityChanged?.(false); options.onVisibilityChanged?.(false);
} }
}); });
statsWindow.once('ready-to-show', () => { const showInitialStatsWindow = () => {
if (!statsWindow) return; if (!statsWindow) return;
showStatsWindow(statsWindow, options); showStatsWindow(statsWindow, options);
}); };
if (shouldPresentStatsWindowAfterLoad()) {
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
} else {
statsWindow.once('ready-to-show', showInitialStatsWindow);
}
statsWindow.on('blur', () => { statsWindow.on('blur', () => {
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) { if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
@@ -15,7 +15,7 @@
* layer that keeps the two views consistent by construction. * layer that keeps the two views consistent by construction.
* 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted) * 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 * 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 * 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. * `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
*/ */
+7
View File
@@ -24,10 +24,17 @@ import {
shouldForwardStartupArgvViaAppControl, shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches, applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches, applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
resolveLinuxPasswordStoreValue, resolveLinuxPasswordStoreValue,
spawnDetachedApp, spawnDetachedApp,
} from './main-entry-runtime'; } 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', () => { 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 entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
const runtimeSource = fs.readFileSync( const runtimeSource = fs.readFileSync(
+10
View File
@@ -14,6 +14,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256; const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner'; 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([ const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang', '--alang',
'--audio-file', '--audio-file',
@@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl(
return hasExplicitCommand(args); 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 { function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) { if (rawCount === undefined) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches, applyEarlyLinuxCommandLineSwitches,
resolveAppControlHandoffTimeoutMs,
sanitizeStartupEnv, sanitizeStartupEnv,
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
@@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
const result = await sendAppControlCommand(process.argv, { const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath, configDir: userDataPath,
timeoutMs: 500, timeoutMs: resolveAppControlHandoffTimeoutMs(),
}); });
if (result.ok) { if (result.ok) {
app.exit(0); app.exit(0);
+3
View File
@@ -5009,6 +5009,9 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
overlayModalRuntime.primeModalWindow();
}
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAfterKnownWordUpdate,
+267 -36
View File
@@ -16,6 +16,7 @@ type MockWindow = {
loading: boolean; loading: boolean;
url: string; url: string;
contentReady: boolean; contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>; loadCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>; readyToShowCallbacks: Array<() => void>;
}; };
@@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & {
getShowCount: () => number; getShowCount: () => number;
getHideCount: () => number; getHideCount: () => number;
show: () => void; show: () => void;
showInactive: () => void;
hide: () => void; hide: () => void;
destroy: () => void; destroy: () => void;
focus: () => void; focus: () => void;
@@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & {
loading: false, loading: false,
url: 'file:///overlay/index.html?layer=modal', url: 'file:///overlay/index.html?layer=modal',
contentReady: true, contentReady: true,
documentLoaded: true,
loadCallbacks: [], loadCallbacks: [],
readyToShowCallbacks: [], readyToShowCallbacks: [],
}; };
@@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & {
state.visible = true; state.visible = true;
state.showCount += 1; state.showCount += 1;
}, },
showInactive: () => {
state.visible = true;
state.showCount += 1;
},
hide: () => { hide: () => {
state.visible = false; state.visible = false;
state.hideCount += 1; state.hideCount += 1;
@@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & {
state.focused = true; state.focused = true;
}, },
emitDidFinishLoad: () => { emitDidFinishLoad: () => {
state.documentLoaded = true;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = true;
const callbacks = state.loadCallbacks.splice(0); const callbacks = state.loadCallbacks.splice(0);
for (const callback of callbacks) { for (const callback of callbacks) {
callback(); 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 } window as typeof window & { __subminerOverlayContentReady?: boolean }
).__subminerOverlayContentReady = state.contentReady; ).__subminerOverlayContentReady = state.contentReady;
(
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
).__subminerOverlayDocumentLoaded = state.documentLoaded;
return window; return window;
} }
@@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
assert.deepEqual(window.sent, [['jimaku:open']]); 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<typeof createMockWindow> | 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', () => { test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
window.loading = false; window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal'; window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, [['runtime-options:open']]);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
@@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
assert.equal(window.getShowCount(), 1); 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 window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => window as never, createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}); },
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
assert.equal(window.isDestroyed(), false); assert.equal(window.isDestroyed(), false);
runtime.handleOverlayModalClosed('subsync'); 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', () => { 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']); 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', () => { test('modal window path runs final close handoff before modal input deactivates', () => {
const mainWindow = createMockWindow(); const mainWindow = createMockWindow();
mainWindow.visible = true; mainWindow.visible = true;
@@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
assert.deepEqual(state, []); 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 window = createMockWindow();
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => window as never, getModalWindow: () => window as never,
createModalWindow: () => window as never, createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}); },
{ platform: 'darwin' },
);
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-open', 'kiku:field-grouping-open',
@@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
); );
runtime.handleOverlayModalClosed('kiku'); 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); 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); 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(); const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false; window.contentReady = false;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
assert.equal(sent, true); assert.equal(sent, true);
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, [['runtime-options:open']]);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open']]); 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', () => { test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
const window = createMockWindow(); const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false; window.contentReady = false;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
@@ -773,29 +1000,34 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
); );
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, []);
window.loading = false;
window.url = 'file:///overlay/index.html?layer=modal';
window.emitDidFinishLoad(); window.emitDidFinishLoad();
assert.deepEqual(window.sent, []); assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
window.contentReady = true; window.contentReady = true;
window.emitReadyToShow(); window.emitReadyToShow();
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
}); });
test('modal reopen creates a fresh window after close destroys the previous one', () => { for (const platform of ['darwin', 'win32'] as const) {
const firstWindow = createMockWindow(); test(`modal reopen reuses the warm window and shows it immediately on ${platform}`, () => {
const secondWindow = createMockWindow(); const modalWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow; let createCalls = 0;
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => currentModal as never, getModalWindow: () => modalWindow as never,
createModalWindow: () => { createModalWindow: () => {
currentModal = secondWindow; createCalls += 1;
return secondWindow as never; return modalWindow as never;
}, },
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}); },
{ platform },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -803,31 +1035,29 @@ test('modal reopen creates a fresh window after close destroys the previous one'
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options'); runtime.handleOverlayModalClosed('runtime-options');
assert.equal(firstWindow.isDestroyed(), true); assert.equal(modalWindow.isDestroyed(), false);
assert.equal(modalWindow.isVisible(), false);
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
}); });
assert.equal(sent, true); assert.equal(sent, true);
assert.equal(currentModal, secondWindow); assert.equal(createCalls, 0);
assert.equal(secondWindow.getShowCount(), 0); assert.equal(modalWindow.isVisible(), true);
}); assert.equal(modalWindow.getShowCount(), 2);
});
}
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => { test('modal reopen on the warm window notifies state change for each lifecycle', () => {
const firstWindow = createMockWindow(); const modalWindow = createMockWindow();
const secondWindow = createMockWindow();
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
const state: boolean[] = []; const state: boolean[] = [];
const runtime = createOverlayModalRuntimeService( const runtime = createOverlayModalRuntimeService(
{ {
getMainWindow: () => null, getMainWindow: () => null,
getModalWindow: () => currentModal as never, getModalWindow: () => modalWindow as never,
createModalWindow: () => { createModalWindow: () => modalWindow as never,
currentModal = secondWindow;
return secondWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}, },
@@ -835,6 +1065,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
onModalStateChange: (active: boolean): void => { onModalStateChange: (active: boolean): void => {
state.push(active); 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'); runtime.handleOverlayModalClosed('runtime-options');
assert.deepEqual(state, [true, false]); assert.deepEqual(state, [true, false]);
assert.equal(firstWindow.isDestroyed(), true); assert.equal(modalWindow.isDestroyed(), false);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options', restoreOnModalClose: 'runtime-options',
@@ -853,7 +1084,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.deepEqual(state, [true, false, true]); 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', () => { test('visible stale modal window is made interactive again before reopening', () => {
+90 -16
View File
@@ -2,7 +2,10 @@ import type { BrowserWindow } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; 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; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire // The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
@@ -39,6 +42,7 @@ export interface OverlayWindowResolver {
} }
export interface OverlayModalRuntime { export interface OverlayModalRuntime {
primeModalWindow: () => boolean;
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
@@ -59,6 +63,8 @@ export interface OverlayModalRuntime {
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>; type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
export interface OverlayModalRuntimeOptions { export interface OverlayModalRuntimeOptions {
platform?: NodeJS.Platform;
focusApplication?: () => void;
onModalStateChange?: (isActive: boolean) => void; onModalStateChange?: (isActive: boolean) => void;
onFinalModalClosed?: () => void; onFinalModalClosed?: () => void;
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle; scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
@@ -79,6 +85,10 @@ export function createOverlayModalRuntimeService(
let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>(); const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
const platform = options.platform ?? process.platform;
const keepModalWindowWarm = platform === 'darwin' || platform === 'win32';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
const clearRevealFallback = (timeout: RevealFallbackHandle): void => const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
@@ -134,7 +144,11 @@ export function createOverlayModalRuntimeService(
} }
const overlayWindow = window as BrowserWindow & { const overlayWindow = window as BrowserWindow & {
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
}; };
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
return false;
}
if ( if (
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' && typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
@@ -145,6 +159,50 @@ export function createOverlayModalRuntimeService(
return currentURL !== '' && currentURL !== 'about:blank'; 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 => { const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return; if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 3); window.setAlwaysOnTop(true, 'screen-saver', 3);
@@ -205,16 +263,19 @@ export function createOverlayModalRuntimeService(
} }
let delivered = false; let delivered = false;
const deliverWhenReady = (): void => { const deliver = (isReady: () => boolean): void => {
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) { if (delivered || window.isDestroyed() || !isReady()) {
return; return;
} }
delivered = true; delivered = true;
sendNow(window); sendNow(window);
}; };
window.webContents.once('did-finish-load', deliverWhenReady); // A hidden macOS panel may not emit ready-to-show until it is presented. The
window.once('ready-to-show', deliverWhenReady); // 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 = ( const showModalWindow = (
@@ -224,8 +285,15 @@ export function createOverlayModalRuntimeService(
} = { passThroughMouseEvents: false }, } = { passThroughMouseEvents: false },
): void => { ): void => {
setWindowFocusable(window); setWindowFocusable(window);
requestOverlayApplicationFocus(); const wasVisible = window.isVisible();
if (!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(); window.show();
} }
elevateModalWindow(window); elevateModalWindow(window);
@@ -245,11 +313,11 @@ export function createOverlayModalRuntimeService(
const ensureModalWindowInteractive = (window: BrowserWindow): void => { const ensureModalWindowInteractive = (window: BrowserWindow): void => {
setWindowFocusable(window); setWindowFocusable(window);
requestOverlayApplicationFocus();
window.setIgnoreMouseEvents(false); window.setIgnoreMouseEvents(false);
elevateModalWindow(window); elevateModalWindow(window);
if (window.isVisible()) { if (window.isVisible()) {
focusApplication();
window.focus(); window.focus();
window.webContents.focus(); window.webContents.focus();
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window); const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
@@ -447,9 +515,15 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
if (keepModalWindowWarm) {
modalWindow.setIgnoreMouseEvents(true, { forward: true });
modalWindow.hide();
markModalWindowPrimed(modalWindow);
} else {
modalWindow.destroy(); modalWindow.destroy();
}
modalWindowPrimedForImmediateShow = false; modalWindowPrimedForImmediateShow = false;
}
}
mainWindowMousePassthroughForcedByModal = false; mainWindowMousePassthroughForcedByModal = false;
setMainWindowVisibilityForModal(false); setMainWindowVisibilityForModal(false);
try { try {
@@ -478,17 +552,16 @@ export function createOverlayModalRuntimeService(
} }
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
} else {
showModalWindow(targetWindow);
}
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) { if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true); setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true); setMainWindowVisibilityForModal(true);
} }
if (targetWindow.isVisible()) {
ensureModalWindowInteractive(targetWindow);
return;
}
showModalWindow(targetWindow);
}; };
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> => const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
@@ -515,6 +588,7 @@ export function createOverlayModalRuntimeService(
}); });
return { return {
primeModalWindow,
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
openJimaku, openJimaku,