/* SubMiner - All-in-one sentence mining overlay Copyright (C) 2024 sudacode This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import { app, BrowserWindow, globalShortcut, clipboard, shell, protocol, Extension, } from "electron"; protocol.registerSchemesAsPrivileged([ { scheme: "chrome-extension", privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, bypassCSP: true, }, }, ]); import * as path from "path"; import * as os from "os"; import * as fs from "fs"; import { MecabTokenizer } from "./mecab-tokenizer"; import type { JimakuApiResponse, JimakuLanguagePreference, SubtitleData, SubtitlePosition, WindowGeometry, SecondarySubMode, SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, KikuFieldGroupingChoice, RuntimeOptionState, MpvSubtitleRenderMetrics, ResolvedConfig, } from "./types"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; import { AnkiIntegration } from "./anki-integration"; import { RuntimeOptionsManager } from "./runtime-options"; import { downloadToFile, isRemoteMediaPath, parseMediaInfo, } from "./jimaku/utils"; import { getSubsyncConfig, } from "./subsync/utils"; import { createLogger, setLogLevel, type LogLevelSource } from "./logger"; import { parseArgs, shouldStartApp, } from "./cli/args"; import type { CliArgs, CliCommandSource } from "./cli/args"; import { printHelp } from "./cli/help"; import { enforceUnsupportedWaylandMode, forceX11Backend, generateDefaultConfigFile, resolveConfiguredShortcuts, resolveKeybindings, showDesktopNotification, } from "./core/utils"; import { MpvIpcClient, SubtitleWebSocketService, TexthookerService, applyMpvSubtitleRenderMetricsPatchService, broadcastRuntimeOptionsChangedRuntimeService, copyCurrentSubtitleService, createOverlayManagerService, createFieldGroupingOverlayRuntimeService, createNumericShortcutRuntimeService, createOverlayContentMeasurementStoreService, createOverlayWindowService, createTokenizerDepsRuntimeService, cycleSecondarySubModeService, enforceOverlayLayerOrderService, ensureOverlayWindowLevelService, getInitialInvisibleOverlayVisibilityService, getJimakuLanguagePreferenceService, getJimakuMaxEntryResultsService, handleMineSentenceDigitService, handleMultiCopyDigitService, hasMpvWebsocketPlugin, initializeOverlayRuntimeService, isAutoUpdateEnabledRuntimeService, jimakuFetchJsonService, loadSubtitlePositionService, loadYomitanExtensionService, markLastCardAsAudioCardService, DEFAULT_MPV_SUBTITLE_RENDER_METRICS, mineSentenceCardService, openYomitanSettingsWindow, playNextSubtitleRuntimeService, registerGlobalShortcutsService, replayCurrentSubtitleRuntimeService, resolveJimakuApiKeyService, runStartupBootstrapRuntimeService, saveSubtitlePositionService, sendMpvCommandRuntimeService, setInvisibleOverlayVisibleService, setMpvSubVisibilityRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService, setVisibleOverlayVisibleService, shouldAutoInitializeOverlayRuntimeFromConfigService, shouldBindVisibleOverlayToMpvSubVisibilityService, showMpvOsdRuntimeService, tokenizeSubtitleService, triggerFieldGroupingService, updateLastCardFromClipboardService, } from "./core/services"; import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service"; import { createAppReadyRuntimeRunner, } from "./main/app-lifecycle"; import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command"; import { registerIpcRuntimeServices, } from "./main/ipc-runtime"; import { createAnkiJimakuIpcRuntimeServiceDeps, } from "./main/dependencies"; import { handleCliCommandRuntimeServiceWithContext, } from "./main/cli-runtime"; import { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime, createSubsyncRuntimeServiceInputFromState, } from "./main/subsync-runtime"; import { createOverlayModalRuntimeService, type OverlayHostedModal, } from "./main/overlay-runtime"; import { createOverlayShortcutsRuntimeService, } from "./main/overlay-shortcuts-runtime"; import { createJlptDictionaryRuntimeService, getJlptDictionarySearchPaths, } from "./main/jlpt-runtime"; import { createFrequencyDictionaryRuntimeService, getFrequencyDictionarySearchPaths, } from "./main/frequency-dictionary-runtime"; import { createMediaRuntimeService } from "./main/media-runtime"; import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime"; import { applyStartupState, createAppState, } from "./main/state"; import { createStartupBootstrapRuntimeDeps } from "./main/startup"; import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle"; import { ConfigService, DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate, } from "./config"; if (process.platform === "linux") { app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); } const DEFAULT_TEXTHOOKER_PORT = 5174; const DEFAULT_MPV_LOG_FILE = path.join( os.homedir(), ".cache", "SubMiner", "mp.log", ); function resolveConfigDir(): string { const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); const baseDirs = Array.from( new Set([ xdgConfigHome || path.join(os.homedir(), ".config"), path.join(os.homedir(), ".config"), ]), ); const appNames = ["SubMiner", "subminer"]; for (const baseDir of baseDirs) { for (const appName of appNames) { const dir = path.join(baseDir, appName); if ( fs.existsSync(path.join(dir, "config.jsonc")) || fs.existsSync(path.join(dir, "config.json")) ) { return dir; } } } for (const baseDir of baseDirs) { for (const appName of appNames) { const dir = path.join(baseDir, appName); if (fs.existsSync(dir)) { return dir; } } } return path.join(baseDirs[0], "SubMiner"); } const CONFIG_DIR = resolveConfigDir(); const USER_DATA_PATH = CONFIG_DIR; const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; const configService = new ConfigService(CONFIG_DIR); const isDev = process.argv.includes("--dev") || process.argv.includes("--debug"); const texthookerService = new TexthookerService(); const subtitleWsService = new SubtitleWebSocketService(); const logger = createLogger("main"); const appLogger = { logInfo: (message: string) => { logger.info(message); }, logWarning: (message: string) => { logger.warn(message); }, logError: (message: string, details: unknown) => { logger.error(message, details); }, logNoRunningInstance: () => { logger.error("No running instance. Use --start to launch the app."); }, logConfigWarning: (warning: { path: string; message: string; value: unknown; fallback: unknown; }) => { logger.warn( `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, ); }, }; function getDefaultSocketPath(): string { if (process.platform === "win32") { return "\\\\.\\pipe\\subminer-socket"; } return "/tmp/subminer-socket"; } if (!fs.existsSync(USER_DATA_PATH)) { fs.mkdirSync(USER_DATA_PATH, { recursive: true }); } app.setPath("userData", USER_DATA_PATH); process.on("SIGINT", () => { app.quit(); }); process.on("SIGTERM", () => { app.quit(); }); const overlayManager = createOverlayManagerService(); const overlayContentMeasurementStore = createOverlayContentMeasurementStoreService({ now: () => Date.now(), warn: (message: string) => logger.warn(message), }); const overlayModalRuntime = createOverlayModalRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), getInvisibleWindow: () => overlayManager.getInvisibleWindow(), }); const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ getConfiguredShortcuts: () => getConfiguredShortcuts(), getShortcutsRegistered: () => appState.shortcutsRegistered, setShortcutsRegistered: (registered) => { appState.shortcutsRegistered = registered; }, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, showMpvOsd: (text: string) => showMpvOsd(text), openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, openJimaku: () => { sendToActiveOverlayWindow("jimaku:open", undefined, { restoreOnModalClose: "jimaku", }); }, markAudioCard: () => markLastCardAsAudioCard(), copySubtitleMultiple: (timeoutMs) => { startPendingMultiCopy(timeoutMs); }, copySubtitle: () => { copyCurrentSubtitle(); }, toggleSecondarySubMode: () => cycleSecondarySubMode(), updateLastCardFromClipboard: () => updateLastCardFromClipboard(), triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), mineSentenceCard: () => mineSentenceCard(), mineSentenceMultiple: (timeoutMs) => { startPendingMineSentenceMultiple(timeoutMs); }, cancelPendingMultiCopy: () => { cancelPendingMultiCopy(); }, cancelPendingMineSentenceMultiple: () => { cancelPendingMineSentenceMultiple(); }, }); const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({ isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, getSearchPaths: () => getJlptDictionarySearchPaths({ getDictionaryRoots: () => [ path.join(__dirname, "..", "..", "vendor", "yomitan-jlpt-vocab"), path.join(app.getAppPath(), "vendor", "yomitan-jlpt-vocab"), path.join(process.resourcesPath, "yomitan-jlpt-vocab"), path.join(process.resourcesPath, "app.asar", "vendor", "yomitan-jlpt-vocab"), USER_DATA_PATH, app.getPath("userData"), path.join(os.homedir(), ".config", "SubMiner"), path.join(os.homedir(), ".config", "subminer"), path.join(os.homedir(), "Library", "Application Support", "SubMiner"), path.join(os.homedir(), "Library", "Application Support", "subminer"), process.cwd(), ], }), setJlptLevelLookup: (lookup) => { appState.jlptLevelLookup = lookup; }, log: (message) => { logger.info(`[JLPT] ${message}`); }, }); const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({ isFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, getSearchPaths: () => getFrequencyDictionarySearchPaths({ getDictionaryRoots: () => [ path.join(__dirname, "..", "..", "vendor", "jiten_freq_global"), path.join(__dirname, "..", "..", "vendor", "frequency-dictionary"), path.join(app.getAppPath(), "vendor", "jiten_freq_global"), path.join(app.getAppPath(), "vendor", "frequency-dictionary"), path.join(process.resourcesPath, "jiten_freq_global"), path.join(process.resourcesPath, "frequency-dictionary"), path.join(process.resourcesPath, "app.asar", "vendor", "jiten_freq_global"), path.join(process.resourcesPath, "app.asar", "vendor", "frequency-dictionary"), USER_DATA_PATH, app.getPath("userData"), path.join(os.homedir(), ".config", "SubMiner"), path.join(os.homedir(), ".config", "subminer"), path.join(os.homedir(), "Library", "Application Support", "SubMiner"), path.join(os.homedir(), "Library", "Application Support", "subminer"), process.cwd(), ].filter((dictionaryRoot) => dictionaryRoot), getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath, }), setFrequencyRankLookup: (lookup) => { appState.frequencyRankLookup = lookup; }, log: (message) => { logger.info(`[Frequency] ${message}`); }, }); function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { return appState.fieldGroupingResolver; } function setFieldGroupingResolver( resolver: ((choice: KikuFieldGroupingChoice) => void) | null, ): void { if (!resolver) { appState.fieldGroupingResolver = null; return; } const sequence = ++appState.fieldGroupingResolverSequence; const wrappedResolver = (choice: KikuFieldGroupingChoice): void => { if (sequence !== appState.fieldGroupingResolverSequence) return; resolver(choice); }; appState.fieldGroupingResolver = wrappedResolver; } const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), getResolver: () => getFieldGroupingResolver(), setResolver: (resolver) => setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), sendToVisibleOverlay: (channel, payload, runtimeOptions) => { return overlayModalRuntime.sendToActiveOverlayWindow( channel, payload, runtimeOptions, ); }, }); const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback; const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, "subtitle-positions"); const mediaRuntime = createMediaRuntimeService({ isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath), loadSubtitlePosition: () => loadSubtitlePosition(), getCurrentMediaPath: () => appState.currentMediaPath, getPendingSubtitlePosition: () => appState.pendingSubtitlePosition, getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR, setCurrentMediaPath: (nextPath: string | null) => { appState.currentMediaPath = nextPath; }, clearPendingSubtitlePosition: () => { appState.pendingSubtitlePosition = null; }, setSubtitlePosition: (position: SubtitlePosition | null) => { appState.subtitlePosition = position; }, broadcastSubtitlePosition: (position) => { broadcastToOverlayWindows("subtitle-position:set", position); }, getCurrentMediaTitle: () => appState.currentMediaTitle, setCurrentMediaTitle: (title) => { appState.currentMediaTitle = title; }, }); const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ getMainWindow: () => overlayManager.getMainWindow(), getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), getWindowTracker: () => appState.windowTracker, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; }, updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), updateInvisibleOverlayBounds: (geometry: WindowGeometry) => updateInvisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel(window); }, enforceOverlayLayerOrder: () => { enforceOverlayLayerOrder(); }, syncOverlayShortcuts: () => { overlayShortcutsRuntime.syncOverlayShortcuts(); }, }); function getRuntimeOptionsState(): RuntimeOptionState[] { if (!appState.runtimeOptionsManager) return []; return appState.runtimeOptionsManager.listOptions(); } function getOverlayWindows(): BrowserWindow[] { return overlayManager.getOverlayWindows(); } function restorePreviousSecondarySubVisibility(): void { if (!appState.mpvClient || !appState.mpvClient.connected) return; appState.mpvClient.restorePreviousSecondarySubVisibility(); } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { overlayManager.broadcastToOverlayWindows(channel, ...args); } function broadcastRuntimeOptionsChanged(): void { broadcastRuntimeOptionsChangedRuntimeService( () => getRuntimeOptionsState(), (channel, ...args) => broadcastToOverlayWindows(channel, ...args), ); } function sendToActiveOverlayWindow( channel: string, payload?: unknown, runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, ): boolean { return overlayModalRuntime.sendToActiveOverlayWindow( channel, payload, runtimeOptions, ); } function setOverlayDebugVisualizationEnabled(enabled: boolean): void { setOverlayDebugVisualizationEnabledRuntimeService( appState.overlayDebugVisualizationEnabled, enabled, (next) => { appState.overlayDebugVisualizationEnabled = next; }, (channel, ...args) => broadcastToOverlayWindows(channel, ...args), ); } function openRuntimeOptionsPalette(): void { overlayModalRuntime.openRuntimeOptionsPalette(); } function getResolvedConfig() { return configService.getConfig(); } function getInitialInvisibleOverlayVisibility(): boolean { return getInitialInvisibleOverlayVisibilityService( getResolvedConfig(), process.platform, ); } function shouldAutoInitializeOverlayRuntimeFromConfig(): boolean { return shouldAutoInitializeOverlayRuntimeFromConfigService(getResolvedConfig()); } function shouldBindVisibleOverlayToMpvSubVisibility(): boolean { return shouldBindVisibleOverlayToMpvSubVisibilityService(getResolvedConfig()); } function isAutoUpdateEnabledRuntime(): boolean { return isAutoUpdateEnabledRuntimeService( getResolvedConfig(), appState.runtimeOptionsManager, ); } function getJimakuLanguagePreference(): JimakuLanguagePreference { return getJimakuLanguagePreferenceService(() => getResolvedConfig(), DEFAULT_CONFIG.jimaku.languagePreference); } function getJimakuMaxEntryResults(): number { return getJimakuMaxEntryResultsService(() => getResolvedConfig(), DEFAULT_CONFIG.jimaku.maxEntryResults); } async function resolveJimakuApiKey(): Promise { return resolveJimakuApiKeyService(() => getResolvedConfig()); } async function jimakuFetchJson( endpoint: string, query: Record = {}, ): Promise> { return jimakuFetchJsonService(endpoint, query, { getResolvedConfig: () => getResolvedConfig(), defaultBaseUrl: DEFAULT_CONFIG.jimaku.apiBaseUrl, defaultMaxEntryResults: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultLanguagePreference: DEFAULT_CONFIG.jimaku.languagePreference, }); } function loadSubtitlePosition(): SubtitlePosition | null { appState.subtitlePosition = loadSubtitlePositionService({ currentMediaPath: appState.currentMediaPath, fallbackPosition: getResolvedConfig().subtitlePosition, subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, }); return appState.subtitlePosition; } function saveSubtitlePosition(position: SubtitlePosition): void { appState.subtitlePosition = position; saveSubtitlePositionService({ position, currentMediaPath: appState.currentMediaPath, subtitlePositionsDir: SUBTITLE_POSITIONS_DIR, onQueuePending: (queued) => { appState.pendingSubtitlePosition = queued; }, onPersisted: () => { appState.pendingSubtitlePosition = null; }, }); } const startupState = runStartupBootstrapRuntimeService( createStartupBootstrapRuntimeDeps({ argv: process.argv, parseArgs: (argv: string[]) => parseArgs(argv), setLogLevel: (level: string, source: LogLevelSource) => { setLogLevel(level, source); }, forceX11Backend: (args: CliArgs) => { forceX11Backend(args); }, enforceUnsupportedWaylandMode: (args: CliArgs) => { enforceUnsupportedWaylandMode(args); }, shouldStartApp: (args: CliArgs) => shouldStartApp(args), getDefaultSocketPath: () => getDefaultSocketPath(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, configDir: CONFIG_DIR, defaultConfig: DEFAULT_CONFIG, generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config), generateDefaultConfigFile: ( args: CliArgs, options: { configDir: string; defaultConfig: unknown; generateTemplate: (config: unknown) => string; }, ) => generateDefaultConfigFile(args, options), onConfigGenerated: (exitCode: number) => { process.exitCode = exitCode; app.quit(); }, onGenerateConfigError: (error: Error) => { logger.error(`Failed to generate config: ${error.message}`); process.exitCode = 1; app.quit(); }, startAppLifecycle: createAppLifecycleRuntimeRunner({ app, platform: process.platform, shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), parseArgs: (argv: string[]) => parseArgs(argv), handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => handleCliCommand(nextArgs, source), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), logNoRunningInstance: () => appLogger.logNoRunningInstance(), onReady: createAppReadyRuntimeRunner({ loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { appState.keybindings = resolveKeybindings( getResolvedConfig(), DEFAULT_KEYBINDINGS, ); }, createMpvClient: () => { appState.mpvClient = createMpvClientRuntimeService(); }, reloadConfig: () => { configService.reloadConfig(); appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); }, getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), initRuntimeOptionsManager: () => { appState.runtimeOptionsManager = new RuntimeOptionsManager( () => configService.getConfig().ankiConnect, { applyAnkiPatch: (patch) => { if (appState.ankiIntegration) { appState.ankiIntegration.applyRuntimeConfigPatch(patch); } }, onOptionsChanged: () => { broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, }, ); }, setSecondarySubMode: (mode: SecondarySubMode) => { appState.secondarySubMode = mode; }, defaultSecondarySubMode: "hover", defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), startSubtitleWebsocket: (port: number) => { subtitleWsService.start(port, () => appState.currentSubText); }, log: (message) => appLogger.logInfo(message), createMecabTokenizerAndCheck: async () => { const tokenizer = new MecabTokenizer(); appState.mecabTokenizer = tokenizer; await tokenizer.checkAvailability(); }, createSubtitleTimingTracker: () => { const tracker = new SubtitleTimingTracker(); appState.subtitleTimingTracker = tracker; }, loadYomitanExtension: async () => { await loadYomitanExtension(); }, texthookerOnlyMode: appState.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: () => shouldAutoInitializeOverlayRuntimeFromConfig(), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), }), onWillQuitCleanup: () => { restorePreviousSecondarySubVisibility(); globalShortcut.unregisterAll(); subtitleWsService.stop(); texthookerService.stop(); if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) { appState.yomitanParserWindow.destroy(); } appState.yomitanParserWindow = null; appState.yomitanParserReadyPromise = null; appState.yomitanParserInitPromise = null; if (appState.windowTracker) { appState.windowTracker.stop(); } if (appState.mpvClient && appState.mpvClient.socket) { appState.mpvClient.socket.destroy(); } if (appState.reconnectTimer) { clearTimeout(appState.reconnectTimer); } if (appState.subtitleTimingTracker) { appState.subtitleTimingTracker.destroy(); } if (appState.ankiIntegration) { appState.ankiIntegration.destroy(); } }, shouldRestoreWindowsOnActivate: () => appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, restoreWindowsOnActivate: () => { createMainWindow(); createInvisibleWindow(); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); }, }), }), ); applyStartupState(appState, startupState); function handleCliCommand( args: CliArgs, source: CliCommandSource = "initial", ): void { handleCliCommandRuntimeServiceWithContext(args, source, { getSocketPath: () => appState.mpvSocketPath, setSocketPath: (socketPath: string) => { appState.mpvSocketPath = socketPath; }, getClient: () => appState.mpvClient, showOsd: (text: string) => showMpvOsd(text), texthookerService, getTexthookerPort: () => appState.texthookerPort, setTexthookerPort: (port: number) => { appState.texthookerPort = port; }, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, openInBrowser: (url: string) => { void shell.openExternal(url).catch((error) => { logger.error(`Failed to open browser for texthooker URL: ${url}`, error); }); }, isOverlayInitialized: () => appState.overlayRuntimeInitialized, initializeOverlay: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleInvisibleOverlay: () => toggleInvisibleOverlay(), setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible), setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible), copyCurrentSubtitle: () => copyCurrentSubtitle(), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), mineSentenceCard: () => mineSentenceCard(), startPendingMineSentenceMultiple: (timeoutMs: number) => startPendingMineSentenceMultiple(timeoutMs), updateLastCardFromClipboard: () => updateLastCardFromClipboard(), refreshKnownWordCache: () => refreshKnownWordCache(), triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => cycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), stopApp: () => app.quit(), hasMainWindow: () => Boolean(overlayManager.getMainWindow()), getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), log: (message: string) => { logger.info(message); }, warn: (message: string) => { logger.warn(message); }, error: (message: string, err: unknown) => { logger.error(message, err); }, }); } function handleInitialArgs(): void { if (!appState.initialArgs) return; handleCliCommand(appState.initialArgs, "initial"); } function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { mpvClient.on("subtitle-change", ({ text }) => { appState.currentSubText = text; subtitleWsService.broadcast(text); void (async () => { if (getOverlayWindows().length > 0) { const subtitleData = await tokenizeSubtitle(text); broadcastToOverlayWindows("subtitle:set", subtitleData); } })(); }); mpvClient.on("subtitle-ass-change", ({ text }) => { appState.currentSubAssText = text; broadcastToOverlayWindows("subtitle-ass:set", text); }); mpvClient.on("secondary-subtitle-change", ({ text }) => { broadcastToOverlayWindows("secondary-subtitle:set", text); }); mpvClient.on("subtitle-timing", ({ text, start, end }) => { if (!text.trim() || !appState.subtitleTimingTracker) { return; } appState.subtitleTimingTracker.recordSubtitle(text, start, end); }); mpvClient.on("media-path-change", ({ path }) => { mediaRuntime.updateCurrentMediaPath(path); }); mpvClient.on("media-title-change", ({ title }) => { mediaRuntime.updateCurrentMediaTitle(title); }); mpvClient.on("subtitle-metrics-change", ({ patch }) => { updateMpvSubtitleRenderMetrics(patch); }); mpvClient.on("secondary-subtitle-visibility", ({ visible }) => { appState.previousSecondarySubVisibility = visible; }); } function createMpvClientRuntimeService(): MpvIpcClient { const mpvClient = new MpvIpcClient(appState.mpvSocketPath, { getResolvedConfig: () => getResolvedConfig(), autoStartOverlay: appState.autoStartOverlay, setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getReconnectTimer: () => appState.reconnectTimer, setReconnectTimer: (timer: ReturnType | null) => { appState.reconnectTimer = timer; }, }); bindMpvClientEventHandlers(mpvClient); return mpvClient; } function updateMpvSubtitleRenderMetrics( patch: Partial, ): void { const { next, changed } = applyMpvSubtitleRenderMetricsPatchService( appState.mpvSubtitleRenderMetrics, patch, ); if (!changed) return; appState.mpvSubtitleRenderMetrics = next; broadcastToOverlayWindows( "mpv-subtitle-render-metrics:set", appState.mpvSubtitleRenderMetrics, ); } async function tokenizeSubtitle(text: string): Promise { await jlptDictionaryRuntime.ensureJlptDictionaryLookup(); await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(); return tokenizeSubtitleService( text, createTokenizerDepsRuntimeService({ getYomitanExt: () => appState.yomitanExt, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window; }, getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, setYomitanParserReadyPromise: (promise) => { appState.yomitanParserReadyPromise = promise; }, getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, setYomitanParserInitPromise: (promise) => { appState.yomitanParserInitPromise = promise; }, isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ?? getResolvedConfig().ankiConnect.nPlusOne.matchMode, getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords, getJlptLevel: (text) => appState.jlptLevelLookup(text), getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt, getFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, getFrequencyRank: (text) => appState.frequencyRankLookup(text), getMecabTokenizer: () => appState.mecabTokenizer, }), ); } function updateVisibleOverlayBounds(geometry: WindowGeometry): void { overlayManager.setOverlayWindowBounds("visible", geometry); } function updateInvisibleOverlayBounds(geometry: WindowGeometry): void { overlayManager.setOverlayWindowBounds("invisible", geometry); } function ensureOverlayWindowLevel(window: BrowserWindow): void { ensureOverlayWindowLevelService(window); } function enforceOverlayLayerOrder(): void { enforceOverlayLayerOrderService({ visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), mainWindow: overlayManager.getMainWindow(), invisibleWindow: overlayManager.getInvisibleWindow(), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), }); } async function loadYomitanExtension(): Promise { return loadYomitanExtensionService({ userDataPath: USER_DATA_PATH, getYomitanParserWindow: () => appState.yomitanParserWindow, setYomitanParserWindow: (window) => { appState.yomitanParserWindow = window; }, setYomitanParserReadyPromise: (promise) => { appState.yomitanParserReadyPromise = promise; }, setYomitanParserInitPromise: (promise) => { appState.yomitanParserInitPromise = promise; }, setYomitanExtension: (extension) => { appState.yomitanExt = extension; }, }); } function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { return createOverlayWindowService( kind, { isDev, overlayDebugVisualizationEnabled: appState.overlayDebugVisualizationEnabled, ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), isOverlayVisible: (windowKind) => windowKind === "visible" ? overlayManager.getVisibleOverlayVisible() : overlayManager.getInvisibleOverlayVisible(), tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === "visible") { overlayManager.setMainWindow(null); } else { overlayManager.setInvisibleWindow(null); } }, }, ); } function createMainWindow(): BrowserWindow { const window = createOverlayWindow("visible"); overlayManager.setMainWindow(window); return window; } function createInvisibleWindow(): BrowserWindow { const window = createOverlayWindow("invisible"); overlayManager.setInvisibleWindow(window); return window; } function initializeOverlayRuntime(): void { if (appState.overlayRuntimeInitialized) { return; } const result = initializeOverlayRuntimeService( { backendOverride: appState.backendOverride, getInitialInvisibleOverlayVisibility: () => getInitialInvisibleOverlayVisibility(), createMainWindow: () => { createMainWindow(); }, createInvisibleWindow: () => { createInvisibleWindow(); }, registerGlobalShortcuts: () => { registerGlobalShortcuts(); }, updateVisibleOverlayBounds: (geometry) => { updateVisibleOverlayBounds(geometry); }, updateInvisibleOverlayBounds: (geometry) => { updateInvisibleOverlayBounds(geometry); }, isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), updateVisibleOverlayVisibility: () => { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, updateInvisibleOverlayVisibility: () => { overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); }, getOverlayWindows: () => getOverlayWindows(), syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), setWindowTracker: (tracker) => { appState.windowTracker = tracker; }, getResolvedConfig: () => getResolvedConfig(), getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, getMpvSocketPath: () => appState.mpvSocketPath, getRuntimeOptionsManager: () => appState.runtimeOptionsManager, setAnkiIntegration: (integration) => { appState.ankiIntegration = integration as AnkiIntegration | null; }, showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, "known-words-cache.json"), }, ); overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible); appState.overlayRuntimeInitialized = true; } function openYomitanSettings(): void { openYomitanSettingsWindow( { yomitanExt: appState.yomitanExt, getExistingWindow: () => appState.yomitanSettingsWindow, setWindow: (window: BrowserWindow | null) => { appState.yomitanSettingsWindow = window; }, }, ); } function registerGlobalShortcuts(): void { registerGlobalShortcutsService( { shortcuts: getConfiguredShortcuts(), onToggleVisibleOverlay: () => toggleVisibleOverlay(), onToggleInvisibleOverlay: () => toggleInvisibleOverlay(), onOpenYomitanSettings: () => openYomitanSettings(), isDev, getMainWindow: () => overlayManager.getMainWindow(), }, ); } function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); } function cycleSecondarySubMode(): void { cycleSecondarySubModeService( { getSecondarySubMode: () => appState.secondarySubMode, setSecondarySubMode: (mode: SecondarySubMode) => { appState.secondarySubMode = mode; }, getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, setLastSecondarySubToggleAtMs: (timestampMs: number) => { appState.lastSecondarySubToggleAtMs = timestampMs; }, broadcastSecondarySubMode: (mode: SecondarySubMode) => { broadcastToOverlayWindows("secondary-subtitle:mode", mode); }, showMpvOsd: (text: string) => showMpvOsd(text), }, ); } function showMpvOsd(text: string): void { appendToMpvLog(`[OSD] ${text}`); showMpvOsdRuntimeService( appState.mpvClient, text, (line) => { logger.info(line); }, ); } function appendToMpvLog(message: string): void { try { fs.mkdirSync(path.dirname(DEFAULT_MPV_LOG_PATH), { recursive: true }); fs.appendFileSync( DEFAULT_MPV_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`, { encoding: "utf8" }, ); } catch { // best-effort logging } } const numericShortcutRuntime = createNumericShortcutRuntimeService({ globalShortcut, showMpvOsd: (text) => showMpvOsd(text), setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), clearTimer: (timer) => clearTimeout(timer), }); const multiCopySession = numericShortcutRuntime.createSession(); const mineSentenceSession = numericShortcutRuntime.createSession(); function getSubsyncRuntimeServiceParams() { return createSubsyncRuntimeServiceInputFromState({ getMpvClient: () => appState.mpvClient, getResolvedSubsyncConfig: () => getSubsyncConfig(getResolvedConfig().subsync), getSubsyncInProgress: () => appState.subsyncInProgress, setSubsyncInProgress: (inProgress: boolean) => { appState.subsyncInProgress = inProgress; }, showMpvOsd: (text: string) => showMpvOsd(text), openManualPicker: (payload: SubsyncManualPayload) => { sendToActiveOverlayWindow("subsync:open-manual", payload, { restoreOnModalClose: "subsync", }); }, }); } async function triggerSubsyncFromConfig(): Promise { await triggerSubsyncFromConfigRuntime(getSubsyncRuntimeServiceParams()); } function cancelPendingMultiCopy(): void { multiCopySession.cancel(); } function startPendingMultiCopy(timeoutMs: number): void { multiCopySession.start({ timeoutMs, onDigit: (count) => handleMultiCopyDigit(count), messages: { prompt: "Copy how many lines? Press 1-9 (Esc to cancel)", timeout: "Copy timeout", cancelled: "Cancelled", }, }); } function handleMultiCopyDigit(count: number): void { handleMultiCopyDigitService( count, { subtitleTimingTracker: appState.subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), }, ); } function copyCurrentSubtitle(): void { copyCurrentSubtitleService( { subtitleTimingTracker: appState.subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), showMpvOsd: (text) => showMpvOsd(text), }, ); } async function updateLastCardFromClipboard(): Promise { await updateLastCardFromClipboardService( { ankiIntegration: appState.ankiIntegration, readClipboardText: () => clipboard.readText(), showMpvOsd: (text) => showMpvOsd(text), }, ); } async function refreshKnownWordCache(): Promise { if (!appState.ankiIntegration) { throw new Error("AnkiConnect integration not enabled"); } await appState.ankiIntegration.refreshKnownWordCache(); } async function triggerFieldGrouping(): Promise { await triggerFieldGroupingService( { ankiIntegration: appState.ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), }, ); } async function markLastCardAsAudioCard(): Promise { await markLastCardAsAudioCardService( { ankiIntegration: appState.ankiIntegration, showMpvOsd: (text) => showMpvOsd(text), }, ); } async function mineSentenceCard(): Promise { await mineSentenceCardService( { ankiIntegration: appState.ankiIntegration, mpvClient: appState.mpvClient, showMpvOsd: (text) => showMpvOsd(text), }, ); } function cancelPendingMineSentenceMultiple(): void { mineSentenceSession.cancel(); } function startPendingMineSentenceMultiple(timeoutMs: number): void { mineSentenceSession.start({ timeoutMs, onDigit: (count) => handleMineSentenceDigit(count), messages: { prompt: "Mine how many lines? Press 1-9 (Esc to cancel)", timeout: "Mine sentence timeout", cancelled: "Cancelled", }, }); } function handleMineSentenceDigit(count: number): void { handleMineSentenceDigitService( count, { subtitleTimingTracker: appState.subtitleTimingTracker, ankiIntegration: appState.ankiIntegration, getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, showMpvOsd: (text) => showMpvOsd(text), logError: (message, err) => { logger.error(message, err); }, }, ); } function registerOverlayShortcuts(): void { overlayShortcutsRuntime.registerOverlayShortcuts(); } function unregisterOverlayShortcuts(): void { overlayShortcutsRuntime.unregisterOverlayShortcuts(); } function syncOverlayShortcuts(): void { overlayShortcutsRuntime.syncOverlayShortcuts(); } function refreshOverlayShortcuts(): void { overlayShortcutsRuntime.refreshOverlayShortcuts(); } function setVisibleOverlayVisible(visible: boolean): void { setVisibleOverlayVisibleService({ visible, setVisibleOverlayVisibleState: (nextVisible) => { overlayManager.setVisibleOverlayVisible(nextVisible); }, updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), updateInvisibleOverlayVisibility: () => overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), syncInvisibleOverlayMousePassthrough: () => overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), setMpvSubVisibility: (mpvSubVisible) => { setMpvSubVisibilityRuntimeService(appState.mpvClient, mpvSubVisible); }, }); } function setInvisibleOverlayVisible(visible: boolean): void { setInvisibleOverlayVisibleService({ visible, setInvisibleOverlayVisibleState: (nextVisible) => { overlayManager.setInvisibleOverlayVisible(nextVisible); }, updateInvisibleOverlayVisibility: () => overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), syncInvisibleOverlayMousePassthrough: () => overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), }); } function toggleVisibleOverlay(): void { setVisibleOverlayVisible(!overlayManager.getVisibleOverlayVisible()); } function toggleInvisibleOverlay(): void { setInvisibleOverlayVisible(!overlayManager.getInvisibleOverlayVisible()); } function setOverlayVisible(visible: boolean): void { setVisibleOverlayVisible(visible); } function toggleOverlay(): void { toggleVisibleOverlay(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { overlayModalRuntime.handleOverlayModalClosed(modal); } function handleMpvCommandFromIpc(command: (string | number)[]): void { handleMpvCommandFromIpcRuntime(command, { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), cycleRuntimeOption: (id, direction) => { if (!appState.runtimeOptionsManager) { return { ok: false, error: "Runtime options manager unavailable" }; } return applyRuntimeOptionResultRuntimeService( appState.runtimeOptionsManager.cycleOption(id, direction), (text) => showMpvOsd(text), ); }, showMpvOsd: (text: string) => showMpvOsd(text), replayCurrentSubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient), sendMpvCommand: (rawCommand: (string | number)[]) => sendMpvCommandRuntimeService(appState.mpvClient, rawCommand), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, }); } async function runSubsyncManualFromIpc( request: SubsyncManualRunRequest, ): Promise { return runSubsyncManualFromIpcRuntime(request, getSubsyncRuntimeServiceParams()); } registerIpcRuntimeServices({ runtimeOptions: { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, showMpvOsd: (text: string) => showMpvOsd(text), }, mainDeps: { getInvisibleWindow: () => overlayManager.getInvisibleWindow(), getMainWindow: () => overlayManager.getMainWindow(), getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(), onOverlayModalClosed: (modal: string) => { handleOverlayModalClosed(modal as OverlayHostedModal); }, openYomitanSettings: () => openYomitanSettings(), quitApp: () => app.quit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), getCurrentSubtitleAss: () => appState.currentSubAssText, getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics, getSubtitlePosition: () => loadSubtitlePosition(), getSubtitleStyle: () => { const resolvedConfig = getResolvedConfig(); if (!resolvedConfig.subtitleStyle) { return null; } return { ...resolvedConfig.subtitleStyle, nPlusOneColor: resolvedConfig.ankiConnect.nPlusOne.nPlusOne, knownWordColor: resolvedConfig.ankiConnect.nPlusOne.knownWord, enableJlpt: resolvedConfig.subtitleStyle.enableJlpt, frequencyDictionary: resolvedConfig.subtitleStyle.frequencyDictionary, }; }, saveSubtitlePosition: (position: unknown) => saveSubtitlePosition(position as SubtitlePosition), getMecabTokenizer: () => appState.mecabTokenizer, handleMpvCommand: (command: (string | number)[]) => handleMpvCommandFromIpc(command), getKeybindings: () => appState.keybindings, getSecondarySubMode: () => appState.secondarySubMode, getMpvClient: () => appState.mpvClient, runSubsyncManual: (request: unknown) => runSubsyncManualFromIpc(request as SubsyncManualRunRequest), getAnkiConnectStatus: () => appState.ankiIntegration !== null, getRuntimeOptions: () => getRuntimeOptionsState(), reportOverlayContentBounds: (payload: unknown) => { overlayContentMeasurementStore.report(payload); }, }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ patchAnkiConnectEnabled: (enabled: boolean) => { configService.patchRawConfig({ ankiConnect: { enabled } }); }, getResolvedConfig: () => getResolvedConfig(), getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, getAnkiIntegration: () => appState.ankiIntegration, setAnkiIntegration: (integration: AnkiIntegration | null) => { appState.ankiIntegration = integration; }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, "known-words-cache.json"), showDesktopNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), getFieldGroupingResolver: () => getFieldGroupingResolver(), setFieldGroupingResolver: ( resolver: ((choice: KikuFieldGroupingChoice) => void) | null, ) => setFieldGroupingResolver(resolver), parseMediaInfo: (mediaPath: string | null) => parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)), getCurrentMediaPath: () => appState.currentMediaPath, jimakuFetchJson: ( endpoint: string, query?: Record, ): Promise> => jimakuFetchJson(endpoint, query), getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(), getJimakuLanguagePreference: () => getJimakuLanguagePreference(), resolveJimakuApiKey: () => resolveJimakuApiKey(), isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), downloadToFile: ( url: string, destPath: string, headers: Record, ) => downloadToFile(url, destPath, headers), }), });