mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(overlay): keep macOS modal windows on fullscreen Spaces (#200)
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user