diff --git a/src/main.ts b/src/main.ts index 429f75a0..b1d191fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -351,10 +351,8 @@ import { runStartupBootstrapRuntime, saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, - addYomitanNoteViaSearch, clearYomitanParserCachesForWindow, getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, - syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, sendMpvCommandRuntime, setMpvSubVisibilityRuntime, setOverlayDebugVisualizationEnabledRuntime, @@ -375,7 +373,6 @@ import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprla import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { probeYoutubeTracks } from './core/services/youtube/track-probe'; -import { startStatsServer } from './core/services/stats-server'; import { destroyStatsWindow, promoteStatsOverlayAbovePlayback, @@ -453,14 +450,7 @@ import { createRunStatsCliCommandHandler, writeStatsCliCommandResponse, } from './main/runtime/stats-cli-command'; -import { - isBackgroundStatsServerProcessAlive, - readBackgroundStatsServerState, - removeBackgroundStatsServerState, - resolveBackgroundStatsServerUrl, - writeBackgroundStatsServerState, -} from './main/runtime/stats-daemon'; -import { createEnsureStatsServerUrlHandler } from './main/runtime/stats-server-routing'; +import { createStatsServerRuntime } from './main/runtime/stats-server-runtime'; import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { @@ -594,7 +584,6 @@ import { import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; -import { shouldForceOverrideYomitanAnkiServer } from './main/runtime/yomitan-anki-server'; import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync'; import { type AnilistMediaGuessRuntimeState, @@ -1010,45 +999,49 @@ const reportFatalError = createFatalErrorReporter({ }); let forceQuitTimer: ReturnType | null = null; -let statsServer: ReturnType | null = null; -const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json'); - -function readLiveBackgroundStatsDaemonState(): { - pid: number; - port: number; - startedAtMs: number; -} | null { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (state.pid === process.pid && !statsServer) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return null; - } - return state; -} - -function clearOwnedBackgroundStatsDaemonState(): void { - const state = readBackgroundStatsServerState(statsDaemonStatePath); - if (state?.pid === process.pid) { - removeBackgroundStatsServerState(statsDaemonStatePath); - } -} - -function stopStatsServer(): void { - if (!statsServer) { - return; - } - statsServer.close(); - statsServer = null; - clearOwnedBackgroundStatsDaemonState(); -} +const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); +const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); +const statsServerRuntime = createStatsServerRuntime({ + userDataPath: USER_DATA_PATH, + statsDistPath, + getResolvedConfig: () => getResolvedConfig(), + getImmersionTracker: () => appState.immersionTracker, + setAppStateStatsServer: (server) => { + appState.statsServer = server; + }, + getMpvSocketPath: () => appState.mpvSocketPath, + getYomitanExt: () => appState.yomitanExt, + getYomitanSession: () => appState.yomitanSession, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (w) => { + appState.yomitanParserWindow = w; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (p) => { + appState.yomitanParserReadyPromise = p; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (p) => { + appState.yomitanParserInitPromise = p; + }, + getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(), + getAnilistRateLimiter: () => anilistRateLimiter, + resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => { + appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); + }, + resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term), + ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(), + setStatsStartupInProgress: (inProgress) => { + appState.statsStartupInProgress = inProgress; + }, +}); +const { + stopStatsServer, + ensureStatsServerStarted, + ensureBackgroundStatsServerStarted, + stopBackgroundStatsServer, +} = statsServerRuntime; function requestAppQuit(): void { destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); @@ -4618,149 +4611,6 @@ const { }); registerProtocolUrlHandlersHandler(); -const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); -const statsPreloadPath = path.join(__dirname, 'preload-stats.js'); - -const startLocalStatsServer = (): void => { - const tracker = appState.immersionTracker; - if (!tracker) { - throw new Error('Immersion tracker failed to initialize.'); - } - if (!statsServer) { - const yomitanDeps = { - getYomitanExt: () => appState.yomitanExt, - getYomitanSession: () => appState.yomitanSession, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (w: BrowserWindow | null) => { - appState.yomitanParserWindow = w; - }, - getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, - setYomitanParserReadyPromise: (p: Promise | null) => { - appState.yomitanParserReadyPromise = p; - }, - getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, - setYomitanParserInitPromise: (p: Promise | null) => { - appState.yomitanParserInitPromise = p; - }, - }; - const yomitanLogger = createLogger('main:yomitan-stats'); - statsServer = startStatsServer({ - port: getResolvedConfig().stats.serverPort, - staticDir: statsDistPath, - tracker, - knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), - mpvSocketPath: appState.mpvSocketPath, - getAnkiConnectConfig: () => getResolvedConfig().ankiConnect, - getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, - getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, - getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path, - anilistRateLimiter, - resolveAnkiNoteId: (noteId: number) => - appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, - resolveSentenceSearchHeadwords, - addYomitanNote: async (word: string) => { - const ankiConnectConfig = 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) { - appState.ankiIntegration?.trackDuplicateNoteIdsForNote( - result.noteId, - result.duplicateNoteIds, - ); - } - return result.noteId; - }, - }); - appState.statsServer = statsServer; - } - appState.statsServer = statsServer; -}; - -const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({ - currentPid: process.pid, - readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath), - removeBackgroundState: () => { - removeBackgroundStatsServerState(statsDaemonStatePath); - }, - isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid), - hasLocalStatsServer: () => statsServer !== null, - startLocalStatsServer, - getConfiguredPort: () => getResolvedConfig().stats.serverPort, -}); - -const ensureBackgroundStatsServerStarted = (): { - url: string; - runningInCurrentProcess: boolean; -} => { - const liveDaemon = readLiveBackgroundStatsDaemonState(); - if (liveDaemon && liveDaemon.pid !== process.pid) { - return { - url: resolveBackgroundStatsServerUrl(liveDaemon), - runningInCurrentProcess: false, - }; - } - - appState.statsStartupInProgress = true; - try { - ensureImmersionTrackerStarted(); - } finally { - appState.statsStartupInProgress = false; - } - - const port = 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 = readBackgroundStatsServerState(statsDaemonStatePath); - if (!state) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - if (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: true }; - } - - try { - process.kill(state.pid, 'SIGTERM'); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { - removeBackgroundStatsServerState(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 (!isBackgroundStatsServerProcessAlive(state.pid)) { - removeBackgroundStatsServerState(statsDaemonStatePath); - return { ok: true, stale: false }; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - throw new Error('Timed out stopping background stats server.'); -}; - const resolveLegacyVocabularyPos = async (row: { headword: string; word: string; diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 12bbd9ed..647c1cee 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -7,6 +7,10 @@ function readMainSource(): string { return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8'); } +function readSource(relPath: string): string { + return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8'); +} + test('manual watched session action starts immersion tracker before marking watched', () => { const source = readMainSource(); const actionBlock = source.match( @@ -346,18 +350,18 @@ test('warm tokenization release can signal readiness before the first subtitle a }); test('stats server Yomitan note creation honors configured Anki server override policy', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/stats-server-runtime.ts'); const startStatsServerBlock = source.match( - /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, + /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, )?.groups?.body; const addYomitanNoteBlock = startStatsServerBlock?.match( - /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; assert.ok(addYomitanNoteBlock); assert.match( addYomitanNoteBlock, - /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, + /const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/, ); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts new file mode 100644 index 00000000..3b7b37cd --- /dev/null +++ b/src/main/runtime/stats-server-runtime.ts @@ -0,0 +1,239 @@ +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, + readBackgroundStatsServerState, + removeBackgroundStatsServerState, + resolveBackgroundStatsServerUrl, + writeBackgroundStatsServerState, +} from './stats-daemon'; +import { createEnsureStatsServerUrlHandler } from './stats-server-routing'; +import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server'; + +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; +} + +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'); + + function readLiveBackgroundStatsDaemonState(): { + pid: number; + port: number; + startedAtMs: number; + } | null { + const state = readBackgroundStatsServerState(statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + if (state.pid === process.pid && !statsServer) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + if (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return null; + } + return state; + } + + function clearOwnedBackgroundStatsDaemonState(): void { + const state = readBackgroundStatsServerState(statsDaemonStatePath); + if (state?.pid === process.pid) { + removeBackgroundStatsServerState(statsDaemonStatePath); + } + } + + function stopStatsServer(): void { + if (!statsServer) { + return; + } + statsServer.close(); + statsServer = 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: () => readBackgroundStatsServerState(statsDaemonStatePath), + removeBackgroundState: () => { + removeBackgroundStatsServerState(statsDaemonStatePath); + }, + isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(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 = readBackgroundStatsServerState(statsDaemonStatePath); + if (!state) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + if (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(statsDaemonStatePath); + return { ok: true, stale: true }; + } + + try { + process.kill(state.pid, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') { + removeBackgroundStatsServerState(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 (!isBackgroundStatsServerProcessAlive(state.pid)) { + removeBackgroundStatsServerState(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, + }; +}