From 8520a4d0688db17ecc4c50690dedf42055351582 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 2 Apr 2026 22:13:34 -0700 Subject: [PATCH] fix: address latest coderabbit feedback --- src/main/anilist-runtime-coordinator.ts | 4 +- ...discord-presence-lifecycle-runtime.test.ts | 94 +++++++++++ .../discord-presence-lifecycle-runtime.ts | 12 +- src/main/headless-known-word-refresh.ts | 11 +- src/main/headless-startup-runtime.test.ts | 45 ++++++ src/main/headless-startup-runtime.ts | 113 +++++++++----- src/main/main-startup-bootstrap.ts | 2 +- src/main/main-startup-runtime.ts | 6 +- src/main/mpv-runtime-bootstrap.ts | 10 +- src/main/overlay-ui-runtime.test.ts | 147 ++++++++++++++++++ src/main/overlay-ui-runtime.ts | 1 + src/main/runtime/discord-presence-runtime.ts | 9 +- src/main/stats-runtime-coordinator.ts | 3 +- src/main/stats-runtime.test.ts | 41 +++++ src/main/stats-runtime.ts | 9 ++ 15 files changed, 446 insertions(+), 61 deletions(-) diff --git a/src/main/anilist-runtime-coordinator.ts b/src/main/anilist-runtime-coordinator.ts index 1426f545..93c52395 100644 --- a/src/main/anilist-runtime-coordinator.ts +++ b/src/main/anilist-runtime-coordinator.ts @@ -74,7 +74,9 @@ export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinator const window = new BrowserWindow(options); input.appState.anilistSetupWindow = window; window.on('closed', () => { - input.appState.anilistSetupWindow = null; + if (input.appState.anilistSetupWindow === window) { + input.appState.anilistSetupWindow = null; + } }); return window as unknown as AnilistSetupWindowLike; }, diff --git a/src/main/discord-presence-lifecycle-runtime.test.ts b/src/main/discord-presence-lifecycle-runtime.test.ts index e5458b3e..7daef1af 100644 --- a/src/main/discord-presence-lifecycle-runtime.test.ts +++ b/src/main/discord-presence-lifecycle-runtime.test.ts @@ -53,3 +53,97 @@ test('discord presence lifecycle runtime starts service and publishes presence w assert.deepEqual(calls, ['start', 'Demo', 'publish']); }); + +test('discord presence lifecycle runtime stops the existing service before replacement', async () => { + const calls: string[] = []; + let service: { start: () => Promise; stop: () => Promise } | null = { + start: async () => { + calls.push('old-start'); + }, + stop: async () => { + calls.push('old-stop'); + }, + }; + + const runtime = createDiscordPresenceLifecycleRuntime({ + getResolvedConfig: () => ({ discordPresence: { enabled: true } }), + getDiscordPresenceService: () => service as never, + setDiscordPresenceService: (next) => { + service = next as typeof service; + }, + getMpvClient: () => null, + getCurrentMediaTitle: () => 'Demo', + getCurrentMediaPath: () => '/tmp/demo.mkv', + getCurrentSubtitleText: () => 'subtitle', + getPlaybackPaused: () => false, + getFallbackMediaDurationSec: () => 12, + createDiscordPresenceService: () => ({ + start: async () => { + calls.push('new-start'); + }, + stop: async () => { + calls.push('new-stop'); + }, + publish: () => { + calls.push('publish'); + }, + }), + createDiscordRuntime: () => ({ + refreshDiscordPresenceMediaDuration: async () => {}, + publishDiscordPresence: () => { + calls.push('runtime-publish'); + }, + }), + now: () => 123, + }); + + await runtime.initializeDiscordPresenceService(); + + assert.deepEqual(calls, ['old-stop', 'new-start', 'runtime-publish']); +}); + +test('discord presence lifecycle runtime stops the existing service when disabled', async () => { + const calls: string[] = []; + let service: { start: () => Promise; stop: () => Promise } | null = { + start: async () => { + calls.push('old-start'); + }, + stop: async () => { + calls.push('old-stop'); + }, + }; + + const runtime = createDiscordPresenceLifecycleRuntime({ + getResolvedConfig: () => ({ discordPresence: { enabled: false } }), + getDiscordPresenceService: () => service as never, + setDiscordPresenceService: (next) => { + service = next as typeof service; + }, + getMpvClient: () => null, + getCurrentMediaTitle: () => 'Demo', + getCurrentMediaPath: () => '/tmp/demo.mkv', + getCurrentSubtitleText: () => 'subtitle', + getPlaybackPaused: () => false, + getFallbackMediaDurationSec: () => 12, + createDiscordPresenceService: () => { + calls.push('create'); + return { + start: async () => {}, + stop: async () => {}, + publish: () => {}, + }; + }, + createDiscordRuntime: () => ({ + refreshDiscordPresenceMediaDuration: async () => {}, + publishDiscordPresence: () => { + calls.push('runtime-publish'); + }, + }), + now: () => 123, + }); + + await runtime.initializeDiscordPresenceService(); + + assert.equal(service, null); + assert.deepEqual(calls, ['old-stop']); +}); diff --git a/src/main/discord-presence-lifecycle-runtime.ts b/src/main/discord-presence-lifecycle-runtime.ts index dc3c44c7..669d5bf5 100644 --- a/src/main/discord-presence-lifecycle-runtime.ts +++ b/src/main/discord-presence-lifecycle-runtime.ts @@ -49,6 +49,10 @@ export function createDiscordPresenceLifecycleRuntime( ): DiscordPresenceLifecycleRuntime { let discordPresenceMediaDurationSec: number | null = null; const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now(); + const stopCurrentDiscordPresenceService = async (): Promise => { + await input.getDiscordPresenceService()?.stop?.(); + input.setDiscordPresenceService(null); + }; const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({ getDiscordPresenceService: () => input.getDiscordPresenceService(), @@ -72,19 +76,17 @@ export function createDiscordPresenceLifecycleRuntime( }, initializeDiscordPresenceService: async () => { if (input.getResolvedConfig().discordPresence.enabled !== true) { - input.setDiscordPresenceService(null); + await stopCurrentDiscordPresenceService(); return; } + await stopCurrentDiscordPresenceService(); input.setDiscordPresenceService( input.createDiscordPresenceService(input.getResolvedConfig().discordPresence), ); await input.getDiscordPresenceService()?.start(); discordPresenceRuntime.publishDiscordPresence(); }, - stopDiscordPresenceService: async () => { - await input.getDiscordPresenceService()?.stop?.(); - input.setDiscordPresenceService(null); - }, + stopDiscordPresenceService: stopCurrentDiscordPresenceService, }; } diff --git a/src/main/headless-known-word-refresh.ts b/src/main/headless-known-word-refresh.ts index 5beddc4d..51bed0f2 100644 --- a/src/main/headless-known-word-refresh.ts +++ b/src/main/headless-known-word-refresh.ts @@ -17,16 +17,17 @@ export async function runHeadlessKnownWordRefresh(input: { }; requestAppQuit: () => void; }): Promise { - if (input.resolvedConfig.ankiConnect.enabled !== true) { + const effectiveAnkiConfig = + input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ?? + input.resolvedConfig.ankiConnect; + + if (effectiveAnkiConfig.enabled !== true) { input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled'); process.exitCode = 1; input.requestAppQuit(); return; } - const effectiveAnkiConfig = - input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ?? - input.resolvedConfig.ankiConnect; const integration = new AnkiIntegration( effectiveAnkiConfig, new SubtitleTimingTracker(), @@ -40,7 +41,7 @@ export async function runHeadlessKnownWordRefresh(input: { cancelled: true, }), path.join(input.userDataPath, 'known-words-cache.json'), - mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai), + mergeAiConfig(input.resolvedConfig.ai, effectiveAnkiConfig.ai), ); try { diff --git a/src/main/headless-startup-runtime.test.ts b/src/main/headless-startup-runtime.test.ts index 26af135a..dc60d5c3 100644 --- a/src/main/headless-startup-runtime.test.ts +++ b/src/main/headless-startup-runtime.test.ts @@ -129,3 +129,48 @@ test('headless startup runtime accepts grouped app lifecycle input', () => { assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' }); assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']); }); + +createHeadlessStartupRuntime< + { mode: string }, + { startAppLifecycle: (args: CliArgs) => void; customFlag: boolean } +>( + // @ts-expect-error custom bootstrap deps require an explicit factory + { + appLifecycleRuntimeRunnerMainDeps: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => {}, + printHelp: () => {}, + logNoRunningInstance: () => {}, + onReady: async () => {}, + onWillQuitCleanup: () => {}, + shouldRestoreWindowsOnActivate: () => false, + restoreWindowsOnActivate: () => {}, + shouldQuitOnWindowAllClosed: () => false, + }, + bootstrap: { + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: (_level: string, _source: LogLevelSource) => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: () => {}, + }, + runStartupBootstrapRuntime: (deps) => { + assert.equal(deps.customFlag, true); + return { mode: 'started' }; + }, + applyStartupState: () => {}, +}); diff --git a/src/main/headless-startup-runtime.ts b/src/main/headless-startup-runtime.ts index 1037f315..5522f7e1 100644 --- a/src/main/headless-startup-runtime.ts +++ b/src/main/headless-startup-runtime.ts @@ -39,34 +39,48 @@ export interface HeadlessStartupBootstrapInput { export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams; -export interface HeadlessStartupRuntimeInput< - TStartupState, - TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, -> { +interface HeadlessStartupRuntimeSharedInput { appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions; appLifecycle?: HeadlessStartupAppLifecycleInput; bootstrap: HeadlessStartupBootstrapInput; createAppLifecycleRuntimeRunner?: ( params: AppLifecycleDepsRuntimeOptions, ) => (args: CliArgs) => void; - createStartupBootstrapRuntimeDeps?: ( + applyStartupState: (startupState: TStartupState) => void; +} + +export interface HeadlessStartupRuntimeDefaultInput + extends HeadlessStartupRuntimeSharedInput { + createStartupBootstrapRuntimeDeps?: undefined; + runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState; +} + +export interface HeadlessStartupRuntimeCustomInput + extends HeadlessStartupRuntimeSharedInput { + createStartupBootstrapRuntimeDeps: ( deps: StartupBootstrapRuntimeFactoryDeps, ) => TStartupBootstrapRuntimeDeps; runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState; - applyStartupState: (startupState: TStartupState) => void; } +export type HeadlessStartupRuntimeInput< + TStartupState, + TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, +> = + | HeadlessStartupRuntimeDefaultInput + | HeadlessStartupRuntimeCustomInput; + export interface HeadlessStartupRuntime { appLifecycleRuntimeRunner: (args: CliArgs) => void; runAndApplyStartupState: () => TStartupState; } -export function createHeadlessStartupRuntime< - TStartupState, - TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, ->( - input: HeadlessStartupRuntimeInput, -): HeadlessStartupRuntime { +function resolveAppLifecycleRuntimeRunnerMainDeps( + input: Pick< + HeadlessStartupRuntimeSharedInput, + 'appLifecycleRuntimeRunnerMainDeps' | 'appLifecycle' + >, +) { const appLifecycleRuntimeRunnerMainDeps = input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle; @@ -74,30 +88,57 @@ export function createHeadlessStartupRuntime< throw new Error('Headless startup runtime needs app lifecycle runtime runner deps'); } - const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({ - startupRuntimeHandlersDeps: { - appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler( - appLifecycleRuntimeRunnerMainDeps, - )(), - createAppLifecycleRuntimeRunner: - input.createAppLifecycleRuntimeRunner ?? - ((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) => - startAppLifecycle( - args, - createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)), - )), - buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({ - ...input.bootstrap, - startAppLifecycle, - }), - createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => - input.createStartupBootstrapRuntimeDeps - ? input.createStartupBootstrapRuntimeDeps(deps) - : (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps), - runStartupBootstrapRuntime: input.runStartupBootstrapRuntime, - applyStartupState: input.applyStartupState, - }, - }); + return createBuildAppLifecycleRuntimeRunnerMainDepsHandler(appLifecycleRuntimeRunnerMainDeps)(); +} + +function buildHeadlessStartupHandlersDeps( + input: HeadlessStartupRuntimeSharedInput, +) { + const appLifecycleRuntimeRunnerMainDeps = + resolveAppLifecycleRuntimeRunnerMainDeps(input); + + return { + appLifecycleRuntimeRunnerMainDeps, + createAppLifecycleRuntimeRunner: + input.createAppLifecycleRuntimeRunner ?? + ((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) => + startAppLifecycle(args, createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)))), + buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({ + ...input.bootstrap, + startAppLifecycle, + }), + applyStartupState: input.applyStartupState, + }; +} + +export function createHeadlessStartupRuntime( + input: HeadlessStartupRuntimeDefaultInput, +): HeadlessStartupRuntime; +export function createHeadlessStartupRuntime( + input: HeadlessStartupRuntimeCustomInput, +): HeadlessStartupRuntime; +export function createHeadlessStartupRuntime( + input: HeadlessStartupRuntimeInput, +): HeadlessStartupRuntime { + const baseDeps = buildHeadlessStartupHandlersDeps(input); + const { appLifecycleRuntimeRunner, runAndApplyStartupState } = + 'createStartupBootstrapRuntimeDeps' in input && input.createStartupBootstrapRuntimeDeps + ? composeHeadlessStartupHandlers({ + startupRuntimeHandlersDeps: { + ...baseDeps, + createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => + input.createStartupBootstrapRuntimeDeps(deps), + runStartupBootstrapRuntime: input.runStartupBootstrapRuntime, + }, + }) + : composeHeadlessStartupHandlers({ + startupRuntimeHandlersDeps: { + ...baseDeps, + createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => + createStartupBootstrapRuntimeDeps(deps), + runStartupBootstrapRuntime: input.runStartupBootstrapRuntime, + }, + }); return { appLifecycleRuntimeRunner, diff --git a/src/main/main-startup-bootstrap.ts b/src/main/main-startup-bootstrap.ts index e401aa3c..77232ebd 100644 --- a/src/main/main-startup-bootstrap.ts +++ b/src/main/main-startup-bootstrap.ts @@ -393,7 +393,7 @@ export function createMainStartupBootstrap( defaultConfig: input.config.defaultConfig, getResolvedConfig: () => input.config.configService.getConfig(), setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'), - hasMpvWebsocketPlugin: () => true, + hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(), }, io: { texthookerService: input.runtime.texthookerService, diff --git a/src/main/main-startup-runtime.ts b/src/main/main-startup-runtime.ts index 2c953196..1888b367 100644 --- a/src/main/main-startup-runtime.ts +++ b/src/main/main-startup-runtime.ts @@ -29,7 +29,11 @@ export function createMainStartupRuntime( ): MainStartupRuntime { const appReady = createAppReadyRuntime(input.appReady); const cliStartup = createCliStartupRuntime(input.cli); - const headlessStartup = createHeadlessStartupRuntime(input.headless); + const headlessStartup = + 'createStartupBootstrapRuntimeDeps' in input.headless && + input.headless.createStartupBootstrapRuntimeDeps + ? createHeadlessStartupRuntime(input.headless) + : createHeadlessStartupRuntime(input.headless); return { appReady, diff --git a/src/main/mpv-runtime-bootstrap.ts b/src/main/mpv-runtime-bootstrap.ts index 1e79554d..8bfe7a13 100644 --- a/src/main/mpv-runtime-bootstrap.ts +++ b/src/main/mpv-runtime-bootstrap.ts @@ -260,15 +260,7 @@ export function createMpvRuntimeFromMainState( handleMpvConnectionChange: (connected) => { input.youtube.handleMpvConnectionChange(connected); }, - handleMediaPathChange: (path) => { - input.youtube.invalidatePendingAutoplayReadyFallbacks(); - input.currentMediaTokenizationGate.updateCurrentMediaPath(path); - input.startupOsdSequencer.reset(); - input.youtube.handleMediaPathChange(path); - if (path) { - input.stats.ensureImmersionTrackerStarted(); - } - }, + handleMediaPathChange: (path) => input.youtube.handleMediaPathChange(path), handleSubtitleTrackChange: (sid) => { input.youtube.handleSubtitleTrackChange(sid); }, diff --git a/src/main/overlay-ui-runtime.test.ts b/src/main/overlay-ui-runtime.test.ts index f5f7ca29..6ea3f0e6 100644 --- a/src/main/overlay-ui-runtime.test.ts +++ b/src/main/overlay-ui-runtime.test.ts @@ -308,6 +308,153 @@ test('overlay ui runtime initializes overlay runtime before visible action when ]); }); +test('overlay ui runtime initializes overlay runtime before overlay visibility action when needed', async () => { + const calls: string[] = []; + let overlayRuntimeInitialized = false; + + const overlayUi = createOverlayUiRuntime({ + windowState: { + getMainWindow: () => null, + setMainWindow: () => {}, + getModalWindow: () => null, + setModalWindow: () => {}, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getOverlayDebugVisualizationEnabled: () => false, + setOverlayDebugVisualizationEnabled: () => {}, + }, + geometry: { + getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + modal: { + onModalStateChange: () => {}, + }, + modalRuntime: { + handleOverlayModalClosed: () => {}, + notifyOverlayModalOpened: () => {}, + waitForModalOpen: async () => false, + getRestoreVisibleOverlayOnModalClose: () => new Set(), + openRuntimeOptionsPalette: () => {}, + sendToActiveOverlayWindow: () => false, + }, + visibilityService: { + getModalActive: () => false, + getForceMousePassthrough: () => false, + getWindowTracker: () => null, + getTrackerNotReadyWarningShown: () => false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => {}, + ensureOverlayWindowLevel: () => {}, + syncPrimaryOverlayWindowLayer: () => {}, + enforceOverlayLayerOrder: () => {}, + syncOverlayShortcuts: () => {}, + isMacOSPlatform: () => false, + isWindowsPlatform: () => false, + showOverlayLoadingOsd: () => {}, + resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }), + }, + overlayWindows: { + createOverlayWindowCore: () => createWindow(), + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => false, + getYomitanSession: () => null, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + }, + visibilityActions: { + setVisibleOverlayVisibleCore: ({ visible }) => { + calls.push(`setVisible:${visible}`); + }, + }, + overlayActions: { + getRuntimeOptionsManager: () => null, + getMpvClient: () => null, + broadcastRuntimeOptionsChangedRuntime: () => {}, + broadcastToOverlayWindows: () => {}, + setOverlayDebugVisualizationEnabledRuntime: () => {}, + }, + tray: null, + bootstrap: { + initializeOverlayRuntimeMainDeps: { + appState: { + backendOverride: null, + windowTracker: null, + subtitleTimingTracker: null, + mpvClient: null, + mpvSocketPath: '/tmp/mpv.sock', + runtimeOptionsManager: null, + ankiIntegration: null, + }, + overlayManager: { + getVisibleOverlayVisible: () => false, + }, + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => {}, + }, + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => {}, + }, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + getOverlayWindows: () => [], + getResolvedConfig: () => ({ ankiConnect: {} }) as never, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => () => Promise.resolve({} as never), + getKnownWordCacheStatePath: () => '/tmp/known.json', + shouldStartAnkiIntegration: () => false, + }, + initializeOverlayRuntimeBootstrapDeps: { + isOverlayRuntimeInitialized: () => overlayRuntimeInitialized, + initializeOverlayRuntimeCore: () => { + calls.push('initializeOverlayRuntimeCore'); + }, + setOverlayRuntimeInitialized: (initialized) => { + overlayRuntimeInitialized = initialized; + calls.push(`setInitialized:${initialized}`); + }, + startBackgroundWarmups: () => { + calls.push('startBackgroundWarmups'); + }, + }, + onInitialized: () => { + calls.push('onInitialized'); + }, + }, + runtimeState: { + isOverlayRuntimeInitialized: () => overlayRuntimeInitialized, + setOverlayRuntimeInitialized: (initialized) => { + overlayRuntimeInitialized = initialized; + }, + }, + mpvSubtitle: { + ensureOverlayMpvSubtitlesHidden: async () => { + calls.push('hideMpvSubs'); + }, + syncOverlayMpvSubtitleSuppression: () => { + calls.push('syncMpvSubs'); + }, + }, + }); + + overlayUi.setOverlayVisible(true); + + assert.deepEqual(calls, [ + 'setInitialized:true', + 'initializeOverlayRuntimeCore', + 'startBackgroundWarmups', + 'onInitialized', + 'syncMpvSubs', + 'hideMpvSubs', + 'setVisible:true', + 'syncMpvSubs', + ]); +}); + test('overlay ui runtime delegates modal actions to injected modal runtime', async () => { const calls: string[] = []; const restoreOnClose = new Set(); diff --git a/src/main/overlay-ui-runtime.ts b/src/main/overlay-ui-runtime.ts index 4af71faf..a2a88151 100644 --- a/src/main/overlay-ui-runtime.ts +++ b/src/main/overlay-ui-runtime.ts @@ -368,6 +368,7 @@ export function createOverlayUiRuntime( } function setOverlayVisible(visible: boolean): void { + ensureOverlayWindowsReadyForVisibilityActions(); if (visible) { void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden(); } diff --git a/src/main/runtime/discord-presence-runtime.ts b/src/main/runtime/discord-presence-runtime.ts index da200c49..8a7b4d5f 100644 --- a/src/main/runtime/discord-presence-runtime.ts +++ b/src/main/runtime/discord-presence-runtime.ts @@ -1,4 +1,4 @@ -import { createDiscordPresenceService } from '../../core/services'; +import { createDiscordPresenceService } from '../../core/services/discord-presence'; import type { ResolvedConfig } from '../../types'; import { createDiscordRpcClient } from './discord-rpc-client.js'; @@ -95,6 +95,10 @@ export function createDiscordPresenceRuntimeFromMainState(input: { }) { const sessionStartedAtMs = Date.now(); let mediaDurationSec: number | null = null; + const stopCurrentDiscordPresenceService = async (): Promise => { + await input.appState.discordPresenceService?.stop?.(); + input.appState.discordPresenceService = null; + }; const discordPresenceRuntime = createDiscordPresenceRuntime({ getDiscordPresenceService: () => input.appState.discordPresenceService, @@ -114,10 +118,11 @@ export function createDiscordPresenceRuntimeFromMainState(input: { const initializeDiscordPresenceService = async (): Promise => { if (input.getResolvedConfig().discordPresence.enabled !== true) { - input.appState.discordPresenceService = null; + await stopCurrentDiscordPresenceService(); return; } + await stopCurrentDiscordPresenceService(); input.appState.discordPresenceService = createDiscordPresenceService({ config: input.getResolvedConfig().discordPresence, createClient: () => createDiscordRpcClient(input.appId), diff --git a/src/main/stats-runtime-coordinator.ts b/src/main/stats-runtime-coordinator.ts index 50a64adc..60cfea3a 100644 --- a/src/main/stats-runtime-coordinator.ts +++ b/src/main/stats-runtime-coordinator.ts @@ -112,7 +112,8 @@ export function createStatsRuntimeCoordinator( await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: true, }); - return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); + const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); + return result.noteId; }, openExternal: input.actions.openExternal, requestAppQuit: input.actions.requestAppQuit, diff --git a/src/main/stats-runtime.test.ts b/src/main/stats-runtime.test.ts index e3fca227..29de318c 100644 --- a/src/main/stats-runtime.test.ts +++ b/src/main/stats-runtime.test.ts @@ -129,3 +129,44 @@ test('stats runtime stops owned server and clears daemon state during quit clean assert.equal(runtime.getStatsServer(), null); }); }); + +test('stats runtime stops the in-process background server without signalling the current process', async () => { + await withTempDir(async (dir) => { + const statePath = path.join(dir, 'stats-daemon.json'); + const calls: string[] = []; + + const runtime = createStatsRuntime({ + statsDaemonStatePath: statePath, + getResolvedConfig: () => ({ + immersionTracking: { enabled: true }, + stats: { serverPort: 6972 }, + }), + getImmersionTracker: () => ({}) as never, + ensureImmersionTrackerStartedCore: () => {}, + startStatsServer: () => ({ + close: () => { + calls.push('close'); + }, + }), + openExternal: async () => {}, + exitAppWithCode: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + getCurrentPid: () => 321, + isProcessAlive: () => true, + killProcess: () => { + calls.push('kill'); + }, + now: () => 500, + }); + + runtime.ensureBackgroundStatsServerStarted(); + const result = await runtime.stopBackgroundStatsServer(); + + assert.deepEqual(result, { ok: true, stale: false }); + assert.deepEqual(calls, ['close']); + assert.equal(fs.existsSync(statePath), false); + assert.equal(runtime.getStatsServer(), null); + }); +}); diff --git a/src/main/stats-runtime.ts b/src/main/stats-runtime.ts index 5150bd6c..64560320 100644 --- a/src/main/stats-runtime.ts +++ b/src/main/stats-runtime.ts @@ -270,6 +270,15 @@ export function createStatsRuntime< removeBackgroundStatsServerState(input.statsDaemonStatePath); return { ok: true, stale: true }; } + if (state.pid === getCurrentPid()) { + if (!statsServer) { + removeBackgroundStatsServerState(input.statsDaemonStatePath); + return { ok: true, stale: true }; + } + + stopStatsServer(); + return { ok: true, stale: false }; + } if (!isProcessAlive(state.pid)) { removeBackgroundStatsServerState(input.statsDaemonStatePath); return { ok: true, stale: true };