fix: address latest coderabbit feedback

This commit is contained in:
2026-04-02 22:13:34 -07:00
parent 564a295e5f
commit 8520a4d068
15 changed files with 446 additions and 61 deletions

View File

@@ -74,7 +74,9 @@ export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinator
const window = new BrowserWindow(options); const window = new BrowserWindow(options);
input.appState.anilistSetupWindow = window; input.appState.anilistSetupWindow = window;
window.on('closed', () => { window.on('closed', () => {
input.appState.anilistSetupWindow = null; if (input.appState.anilistSetupWindow === window) {
input.appState.anilistSetupWindow = null;
}
}); });
return window as unknown as AnilistSetupWindowLike; return window as unknown as AnilistSetupWindowLike;
}, },

View File

@@ -53,3 +53,97 @@ test('discord presence lifecycle runtime starts service and publishes presence w
assert.deepEqual(calls, ['start', 'Demo', 'publish']); 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<void>; stop: () => Promise<void> } | 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<void>; stop: () => Promise<void> } | 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']);
});

View File

@@ -49,6 +49,10 @@ export function createDiscordPresenceLifecycleRuntime(
): DiscordPresenceLifecycleRuntime { ): DiscordPresenceLifecycleRuntime {
let discordPresenceMediaDurationSec: number | null = null; let discordPresenceMediaDurationSec: number | null = null;
const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now(); const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now();
const stopCurrentDiscordPresenceService = async (): Promise<void> => {
await input.getDiscordPresenceService()?.stop?.();
input.setDiscordPresenceService(null);
};
const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({ const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({
getDiscordPresenceService: () => input.getDiscordPresenceService(), getDiscordPresenceService: () => input.getDiscordPresenceService(),
@@ -72,19 +76,17 @@ export function createDiscordPresenceLifecycleRuntime(
}, },
initializeDiscordPresenceService: async () => { initializeDiscordPresenceService: async () => {
if (input.getResolvedConfig().discordPresence.enabled !== true) { if (input.getResolvedConfig().discordPresence.enabled !== true) {
input.setDiscordPresenceService(null); await stopCurrentDiscordPresenceService();
return; return;
} }
await stopCurrentDiscordPresenceService();
input.setDiscordPresenceService( input.setDiscordPresenceService(
input.createDiscordPresenceService(input.getResolvedConfig().discordPresence), input.createDiscordPresenceService(input.getResolvedConfig().discordPresence),
); );
await input.getDiscordPresenceService()?.start(); await input.getDiscordPresenceService()?.start();
discordPresenceRuntime.publishDiscordPresence(); discordPresenceRuntime.publishDiscordPresence();
}, },
stopDiscordPresenceService: async () => { stopDiscordPresenceService: stopCurrentDiscordPresenceService,
await input.getDiscordPresenceService()?.stop?.();
input.setDiscordPresenceService(null);
},
}; };
} }

View File

