import path from 'node:path'; import type { BrowserWindow } from 'electron'; import { addYomitanNoteViaSearch, syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, } from '../../core/services'; import { startStatsServer } from '../../core/services/stats-server'; import { createLogger } from '../../logger'; import type { ResolvedConfig } from '../../types/config'; import type { AppState } from '../state'; import { isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive, readBackgroundStatsServerState as defaultReadBackgroundStatsServerState, removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState, resolveBackgroundStatsServerUrl, verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity, writeBackgroundStatsServerState, } from './stats-daemon'; import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server'; export function isSelfOwnedBackgroundStatsDaemonState(state: { pid: number; port?: number; startedAtMs?: number; }): boolean { return state.pid === process.pid; } export function shouldClearAppStateStatsServerOnStop(options: { hadStatsServer: boolean; }): boolean { return options.hadStatsServer; } export interface StatsServerRuntimeDeps { userDataPath: string; statsDistPath: string; getResolvedConfig: () => ResolvedConfig; getImmersionTracker: () => AppState['immersionTracker']; setAppStateStatsServer: (server: AppState['statsServer']) => void; getMpvSocketPath: () => AppState['mpvSocketPath']; getYomitanExt: () => AppState['yomitanExt']; getYomitanSession: () => AppState['yomitanSession']; getYomitanParserWindow: () => AppState['yomitanParserWindow']; setYomitanParserWindow: (w: BrowserWindow | null) => void; getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise']; setYomitanParserReadyPromise: (p: Promise | null) => void; getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise']; setYomitanParserInitPromise: (p: Promise | null) => void; getYomitanAnkiDeckName: () => Promise; getAnilistRateLimiter: () => NonNullable< Parameters[0]['anilistRateLimiter'] >; resolveAnkiNoteId: (noteId: number) => number; trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void; resolveSentenceSearchHeadwords: (term: string) => Promise; ensureImmersionTrackerStarted: () => void; setStatsStartupInProgress: (inProgress: boolean) => void; readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState; removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState; isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive; verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity; killProcess?: (pid: number, signal: NodeJS.Signals) => void; } export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { stopStatsServer: () => void; ensureStatsServerStarted: ReturnType; ensureBackgroundStatsServerStarted: () => { url: string; runningInCurrentProcess: boolean; }; stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>; } { let statsServer: ReturnType | null = null; const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json'); const readDaemonState = deps.readBackgroundStatsServerState ?? ((statePath: string) => defaultReadBackgroundStatsServerState(statePath)); const removeDaemonState = deps.removeBackgroundStatsServerState ?? ((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath)); const isDaemonAlive = deps.isBackgroundStatsServerProcessAlive ?? ((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid)); const verifyDaemonIdentity = deps.verifyBackgroundStatsServerIdentity ?? ((pid: number, startedAtMs: number) => defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs)); const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal)); function readLiveBackgroundStatsDaemonState(): { pid: number; port: number; startedAtMs: number; } | null { const state = readDaemonState(statsDaemonStatePath); if (!state) { removeDaemonState(statsDaemonStatePath); return null; } if (state.pid === process.pid && !statsServer) { removeDaemonState(statsDaemonStatePath); return null; } if (!isDaemonAlive(state.pid)) { removeDaemonState(statsDaemonStatePath); return null; } return state; } function clearOwnedBackgroundStatsDaemonState(): void { const state = readDaemonState(statsDaemonStatePath); if (state?.pid === process.pid) { removeDaemonState(statsDaemonStatePath); } } function stopStatsServer(): void { if (!statsServer) { return; } statsServer.close(); statsServer = null; if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) { deps.setAppStateStatsServer(null); } clearOwnedBackgroundStatsDaemonState(); } const startLocalStatsServer = (): void => { const tracker = deps.getImmersionTracker(); if (!tracker) { throw new Error('Immersion tracker failed to initialize.'); } if (!statsServer) { const yomitanDeps = { getYomitanExt: () => deps.getYomitanExt(), getYomitanSession: () => deps.getYomitanSession(), getYomitanParserWindow: () => deps.getYomitanParserWindow(), setYomitanParserWindow: (w: BrowserWindow | null) => { deps.setYomitanParserWindow(w); }, getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), setYomitanParserReadyPromise: (p: Promise | null) => { deps.setYomitanParserReadyPromise(p); }, getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), setYomitanParserInitPromise: (p: Promise | null) => { deps.setYomitanParserInitPromise(p); }, }; const yomitanLogger = createLogger('main:yomitan-stats'); statsServer = startStatsServer({ port: deps.getResolvedConfig().stats.serverPort, staticDir: deps.statsDistPath, tracker, knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'), mpvSocketPath: deps.getMpvSocketPath(), getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect, getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName, getSecondarySubtitleLanguages: () => deps.getResolvedConfig().secondarySub.secondarySubLanguages, getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path, anilistRateLimiter: deps.getAnilistRateLimiter(), resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId), resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term), addYomitanNote: async (word: string) => { const ankiConnectConfig = deps.getResolvedConfig().ankiConnect; const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), deck: ankiConnectConfig.deck, }); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); if (result.noteId && result.duplicateNoteIds.length > 0) { deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds); } return result.noteId; }, }); deps.setAppStateStatsServer(statsServer); } deps.setAppStateStatsServer(statsServer); }; const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ currentPid: process.pid, readBackgroundState: () => readDaemonState(statsDaemonStatePath), removeBackgroundState: () => { removeDaemonState(statsDaemonStatePath); }, isProcessAlive: (pid) => isDaemonAlive(pid), hasLocalStatsServer: () => statsServer !== null, startLocalStatsServer, getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort, }); const ensureBackgroundStatsServerStarted = (): { url: string; runningInCurrentProcess: boolean; } => { const liveDaemon = readLiveBackgroundStatsDaemonState(); if (liveDaemon && liveDaemon.pid !== process.pid) { return { url: resolveBackgroundStatsServerUrl(liveDaemon), runningInCurrentProcess: false, }; } deps.setStatsStartupInProgress(true); try { deps.ensureImmersionTrackerStarted(); } finally { deps.setStatsStartupInProgress(false); } const port = deps.getResolvedConfig().stats.serverPort; const result = ensureStatsServerStarted(); if (result.source === 'local') { writeBackgroundStatsServerState(statsDaemonStatePath, { pid: process.pid, port, startedAtMs: Date.now(), }); } return { url: result.url, runningInCurrentProcess: result.source === 'local' }; }; const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => { const state = readDaemonState(statsDaemonStatePath); if (!state) { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } if (isSelfOwnedBackgroundStatsDaemonState(state)) { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } if (!isDaemonAlive(state.pid)) { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } try { killProcess(state.pid, 'SIGTERM'); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: true }; } if ((error as NodeJS.ErrnoException)?.code === 'EPERM') { throw new Error( `Insufficient permissions to stop background stats server (pid ${state.pid}).`, ); } throw error; } const deadline = Date.now() + 2_000; while (Date.now() < deadline) { if (!isDaemonAlive(state.pid)) { removeDaemonState(statsDaemonStatePath); return { ok: true, stale: false }; } await new Promise((resolve) => setTimeout(resolve, 50)); } throw new Error('Timed out stopping background stats server.'); }; return { stopStatsServer, ensureStatsServerStarted, ensureBackgroundStatsServerStarted, stopBackgroundStatsServer, }; }