diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index d4f9a723..e3046187 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -229,6 +229,22 @@ function M.create(ctx) end) end + local function run_binary_command_async(args, callback) + subminer_log("debug", "process", "Binary command: " .. table.concat(args, " ")) + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + local ok = success and (result == nil or result.status == 0) + if callback then + callback(ok, result, error) + end + end) + end + local function parse_start_script_message_overrides(...) local overrides = {} for i = 1, select("#", ...) do @@ -528,6 +544,7 @@ function M.create(ctx) build_command_args = build_command_args, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, + run_binary_command_async = run_binary_command_async, parse_start_script_message_overrides = parse_start_script_message_overrides, ensure_texthooker_running = ensure_texthooker_running, start_overlay = start_overlay, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index a01334b9..aa734fa8 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -108,6 +108,8 @@ function M.create(ctx) local function build_cli_args(action_id, payload) if action_id == "toggleVisibleOverlay" then return { "--toggle-visible-overlay" } + elseif action_id == "toggleStatsOverlay" then + return { "--toggle-stats-overlay" } elseif action_id == "copySubtitle" then return { "--copy-subtitle" } elseif action_id == "copySubtitleMultiple" then @@ -142,6 +144,13 @@ function M.create(ctx) return { "--shift-sub-delay-prev-line" } elseif action_id == "shiftSubDelayNextLine" then return { "--shift-sub-delay-next-line" } + elseif action_id == "cycleRuntimeOption" then + local runtime_option_id = payload and payload.runtimeOptionId or nil + if type(runtime_option_id) ~= "string" or runtime_option_id == "" then + return nil + end + local direction = payload and payload.direction == -1 and "prev" or "next" + return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction } end return nil @@ -163,7 +172,24 @@ function M.create(ctx) for _, arg in ipairs(cli_args) do args[#args + 1] = arg end - process.run_binary_command_async(args, function(ok, result, error) + local runner = process.run_binary_command_async + if type(runner) ~= "function" then + runner = function(binary_args, callback) + mp.command_native_async({ + name = "subprocess", + args = binary_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + local ok = success and (result == nil or result.status == 0) + if callback then + callback(ok, result, error) + end + end) + end + end + runner(args, function(ok, result, error) if ok then return end diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 84275417..0e066bfc 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -75,6 +75,7 @@ test('parseArgs captures youtube startup forwarding flags', () => { test('parseArgs captures session action forwarding flags', () => { const args = parseArgs([ + '--toggle-stats-overlay', '--open-jimaku', '--open-youtube-picker', '--open-playlist-browser', @@ -82,11 +83,14 @@ test('parseArgs captures session action forwarding flags', () => { '--play-next-subtitle', '--shift-sub-delay-prev-line', '--shift-sub-delay-next-line', + '--cycle-runtime-option', + 'anki.autoUpdateNewCards:prev', '--copy-subtitle-count', '3', '--mine-sentence-count=2', ]); + assert.equal(args.toggleStatsOverlay, true); assert.equal(args.openJimaku, true); assert.equal(args.openYoutubePicker, true); assert.equal(args.openPlaylistBrowser, true); @@ -94,6 +98,8 @@ test('parseArgs captures session action forwarding flags', () => { assert.equal(args.playNextSubtitle, true); assert.equal(args.shiftSubDelayPrevLine, true); assert.equal(args.shiftSubDelayNextLine, true); + assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); + assert.equal(args.cycleRuntimeOptionDirection, -1); assert.equal(args.copySubtitleCount, 3); assert.equal(args.mineSentenceCount, 2); assert.equal(hasExplicitCommand(args), true); @@ -199,6 +205,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(anilistRetryQueue), true); assert.equal(shouldStartApp(anilistRetryQueue), false); + const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']); + assert.equal(toggleStatsOverlay.toggleStatsOverlay, true); + assert.equal(hasExplicitCommand(toggleStatsOverlay), true); + assert.equal(shouldStartApp(toggleStatsOverlay), true); + + const cycleRuntimeOption = parseArgs([ + '--cycle-runtime-option', + 'anki.autoUpdateNewCards:next', + ]); + assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); + assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1); + assert.equal(hasExplicitCommand(cycleRuntimeOption), true); + assert.equal(shouldStartApp(cycleRuntimeOption), true); + assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true); + const dictionary = parseArgs(['--dictionary']); assert.equal(dictionary.dictionary, true); assert.equal(hasExplicitCommand(dictionary), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index f294da27..06eab554 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -24,6 +24,7 @@ export interface CliArgs { triggerFieldGrouping: boolean; triggerSubsync: boolean; markAudioCard: boolean; + toggleStatsOverlay: boolean; openRuntimeOptions: boolean; openJimaku: boolean; openYoutubePicker: boolean; @@ -32,6 +33,8 @@ export interface CliArgs { playNextSubtitle: boolean; shiftSubDelayPrevLine: boolean; shiftSubDelayNextLine: boolean; + cycleRuntimeOptionId?: string; + cycleRuntimeOptionDirection?: 1 | -1; copySubtitleCount?: number; mineSentenceCount?: number; anilistStatus: boolean; @@ -111,6 +114,7 @@ export function parseArgs(argv: string[]): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, openRuntimeOptions: false, openJimaku: false, openYoutubePicker: false, @@ -154,6 +158,24 @@ export function parseArgs(argv: string[]): CliArgs { return value; }; + const parseCycleRuntimeOption = ( + value: string | undefined, + ): { id: string; direction: 1 | -1 } | null => { + if (!value) return null; + const separatorIndex = value.lastIndexOf(':'); + if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null; + const id = value.slice(0, separatorIndex).trim(); + const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase(); + if (!id) return null; + if (rawDirection === 'next' || rawDirection === '1') { + return { id, direction: 1 }; + } + if (rawDirection === 'prev' || rawDirection === '-1') { + return { id, direction: -1 }; + } + return null; + }; + for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg || !arg.startsWith('--')) continue; @@ -195,6 +217,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--mark-audio-card') args.markAudioCard = true; + else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-jimaku') args.openJimaku = true; else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; @@ -203,6 +226,19 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true; else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true; + else if (arg.startsWith('--cycle-runtime-option=')) { + const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]); + if (parsed) { + args.cycleRuntimeOptionId = parsed.id; + args.cycleRuntimeOptionDirection = parsed.direction; + } + } else if (arg === '--cycle-runtime-option') { + const parsed = parseCycleRuntimeOption(readValue(argv[i + 1])); + if (parsed) { + args.cycleRuntimeOptionId = parsed.id; + args.cycleRuntimeOptionDirection = parsed.direction; + } + } else if (arg.startsWith('--copy-subtitle-count=')) { const value = Number(arg.split('=', 2)[1]); if (Number.isInteger(value)) args.copySubtitleCount = value; @@ -407,6 +443,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || args.openRuntimeOptions || args.openJimaku || args.openYoutubePicker || @@ -415,6 +452,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.anilistStatus || @@ -468,6 +506,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.triggerFieldGrouping && !args.triggerSubsync && !args.markAudioCard && + !args.toggleStatsOverlay && !args.openRuntimeOptions && !args.openJimaku && !args.openYoutubePicker && @@ -476,6 +515,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.playNextSubtitle && !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && + args.cycleRuntimeOptionId === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && @@ -520,6 +560,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || args.openRuntimeOptions || args.openJimaku || args.openYoutubePicker || @@ -528,6 +569,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.dictionary || @@ -567,6 +609,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.triggerFieldGrouping && !args.triggerSubsync && !args.markAudioCard && + !args.toggleStatsOverlay && !args.openRuntimeOptions && !args.openJimaku && !args.openYoutubePicker && @@ -575,6 +618,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.playNextSubtitle && !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && + args.cycleRuntimeOptionId === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && @@ -627,6 +671,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined ); diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 4fcdf131..b86a92ef 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -28,6 +28,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, openRuntimeOptions: false, openJimaku: false, openYoutubePicker: false, @@ -36,6 +37,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index f72f7155..e3f2a495 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy ); assert.ok(calls.includes('startBackgroundWarmups')); assert.ok( - calls.includes( - 'log:Runtime ready: immersion tracker startup deferred until first media activity.', - ), + calls.includes('log:Runtime ready: immersion tracker startup requested.'), ); }); @@ -103,6 +101,17 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a ); }); +test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => { + const { deps, calls } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + }); + + await runAppReadyRuntime(deps); + + assert.ok(calls.includes('createImmersionTracker')); + assert.ok(calls.indexOf('createImmersionTracker') < calls.indexOf('handleInitialArgs')); +}); + test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => { const { deps, calls } = makeDeps({ getResolvedConfig: () => ({ diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index d3100d66..465ffbca 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -29,6 +29,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, refreshKnownWords: false, openRuntimeOptions: false, openJimaku: false, @@ -38,6 +39,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -509,6 +512,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () => expected: 'startPendingMineSentenceMultiple:2500', }, { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, + { args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' }, { args: { openRuntimeOptions: true }, expected: 'openRuntimeOptionsPalette', @@ -528,6 +532,33 @@ test('handleCliCommand handles visibility and utility command dispatches', () => } }); +test('handleCliCommand dispatches cycle-runtime-option session action', async () => { + let request: unknown = null; + const { deps } = createDeps({ + dispatchSessionAction: async (nextRequest) => { + request = nextRequest; + }, + }); + + handleCliCommand( + makeArgs({ + cycleRuntimeOptionId: 'anki.autoUpdateNewCards', + cycleRuntimeOptionDirection: -1, + }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(request, { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: 'anki.autoUpdateNewCards', + direction: -1, + }, + }); +}); + test('handleCliCommand logs AniList status details', () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index caa74c10..b4a8d567 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -396,6 +396,12 @@ export function handleCliCommand( 'markLastCardAsAudioCard', 'Audio card failed', ); + } else if (args.toggleStatsOverlay) { + dispatchCliSessionAction( + { actionId: 'toggleStatsOverlay' }, + 'toggleStatsOverlay', + 'Stats toggle failed', + ); } else if (args.openRuntimeOptions) { deps.openRuntimeOptionsPalette(); } else if (args.openJimaku) { @@ -436,6 +442,18 @@ export function handleCliCommand( 'shiftSubDelayNextLine', 'Shift subtitle delay failed', ); + } else if (args.cycleRuntimeOptionId !== undefined) { + dispatchCliSessionAction( + { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: args.cycleRuntimeOptionId, + direction: args.cycleRuntimeOptionDirection ?? 1, + }, + }, + 'cycleRuntimeOption', + 'Runtime option change failed', + ); } else if (args.copySubtitleCount !== undefined) { dispatchCliSessionAction( { actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } }, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index afbc7acc..8916f464 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -35,7 +35,10 @@ import { const { ipcMain } = electron; export interface IpcServiceDeps { - onOverlayModalClosed: (modal: OverlayHostedModal) => void; + onOverlayModalClosed: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, @@ -160,7 +163,10 @@ interface IpcMainRegistrar { export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; - onOverlayModalClosed: (modal: OverlayHostedModal) => void; + onOverlayModalClosed: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, @@ -321,10 +327,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => { + ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); if (!parsedModal) return; - deps.onOverlayModalClosed(parsedModal); + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalClosed(parsedModal, senderWindow); }); ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index ca191578..ddafaa33 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -320,22 +320,7 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => { test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => { const result = registerOverlayShortcutsRuntime({ - getConfiguredShortcuts: () => - ({ - toggleVisibleOverlayGlobal: null, - copySubtitle: null, - copySubtitleMultiple: null, - updateLastCardFromClipboard: null, - triggerFieldGrouping: null, - triggerSubsync: null, - mineSentence: null, - mineSentenceMultiple: null, - multiCopyTimeoutMs: 2500, - toggleSecondarySub: null, - markAudioCard: null, - openRuntimeOptions: null, - openJimaku: 'Ctrl+J', - }) as never, + getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }), getOverlayHandlers: () => ({ copySubtitle: () => {}, copySubtitleMultiple: () => {}, @@ -359,22 +344,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured', test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => { const calls: string[] = []; const result = unregisterOverlayShortcutsRuntime(true, { - getConfiguredShortcuts: () => - ({ - toggleVisibleOverlayGlobal: null, - copySubtitle: null, - copySubtitleMultiple: null, - updateLastCardFromClipboard: null, - triggerFieldGrouping: null, - triggerSubsync: null, - mineSentence: null, - mineSentenceMultiple: null, - multiCopyTimeoutMs: 2500, - toggleSecondarySub: null, - markAudioCard: null, - openRuntimeOptions: null, - openJimaku: null, - }) as never, + getConfiguredShortcuts: () => makeShortcuts(), getOverlayHandlers: () => ({ copySubtitle: () => {}, copySubtitleMultiple: () => {}, diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index e26089b5..7b35248b 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -664,6 +664,80 @@ test('tracked Windows overlay stays interactive while the overlay window itself assert.ok(!calls.includes('enforce-order')); }); +test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + window.hide(); + calls.length = 0; + setFocused(true); + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); +}); + test('tracked Windows overlay binds above mpv even when tracker focus lags', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 7126d6d8..0e1cf005 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -92,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: { const showPassiveVisibleOverlay = (): void => { const forceMousePassthrough = args.forceMousePassthrough === true; + const wasVisible = mainWindow.isVisible(); const shouldDefaultToPassthrough = args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough; const isVisibleOverlayFocused = @@ -116,8 +117,10 @@ export function updateVisibleOverlayVisibility(args: { windowsForegroundProcessName === windowsOverlayProcessName)) && !isTrackedWindowsTargetMinimized && (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); + const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible; const shouldIgnoreMouseEvents = - forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused); + forceMousePassthrough || + (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldKeepTrackedWindowsOverlayTopmost = !args.isWindowsPlatform || @@ -126,8 +129,6 @@ export function updateVisibleOverlayVisibility(args: { isTrackedWindowsTargetFocused || shouldPreserveWindowsOverlayDuringFocusHandoff || (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); - const wasVisible = mainWindow.isVisible(); - if (shouldIgnoreMouseEvents) { mainWindow.setIgnoreMouseEvents(true, { forward: true }); } else { diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 645d3fcc..62bcc34c 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -97,6 +97,9 @@ export function createOverlayWindow( }, ): BrowserWindow { const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = false; if (!(process.platform === 'win32' && kind === 'visible')) { options.ensureOverlayWindowLevel(window); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index d5a16552..02f83a3e 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -97,7 +97,26 @@ function normalizeCodeToken(token: string): string | null { /^arrow(?:up|down|left|right)$/i.test(normalized) || /^f\d{1,2}$/i.test(normalized) ) { - return normalized[0]!.toUpperCase() + normalized.slice(1); + const keyMatch = normalized.match(/^key([a-z])$/i); + if (keyMatch) { + return `Key${keyMatch[1]!.toUpperCase()}`; + } + + const digitMatch = normalized.match(/^digit([0-9])$/i); + if (digitMatch) { + return `Digit${digitMatch[1]}`; + } + + const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i); + if (arrowMatch) { + const direction = arrowMatch[1]!; + return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`; + } + + const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i); + if (functionKeyMatch) { + return `F${functionKeyMatch[1]}`; + } } return null; } diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 4f4ea85b..277027f6 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -28,6 +28,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, openRuntimeOptions: false, openJimaku: false, openYoutubePicker: false, @@ -36,6 +37,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 3b465185..ae7033fe 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -311,7 +311,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { - sendToActiveOverlayWindow('jimaku:open', undefined, { - restoreOnModalClose: 'jimaku', - }); + openJimakuOverlay(); }, markAudioCard: () => markLastCardAsAudioCard(), copySubtitleMultiple: (timeoutMs: number) => { @@ -2206,7 +2205,42 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void { } function openRuntimeOptionsPalette(): void { - overlayVisibilityComposer.openRuntimeOptionsPalette(); + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + ensureOverlayWindowsReadyForVisibilityActions(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }, + { + channel: IPC_CHANNELS.event.runtimeOptionsOpen, + modal: 'runtime-options', + preferModalWindow: true, + }, + ); + if (!opened) { + showMpvOsd('Runtime options overlay unavailable.'); + } +} + +function openJimakuOverlay(): void { + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + ensureOverlayWindowsReadyForVisibilityActions(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + sendToActiveOverlayWindow(channel, payload, runtimeOptions), + }, + { + channel: IPC_CHANNELS.event.jimakuOpen, + modal: 'jimaku', + }, + ); + if (!opened) { + showMpvOsd('Jimaku overlay unavailable.'); + } } function openPlaylistBrowser(): void { @@ -4522,7 +4556,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro toggleSecondarySub: () => handleCycleSecondarySubMode(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openJimaku: () => overlayModalRuntime.openJimaku(), + openJimaku: () => openJimakuOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openPlaylistBrowser: () => openPlaylistBrowser(), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), @@ -4551,7 +4585,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openJimaku: () => overlayModalRuntime.openJimaku(), + openJimaku: () => openJimakuOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openPlaylistBrowser: () => openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => { @@ -4591,7 +4625,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mainWindow.focus(); } }, - onOverlayModalClosed: (modal) => { + onOverlayModalClosed: (modal, senderWindow) => { + const modalWindow = overlayManager.getModalWindow(); + if ( + senderWindow && + modalWindow && + senderWindow === modalWindow && + !senderWindow.isDestroyed() + ) { + senderWindow.setIgnoreMouseEvents(true, { forward: true }); + senderWindow.hide(); + } handleOverlayModalClosed(modal); }, onOverlayModalOpened: (modal) => { diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 82c8a64e..95afd85b 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -7,13 +7,16 @@ type MockWindow = { visible: boolean; focused: boolean; ignoreMouseEvents: boolean; + forwardedIgnoreMouseEvents: boolean; webContentsFocused: boolean; showCount: number; hideCount: number; sent: unknown[][]; loading: boolean; url: string; + contentReady: boolean; loadCallbacks: Array<() => void>; + readyToShowCallbacks: Array<() => void>; }; function createMockWindow(): MockWindow & { @@ -29,6 +32,7 @@ function createMockWindow(): MockWindow & { show: () => void; hide: () => void; focus: () => void; + once: (event: 'ready-to-show', cb: () => void) => void; webContents: { focused: boolean; isLoading: () => boolean; @@ -44,13 +48,16 @@ function createMockWindow(): MockWindow & { visible: false, focused: false, ignoreMouseEvents: false, + forwardedIgnoreMouseEvents: false, webContentsFocused: false, showCount: 0, hideCount: 0, sent: [], loading: false, url: 'file:///overlay/index.html?layer=modal', + contentReady: true, loadCallbacks: [], + readyToShowCallbacks: [], }; const window = { ...state, @@ -58,8 +65,9 @@ function createMockWindow(): MockWindow & { isVisible: () => state.visible, isFocused: () => state.focused, getURL: () => state.url, - setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { state.ignoreMouseEvents = ignore; + state.forwardedIgnoreMouseEvents = options?.forward === true; }, setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, moveTop: () => {}, @@ -76,6 +84,9 @@ function createMockWindow(): MockWindow & { focus: () => { state.focused = true; }, + once: (_event: 'ready-to-show', cb: () => void) => { + state.readyToShowCallbacks.push(cb); + }, webContents: { isLoading: () => state.loading, getURL: () => state.url, @@ -139,6 +150,25 @@ function createMockWindow(): MockWindow & { }, }); + Object.defineProperty(window, 'forwardedIgnoreMouseEvents', { + get: () => state.forwardedIgnoreMouseEvents, + set: (value: boolean) => { + state.forwardedIgnoreMouseEvents = value; + }, + }); + + Object.defineProperty(window, 'contentReady', { + get: () => state.contentReady, + set: (value: boolean) => { + state.contentReady = value; + (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady = + value; + }, + }); + + (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady = + state.contentReady; + return window; } @@ -199,6 +229,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co const window = createMockWindow(); window.url = ''; window.loading = true; + window.contentReady = false; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, getModalWindow: () => window as never, @@ -217,9 +248,14 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co assert.deepEqual(window.sent, []); assert.equal(window.loadCallbacks.length, 1); + assert.equal(window.readyToShowCallbacks.length, 1); window.loading = false; window.url = 'file:///overlay/index.html?layer=modal'; window.loadCallbacks[0]!(); + assert.deepEqual(window.sent, []); + + window.contentReady = true; + window.readyToShowCallbacks[0]!(); runtime.notifyOverlayModalOpened('runtime-options'); assert.deepEqual(window.sent, [['runtime-options:open']]); @@ -325,11 +361,12 @@ test('modal window path makes visible main overlay click-through until modal clo assert.equal(sent, true); assert.equal(mainWindow.ignoreMouseEvents, true); + assert.equal(mainWindow.forwardedIgnoreMouseEvents, true); assert.equal(modalWindow.ignoreMouseEvents, false); runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.ignoreMouseEvents, false); + assert.equal(mainWindow.ignoreMouseEvents, true); }); test('modal window path hides visible main overlay until modal closes', () => { @@ -359,8 +396,8 @@ test('modal window path hides visible main overlay until modal closes', () => { runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.getShowCount(), 1); - assert.equal(mainWindow.isVisible(), true); + assert.equal(mainWindow.getShowCount(), 0); + assert.equal(mainWindow.isVisible(), false); }); test('modal runtime notifies callers when modal input state becomes active/inactive', () => { @@ -500,6 +537,7 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open window.loading = true; window.url = ''; + window.contentReady = false; const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku', @@ -519,6 +557,36 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open assert.equal(window.ignoreMouseEvents, false); }); +test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => { + 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: () => {}, + }); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(sent, true); + assert.deepEqual(window.sent, []); + assert.equal(window.loadCallbacks.length, 1); + assert.equal(window.readyToShowCallbacks.length, 1); + + window.loadCallbacks[0]!(); + assert.deepEqual(window.sent, []); + + window.contentReady = true; + window.readyToShowCallbacks[0]!(); + assert.deepEqual(window.sent, [['runtime-options:open']]); +}); + test('waitForModalOpen resolves true after modal acknowledgement', async () => { const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0f5e0792..4f700225 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -3,6 +3,7 @@ import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; +const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; @@ -90,6 +91,15 @@ export function createOverlayModalRuntimeService( if (window.webContents.isLoading()) { return false; } + const overlayWindow = window as BrowserWindow & { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + }; + if ( + typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' && + overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true + ) { + return false; + } const currentURL = window.webContents.getURL(); return currentURL !== '' && currentURL !== 'about:blank'; }; @@ -109,11 +119,17 @@ export function createOverlayModalRuntimeService( return; } - window.webContents.once('did-finish-load', () => { - if (!window.isDestroyed() && !window.webContents.isLoading()) { - sendNow(window); + let delivered = false; + const deliverWhenReady = (): void => { + if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) { + return; } - }); + delivered = true; + sendNow(window); + }; + + window.webContents.once('did-finish-load', deliverWhenReady); + window.once('ready-to-show', deliverWhenReady); }; const showModalWindow = ( @@ -320,12 +336,12 @@ export function createOverlayModalRuntimeService( const modalWindow = deps.getModalWindow(); if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); - notifyModalStateChange(false); - setMainWindowMousePassthroughForModal(false); - setMainWindowVisibilityForModal(false); if (modalWindow && !modalWindow.isDestroyed()) { modalWindow.hide(); } + mainWindowMousePassthroughForcedByModal = false; + mainWindowHiddenByModal = false; + notifyModalStateChange(false); } }; diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 42897df6..b2c51ea7 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -42,6 +42,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, openRuntimeOptions: false, openJimaku: false, openYoutubePicker: false, @@ -50,6 +51,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 4accf574..d03f739f 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -76,7 +76,16 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || args.openRuntimeOptions || + args.openJimaku || + args.openYoutubePicker || + args.openPlaylistBrowser || + args.replayCurrentSubtitle || + args.playNextSubtitle || + args.shiftSubDelayPrevLine || + args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || args.anilistStatus || args.anilistLogout || args.anilistSetup || diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts index e7b5fd32..3fcb0b57 100644 --- a/src/main/runtime/immersion-startup.test.ts +++ b/src/main/runtime/immersion-startup.test.ts @@ -161,6 +161,41 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking')); }); +test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => { + const calls: string[] = []; + const trackerInstance = { kind: 'tracker' }; + let assignedTracker: unknown = null; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => makeConfig(), + getConfiguredDbPath: () => '/tmp/subminer.db', + createTrackerService: () => trackerInstance, + setTracker: (nextTracker) => { + assignedTracker = nextTracker; + }, + getMpvClient: () => ({ + connected: false, + connect: () => { + throw new Error('socket not ready'); + }, + }), + seedTrackerFromCurrentMedia: () => calls.push('seedTracker'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`), + }); + + handler(); + + assert.equal(assignedTracker, trackerInstance); + assert.ok(calls.includes('seedTracker')); + assert.ok( + calls.includes( + 'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready', + ), + ); + assert.equal(calls.includes('warn:Immersion tracker startup failed; disabling tracking.'), false); +}); + test('createImmersionTrackerStartupHandler disables tracker on failure', () => { const calls: string[] = []; let assignedTracker: unknown = 'initial'; diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts index 719f6413..bf533d2d 100644 --- a/src/main/runtime/immersion-startup.ts +++ b/src/main/runtime/immersion-startup.ts @@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler( const mpvClient = deps.getMpvClient(); if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) { deps.logInfo('Auto-connecting MPV client for immersion tracking'); - mpvClient.connect(); + try { + mpvClient.connect(); + } catch (error) { + deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error); + } } deps.seedTrackerFromCurrentMedia(); } catch (error) { diff --git a/src/main/runtime/overlay-hosted-modal-open.test.ts b/src/main/runtime/overlay-hosted-modal-open.test.ts new file mode 100644 index 00000000..adaa8552 --- /dev/null +++ b/src/main/runtime/overlay-hosted-modal-open.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { openOverlayHostedModal } from './overlay-hosted-modal-open'; + +test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => { + const calls: string[] = []; + + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => { + calls.push('ensureOverlayStartupPrereqs'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensureOverlayWindowsReadyForVisibilityActions'); + }, + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => { + calls.push(`send:${channel}`); + assert.equal(payload, undefined); + assert.deepEqual(runtimeOptions, { + restoreOnModalClose: 'runtime-options', + preferModalWindow: undefined, + }); + return true; + }, + }, + { + channel: 'runtime-options:open', + modal: 'runtime-options', + }, + ); + + assert.equal(opened, true); + assert.deepEqual(calls, [ + 'ensureOverlayStartupPrereqs', + 'ensureOverlayWindowsReadyForVisibilityActions', + 'send:runtime-options:open', + ]); +}); + +test('openOverlayHostedModal forwards payload and modal-window preference', () => { + const payload = { sessionId: 'yt-1' }; + + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => { + assert.equal(channel, 'youtube:picker-open'); + assert.deepEqual(forwardedPayload, payload); + assert.deepEqual(runtimeOptions, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }); + return false; + }, + }, + { + channel: 'youtube:picker-open', + modal: 'youtube-track-picker', + payload, + preferModalWindow: true, + }, + ); + + assert.equal(opened, false); +}); diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts new file mode 100644 index 00000000..ab850da8 --- /dev/null +++ b/src/main/runtime/overlay-hosted-modal-open.ts @@ -0,0 +1,29 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; + +export function openOverlayHostedModal( + deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + }, + input: { + channel: string; + modal: OverlayHostedModal; + payload?: unknown; + preferModalWindow?: boolean; + }, +): boolean { + deps.ensureOverlayStartupPrereqs(); + deps.ensureOverlayWindowsReadyForVisibilityActions(); + return deps.sendToActiveOverlayWindow(input.channel, input.payload, { + restoreOnModalClose: input.modal, + preferModalWindow: input.preferModalWindow, + }); +} diff --git a/src/renderer/modals/runtime-options.test.ts b/src/renderer/modals/runtime-options.test.ts new file mode 100644 index 00000000..f1146da3 --- /dev/null +++ b/src/renderer/modals/runtime-options.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ElectronAPI, RuntimeOptionState } from '../../types'; +import { createRendererState } from '../state.js'; +import { createRuntimeOptionsModal } from './runtime-options.js'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + toggle: (entry: string, force?: boolean) => { + if (force === undefined) { + if (tokens.has(entry)) { + tokens.delete(entry); + return false; + } + tokens.add(entry); + return true; + } + if (force) tokens.add(entry); + else tokens.delete(entry); + return force; + }, + contains: (entry: string) => tokens.has(entry), + }; +} + +function createElementStub() { + return { + className: '', + textContent: '', + title: '', + classList: createClassList(), + appendChild: () => {}, + addEventListener: () => {}, + }; +} + +function createRuntimeOptionsListStub() { + return { + innerHTML: '', + appendChild: () => {}, + querySelector: () => null, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +function flushAsyncWork(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function withRuntimeOptionsModal( + getRuntimeOptions: () => Promise, + run: (input: { + modal: ReturnType; + state: ReturnType; + overlayClassList: ReturnType; + modalClassList: ReturnType; + statusNode: { + textContent: string; + classList: ReturnType; + }; + syncCalls: string[]; + }) => Promise | void, +): Promise { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const statusNode = { + textContent: '', + classList: createClassList(), + }; + const overlayClassList = createClassList(); + const modalClassList = createClassList(['hidden']); + const syncCalls: string[] = []; + const state = createRendererState(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getRuntimeOptions, + setRuntimeOptionValue: async () => ({ ok: true }), + notifyOverlayModalClosed: () => {}, + } satisfies Pick< + ElectronAPI, + 'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed' + >, + }, + }); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createElementStub(), + }, + }); + + const modal = createRuntimeOptionsModal( + { + dom: { + overlay: { classList: overlayClassList }, + runtimeOptionsModal: { + classList: modalClassList, + setAttribute: () => {}, + }, + runtimeOptionsClose: { + addEventListener: () => {}, + }, + runtimeOptionsList: createRuntimeOptionsListStub(), + runtimeOptionsStatus: statusNode, + }, + state, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => { + syncCalls.push('sync'); + }, + }, + ); + + return Promise.resolve() + .then(() => + run({ + modal, + state, + overlayClassList, + modalClassList, + statusNode, + syncCalls, + }), + ) + .finally(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: previousWindow, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: previousDocument, + }); + }); +} + +test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => { + const deferred = createDeferred(); + + await withRuntimeOptionsModal(() => deferred.promise, async (input) => { + input.modal.openRuntimeOptionsModal(); + + assert.equal(input.state.runtimeOptionsModalOpen, true); + assert.equal(input.overlayClassList.contains('interactive'), true); + assert.equal(input.modalClassList.contains('hidden'), false); + assert.equal(input.statusNode.textContent, 'Loading runtime options...'); + assert.deepEqual(input.syncCalls, ['sync']); + + deferred.resolve([ + { + id: 'anki.autoUpdateNewCards', + label: 'Auto-update new cards', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ]); + await flushAsyncWork(); + + assert.equal( + input.statusNode.textContent, + 'Use arrow keys. Click value to cycle. Enter or double-click to apply.', + ); + assert.equal(input.statusNode.classList.contains('error'), false); + }); +}); + +test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => { + const deferred = createDeferred(); + + await withRuntimeOptionsModal(() => deferred.promise, async (input) => { + input.modal.openRuntimeOptionsModal(); + deferred.reject(new Error('boom')); + await flushAsyncWork(); + + assert.equal(input.state.runtimeOptionsModalOpen, true); + assert.equal(input.overlayClassList.contains('interactive'), true); + assert.equal(input.modalClassList.contains('hidden'), false); + assert.equal(input.statusNode.textContent, 'Failed to load runtime options'); + assert.equal(input.statusNode.classList.contains('error'), true); + }); +}); diff --git a/src/renderer/modals/runtime-options.ts b/src/renderer/modals/runtime-options.ts index 8b9cfdbd..ed7f9c83 100644 --- a/src/renderer/modals/runtime-options.ts +++ b/src/renderer/modals/runtime-options.ts @@ -22,6 +22,9 @@ export function createRuntimeOptionsModal( syncSettingsModalSubtitleSuppression: () => void; }, ) { + const DEFAULT_STATUS_MESSAGE = + 'Use arrow keys. Click value to cycle. Enter or double-click to apply.'; + function formatRuntimeOptionValue(value: RuntimeOptionValue): string { if (typeof value === 'boolean') { return value ? 'On' : 'Off'; @@ -177,10 +180,13 @@ export function createRuntimeOptionsModal( } } - async function openRuntimeOptionsModal(): Promise { + async function refreshRuntimeOptions(): Promise { const optionsList = await window.electronAPI.getRuntimeOptions(); updateRuntimeOptions(optionsList); + setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE); + } + function showRuntimeOptionsModalShell(): void { ctx.state.runtimeOptionsModalOpen = true; options.syncSettingsModalSubtitleSuppression(); @@ -188,9 +194,19 @@ export function createRuntimeOptionsModal( ctx.dom.runtimeOptionsModal.classList.remove('hidden'); ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false'); - setRuntimeOptionsStatus( - 'Use arrow keys. Click value to cycle. Enter or double-click to apply.', - ); + setRuntimeOptionsStatus('Loading runtime options...'); + } + + function openRuntimeOptionsModal(): void { + if (!ctx.state.runtimeOptionsModalOpen) { + showRuntimeOptionsModalShell(); + } else { + setRuntimeOptionsStatus('Refreshing runtime options...'); + } + + void refreshRuntimeOptions().catch(() => { + setRuntimeOptionsStatus('Failed to load runtime options', true); + }); } function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 1f4fdd46..71420299 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -432,15 +432,9 @@ registerRendererGlobalErrorHandlers(window, recovery); function registerModalOpenHandlers(): void { window.electronAPI.onOpenRuntimeOptions(() => { - runGuardedAsync('runtime-options:open', async () => { - try { - await runtimeOptionsModal.openRuntimeOptionsModal(); - window.electronAPI.notifyOverlayModalOpened('runtime-options'); - } catch { - runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); - window.electronAPI.notifyOverlayModalClosed('runtime-options'); - syncSettingsModalSubtitleSuppression(); - } + runGuarded('runtime-options:open', () => { + runtimeOptionsModal.openRuntimeOptionsModal(); + window.electronAPI.notifyOverlayModalOpened('runtime-options'); }); }); window.electronAPI.onOpenJimaku(() => { diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index bb0ad535..c13c43f0 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -147,7 +147,7 @@ export class WindowsWindowTracker extends BaseWindowTracker { const focusedMatch = result.matches.find((m) => m.isForeground); const best = focusedMatch ?? - result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!; + [...result.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!; return { geometry: best.bounds,