import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { ConfigService } from './config/service'; import { createLogger, setLogLevel } from './logger'; import { ImmersionTrackerService } from './core/services/immersion-tracker-service'; import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher'; import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter'; import { startStatsServer } from './core/services/stats-server'; import { removeBackgroundStatsServerState, writeBackgroundStatsServerState, } from './main/runtime/stats-daemon'; import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; import { createInvokeStatsWordHelperHandler, type StatsWordHelperResponse } from './stats-word-helper-client'; const logger = createLogger('stats-daemon'); const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000; function readFlagValue(argv: string[], flag: string): string | undefined { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg) continue; if (arg === flag) { const value = argv[i + 1]; if (value && !value.startsWith('--')) { return value; } return undefined; } if (arg.startsWith(`${flag}=`)) { return arg.split('=', 2)[1]; } } return undefined; } async function waitForWordHelperResponse(responsePath: string): Promise { const deadline = Date.now() + STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS; while (Date.now() < deadline) { try { if (fs.existsSync(responsePath)) { return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsWordHelperResponse; } } catch { // retry until timeout } await new Promise((resolve) => setTimeout(resolve, 100)); } return { ok: false, error: 'Timed out waiting for stats word helper response.', }; } const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({ createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), joinPath: (...parts) => path.join(...parts), spawnHelper: async (options) => { const childArgs = [ options.scriptPath, '--stats-word-helper-response-path', options.responsePath, '--stats-word-helper-user-data-path', options.userDataPath, '--stats-word-helper-word', options.word, ]; const logLevel = readFlagValue(process.argv, '--log-level'); if (logLevel) { childArgs.push('--log-level', logLevel); } const child = spawn(process.execPath, childArgs, { stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, }, }); return await new Promise((resolve) => { child.once('exit', (code) => resolve(code ?? 1)); child.once('error', () => resolve(1)); }); }, waitForResponse: waitForWordHelperResponse, removeDir: (targetPath) => { fs.rmSync(targetPath, { recursive: true, force: true }); }, }); const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim(); const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim(); const logLevel = readFlagValue(process.argv, '--log-level'); if (logLevel) { setLogLevel(logLevel, 'cli'); } if (!userDataPath) { if (responsePath) { writeStatsCliCommandResponse(responsePath, { ok: false, error: 'Missing --stats-user-data-path for stats daemon runner.', }); } process.exit(1); } const daemonUserDataPath = userDataPath; const statePath = path.join(userDataPath, 'stats-daemon.json'); const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json'); const statsDistPath = path.join(__dirname, '..', 'stats', 'dist'); const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js'); let tracker: ImmersionTrackerService | null = null; let statsServer: ReturnType | null = null; function writeFailureResponse(message: string): void { if (!responsePath) return; writeStatsCliCommandResponse(responsePath, { ok: false, error: message }); } function clearOwnedState(): void { const rawState = (() => { try { return JSON.parse(fs.readFileSync(statePath, 'utf8')) as { pid?: number }; } catch { return null; } })(); if (rawState?.pid === process.pid) { removeBackgroundStatsServerState(statePath); } } function shutdown(code = 0): void { try { statsServer?.close(); } catch { // ignore } statsServer = null; try { tracker?.destroy(); } catch { // ignore } tracker = null; clearOwnedState(); process.exit(code); } process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); async function main(): Promise { try { const configService = new ConfigService(daemonUserDataPath); const config = configService.getConfig(); if (config.immersionTracking?.enabled === false) { throw new Error('Immersion tracking is disabled in config.'); } const configuredDbPath = config.immersionTracking?.dbPath?.trim() || ''; tracker = new ImmersionTrackerService({ dbPath: configuredDbPath || path.join(daemonUserDataPath, 'immersion.sqlite'), policy: { batchSize: config.immersionTracking.batchSize, flushIntervalMs: config.immersionTracking.flushIntervalMs, queueCap: config.immersionTracking.queueCap, payloadCapBytes: config.immersionTracking.payloadCapBytes, maintenanceIntervalMs: config.immersionTracking.maintenanceIntervalMs, retention: { eventsDays: config.immersionTracking.retention.eventsDays, telemetryDays: config.immersionTracking.retention.telemetryDays, sessionsDays: config.immersionTracking.retention.sessionsDays, dailyRollupsDays: config.immersionTracking.retention.dailyRollupsDays, monthlyRollupsDays: config.immersionTracking.retention.monthlyRollupsDays, vacuumIntervalDays: config.immersionTracking.retention.vacuumIntervalDays, }, }, }); tracker.setCoverArtFetcher( createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')), ); statsServer = startStatsServer({ port: config.stats.serverPort, staticDir: statsDistPath, tracker, knownWordCachePath, ankiConnectConfig: config.ankiConnect, addYomitanNote: async (word: string) => await invokeStatsWordHelper({ helperScriptPath: wordHelperScriptPath, userDataPath: daemonUserDataPath, word, }), }); writeBackgroundStatsServerState(statePath, { pid: process.pid, port: config.stats.serverPort, startedAtMs: Date.now(), }); if (responsePath) { writeStatsCliCommandResponse(responsePath, { ok: true, url: `http://127.0.0.1:${config.stats.serverPort}`, }); } logger.info(`Background stats daemon listening on http://127.0.0.1:${config.stats.serverPort}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Failed to start stats daemon', message); writeFailureResponse(message); shutdown(1); } } void main();