@@ -17,16 +17,17 @@ export async function runHeadlessKnownWordRefresh(input: {
}; };
requestAppQuit: () => void; requestAppQuit: () => void;
}): Promise<void> { }): Promise<void> {
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'); input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
process.exitCode = 1; process.exitCode = 1;
input.requestAppQuit(); input.requestAppQuit();
return; return;
} }
const effectiveAnkiConfig =
input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ??
input.resolvedConfig.ankiConnect;
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
effectiveAnkiConfig, effectiveAnkiConfig,
new SubtitleTimingTracker(), new SubtitleTimingTracker(),
@@ -40,7 +41,7 @@ export async function runHeadlessKnownWordRefresh(input: {
cancelled: true, cancelled: true,
}), }),
path.join(input.userDataPath, 'known-words-cache.json'), path.join(input.userDataPath, 'known-words-cache.json'),
mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai), mergeAiConfig(input.resolvedConfig.ai, effectiveAnkiConfig.ai),
); );
try { try {

View File

@@ -129,3 +129,48 @@ test('headless startup runtime accepts grouped app lifecycle input', () => {
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' }); assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply: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: () => {},
});

View File

@@ -39,34 +39,48 @@ export interface HeadlessStartupBootstrapInput {
export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams; export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams;
export interface HeadlessStartupRuntimeInput< interface HeadlessStartupRuntimeSharedInput<TStartupState> {
TStartupState,
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
> {
appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions; appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions;
appLifecycle?: HeadlessStartupAppLifecycleInput; appLifecycle?: HeadlessStartupAppLifecycleInput;
bootstrap: HeadlessStartupBootstrapInput; bootstrap: HeadlessStartupBootstrapInput;
createAppLifecycleRuntimeRunner?: ( createAppLifecycleRuntimeRunner?: (
params: AppLifecycleDepsRuntimeOptions, params: AppLifecycleDepsRuntimeOptions,
) => (args: CliArgs) => void; ) => (args: CliArgs) => void;
createStartupBootstrapRuntimeDeps?: ( applyStartupState: (startupState: TStartupState) => void;
}
export interface HeadlessStartupRuntimeDefaultInput<TStartupState>
extends HeadlessStartupRuntimeSharedInput<TStartupState> {
createStartupBootstrapRuntimeDeps?: undefined;
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
}
export interface HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>
extends HeadlessStartupRuntimeSharedInput<TStartupState> {
createStartupBootstrapRuntimeDeps: (
deps: StartupBootstrapRuntimeFactoryDeps, deps: StartupBootstrapRuntimeFactoryDeps,
) => TStartupBootstrapRuntimeDeps; ) => TStartupBootstrapRuntimeDeps;
runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState; runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState;
applyStartupState: (startupState: TStartupState) => void;
} }
export type HeadlessStartupRuntimeInput<
TStartupState,
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
> =
| HeadlessStartupRuntimeDefaultInput<TStartupState>
| HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>;
export interface HeadlessStartupRuntime<TStartupState> { export interface HeadlessStartupRuntime<TStartupState> {
appLifecycleRuntimeRunner: (args: CliArgs) => void; appLifecycleRuntimeRunner: (args: CliArgs) => void;
runAndApplyStartupState: () => TStartupState; runAndApplyStartupState: () => TStartupState;
} }
export function createHeadlessStartupRuntime< function resolveAppLifecycleRuntimeRunnerMainDeps(
TStartupState, input: Pick<
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps, HeadlessStartupRuntimeSharedInput<unknown>,
>( 'appLifecycleRuntimeRunnerMainDeps' | 'appLifecycle'
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>, >,
): HeadlessStartupRuntime<TStartupState> { ) {
const appLifecycleRuntimeRunnerMainDeps = const appLifecycleRuntimeRunnerMainDeps =
input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle; input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle;
@@ -74,30 +88,57 @@ export function createHeadlessStartupRuntime<
throw new Error('Headless startup runtime needs app lifecycle runtime runner deps'); throw new Error('Headless startup runtime needs app lifecycle runtime runner deps');
} }
const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({ return createBuildAppLifecycleRuntimeRunnerMainDepsHandler(appLifecycleRuntimeRunnerMainDeps)();
startupRuntimeHandlersDeps: { }
appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
appLifecycleRuntimeRunnerMainDeps, function buildHeadlessStartupHandlersDeps<TStartupState>(
)(), input: HeadlessStartupRuntimeSharedInput<TStartupState>,
createAppLifecycleRuntimeRunner: ) {
input.createAppLifecycleRuntimeRunner ?? const appLifecycleRuntimeRunnerMainDeps =
((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) => resolveAppLifecycleRuntimeRunnerMainDeps(input);
startAppLifecycle(
args, return {
createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)), appLifecycleRuntimeRunnerMainDeps,
)), createAppLifecycleRuntimeRunner:
buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({ input.createAppLifecycleRuntimeRunner ??
...input.bootstrap, ((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) =>
startAppLifecycle, startAppLifecycle(args, createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)))),
}), buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ...input.bootstrap,
input.createStartupBootstrapRuntimeDeps startAppLifecycle,
? input.createStartupBootstrapRuntimeDeps(deps) }),
: (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps), applyStartupState: input.applyStartupState,
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime, };
applyStartupState: input.applyStartupState, }
},
}); export function createHeadlessStartupRuntime<TStartupState>(
input: HeadlessStartupRuntimeDefaultInput<TStartupState>,
): HeadlessStartupRuntime<TStartupState>;
export function createHeadlessStartupRuntime<TStartupState, TStartupBootstrapRuntimeDeps>(
input: HeadlessStartupRuntimeCustomInput<TStartupState, TStartupBootstrapRuntimeDeps>,
): HeadlessStartupRuntime<TStartupState>;
export function createHeadlessStartupRuntime<TStartupState, TStartupBootstrapRuntimeDeps>(
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>,
): HeadlessStartupRuntime<TStartupState> {
const baseDeps = buildHeadlessStartupHandlersDeps(input);
const { appLifecycleRuntimeRunner, runAndApplyStartupState } =
'createStartupBootstrapRuntimeDeps' in input && input.createStartupBootstrapRuntimeDeps
? composeHeadlessStartupHandlers<CliArgs, TStartupState, TStartupBootstrapRuntimeDeps>({
startupRuntimeHandlersDeps: {
...baseDeps,
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
input.createStartupBootstrapRuntimeDeps(deps),
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
},
})
: composeHeadlessStartupHandlers<CliArgs, TStartupState, StartupBootstrapRuntimeDeps>({
startupRuntimeHandlersDeps: {
...baseDeps,
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
createStartupBootstrapRuntimeDeps(deps),
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
},
});
return { return {
appLifecycleRuntimeRunner, appLifecycleRuntimeRunner,

View File

@@ -393,7 +393,7 @@ export function createMainStartupBootstrap<TStartupState>(
defaultConfig: input.config.defaultConfig, defaultConfig: input.config.defaultConfig,
getResolvedConfig: () => input.config.configService.getConfig(), getResolvedConfig: () => input.config.configService.getConfig(),
setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'), setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'),
hasMpvWebsocketPlugin: () => true, hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
}, },
io: { io: {
texthookerService: input.runtime.texthookerService, texthookerService: input.runtime.texthookerService,

View File

@@ -29,7 +29,11 @@ export function createMainStartupRuntime<TStartupState>(
): MainStartupRuntime<TStartupState> { ): MainStartupRuntime<TStartupState> {
const appReady = createAppReadyRuntime(input.appReady); const appReady = createAppReadyRuntime(input.appReady);
const cliStartup = createCliStartupRuntime(input.cli); const cliStartup = createCliStartupRuntime(input.cli);
const headlessStartup = createHeadlessStartupRuntime<TStartupState>(input.headless); const headlessStartup =
'createStartupBootstrapRuntimeDeps' in input.headless &&
input.headless.createStartupBootstrapRuntimeDeps
? createHeadlessStartupRuntime(input.headless)
: createHeadlessStartupRuntime(input.headless);
return { return {
appReady, appReady,

View File

@@ -260,15 +260,7 @@ export function createMpvRuntimeFromMainState(
handleMpvConnectionChange: (connected) => { handleMpvConnectionChange: (connected) => {
input.youtube.handleMpvConnectionChange(connected); input.youtube.handleMpvConnectionChange(connected);
}, },
handleMediaPathChange: (path) => { handleMediaPathChange: (path) => input.youtube.handleMediaPathChange(path),
input.youtube.invalidatePendingAutoplayReadyFallbacks();
input.currentMediaTokenizationGate.updateCurrentMediaPath(path);
input.startupOsdSequencer.reset();
input.youtube.handleMediaPathChange(path);
if (path) {
input.stats.ensureImmersionTrackerStarted();
}
},
handleSubtitleTrackChange: (sid) => { handleSubtitleTrackChange: (sid) => {
input.youtube.handleSubtitleTrackChange(sid); input.youtube.handleSubtitleTrackChange(sid);
}, },

View File

@@ -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<OverlayHostedModal>(),
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 () => { test('overlay ui runtime delegates modal actions to injected modal runtime', async () => {
const calls: string[] = []; const calls: string[] = [];
const restoreOnClose = new Set<OverlayHostedModal>(); const restoreOnClose = new Set<OverlayHostedModal>();

View File

@@ -368,6 +368,7 @@ export function createOverlayUiRuntime<TWindow extends WindowLike>(
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (visible) { if (visible) {
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden(); void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
} }

View File

@@ -1,4 +1,4 @@
import { createDiscordPresenceService } from '../../core/services'; import { createDiscordPresenceService } from '../../core/services/discord-presence';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
import { createDiscordRpcClient } from './discord-rpc-client.js'; import { createDiscordRpcClient } from './discord-rpc-client.js';
@@ -95,6 +95,10 @@ export function createDiscordPresenceRuntimeFromMainState(input: {
}) { }) {
const sessionStartedAtMs = Date.now(); const sessionStartedAtMs = Date.now();
let mediaDurationSec: number | null = null; let mediaDurationSec: number | null = null;
const stopCurrentDiscordPresenceService = async (): Promise<void> => {
await input.appState.discordPresenceService?.stop?.();
input.appState.discordPresenceService = null;
};
const discordPresenceRuntime = createDiscordPresenceRuntime({ const discordPresenceRuntime = createDiscordPresenceRuntime({
getDiscordPresenceService: () => input.appState.discordPresenceService, getDiscordPresenceService: () => input.appState.discordPresenceService,
@@ -114,10 +118,11 @@ export function createDiscordPresenceRuntimeFromMainState(input: {
const initializeDiscordPresenceService = async (): Promise<void> => { const initializeDiscordPresenceService = async (): Promise<void> => {
if (input.getResolvedConfig().discordPresence.enabled !== true) { if (input.getResolvedConfig().discordPresence.enabled !== true) {
input.appState.discordPresenceService = null; await stopCurrentDiscordPresenceService();
return; return;
} }
await stopCurrentDiscordPresenceService();
input.appState.discordPresenceService = createDiscordPresenceService({ input.appState.discordPresenceService = createDiscordPresenceService({
config: input.getResolvedConfig().discordPresence, config: input.getResolvedConfig().discordPresence,
createClient: () => createDiscordRpcClient(input.appId), createClient: () => createDiscordRpcClient(input.appId),

View File

@@ -112,7 +112,8 @@ export function createStatsRuntimeCoordinator(
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true, forceOverride: true,
}); });
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
return result.noteId;
}, },
openExternal: input.actions.openExternal, openExternal: input.actions.openExternal,
requestAppQuit: input.actions.requestAppQuit, requestAppQuit: input.actions.requestAppQuit,

View File

@@ -129,3 +129,44 @@ test('stats runtime stops owned server and clears daemon state during quit clean
assert.equal(runtime.getStatsServer(), null); 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);
});
});

View File

@@ -270,6 +270,15 @@ export function createStatsRuntime<
removeBackgroundStatsServerState(input.statsDaemonStatePath); removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: true }; 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)) { if (!isProcessAlive(state.pid)) {
removeBackgroundStatsServerState(input.statsDaemonStatePath); removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: true }; return { ok: true, stale: true };