From 0d7b65ec88e904b99697d398a38c34b30124ec81 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 23:11:20 -0800 Subject: [PATCH] refactor: extract main runtime dependency builders --- docs/subagents/INDEX.md | 2 +- .../codex-task85-20260219T233711Z-46hc.md | 46 ++ src/main.ts | 611 ++++++++++-------- src/main/runtime/app-ready-main-deps.test.ts | 71 ++ src/main/runtime/app-ready-main-deps.ts | 38 ++ .../cli-command-prechecks-main-deps.test.ts | 21 + .../cli-command-prechecks-main-deps.ts | 17 + .../runtime/field-grouping-resolver.test.ts | 56 ++ src/main/runtime/field-grouping-resolver.ts | 29 + src/main/runtime/jellyfin-client-info.test.ts | 56 ++ src/main/runtime/jellyfin-client-info.ts | 33 + .../runtime/mpv-jellyfin-defaults.test.ts | 32 + src/main/runtime/mpv-jellyfin-defaults.ts | 30 + .../overlay-runtime-main-actions.test.ts | 134 ++++ .../runtime/overlay-runtime-main-actions.ts | 90 +++ .../secondary-sub-mode-main-deps.test.ts | 39 ++ .../runtime/secondary-sub-mode-main-deps.ts | 21 + .../startup-bootstrap-main-deps.test.ts | 52 ++ .../runtime/startup-bootstrap-main-deps.ts | 62 ++ .../runtime/startup-config-main-deps.test.ts | 64 ++ src/main/runtime/startup-config-main-deps.ts | 47 ++ .../startup-lifecycle-main-deps.test.ts | 35 + .../runtime/startup-lifecycle-main-deps.ts | 20 + .../subtitle-tokenization-main-deps.test.ts | 77 +++ .../subtitle-tokenization-main-deps.ts | 69 ++ 25 files changed, 1490 insertions(+), 262 deletions(-) create mode 100644 src/main/runtime/app-ready-main-deps.test.ts create mode 100644 src/main/runtime/app-ready-main-deps.ts create mode 100644 src/main/runtime/cli-command-prechecks-main-deps.test.ts create mode 100644 src/main/runtime/cli-command-prechecks-main-deps.ts create mode 100644 src/main/runtime/field-grouping-resolver.test.ts create mode 100644 src/main/runtime/field-grouping-resolver.ts create mode 100644 src/main/runtime/jellyfin-client-info.test.ts create mode 100644 src/main/runtime/jellyfin-client-info.ts create mode 100644 src/main/runtime/mpv-jellyfin-defaults.test.ts create mode 100644 src/main/runtime/mpv-jellyfin-defaults.ts create mode 100644 src/main/runtime/overlay-runtime-main-actions.test.ts create mode 100644 src/main/runtime/overlay-runtime-main-actions.ts create mode 100644 src/main/runtime/secondary-sub-mode-main-deps.test.ts create mode 100644 src/main/runtime/secondary-sub-mode-main-deps.ts create mode 100644 src/main/runtime/startup-bootstrap-main-deps.test.ts create mode 100644 src/main/runtime/startup-bootstrap-main-deps.ts create mode 100644 src/main/runtime/startup-config-main-deps.test.ts create mode 100644 src/main/runtime/startup-config-main-deps.ts create mode 100644 src/main/runtime/startup-lifecycle-main-deps.test.ts create mode 100644 src/main/runtime/startup-lifecycle-main-deps.ts create mode 100644 src/main/runtime/subtitle-tokenization-main-deps.test.ts create mode 100644 src/main/runtime/subtitle-tokenization-main-deps.ts diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index e49fe45..d36aede 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -6,7 +6,7 @@ Read first. Keep concise. | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T05:50:43Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T06:56:20Z` | | `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | | `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | | `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index c0d844a..a95f08e 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -193,6 +193,28 @@ - `src/main/runtime/initial-args-main-deps.test.ts` - `src/main/runtime/mpv-main-event-main-deps.ts` - `src/main/runtime/mpv-main-event-main-deps.test.ts` +- `src/main/runtime/subtitle-tokenization-main-deps.ts` +- `src/main/runtime/subtitle-tokenization-main-deps.test.ts` +- `src/main/runtime/secondary-sub-mode-main-deps.ts` +- `src/main/runtime/secondary-sub-mode-main-deps.test.ts` +- `src/main/runtime/overlay-runtime-main-actions.ts` +- `src/main/runtime/overlay-runtime-main-actions.test.ts` +- `src/main/runtime/jellyfin-client-info.ts` +- `src/main/runtime/jellyfin-client-info.test.ts` +- `src/main/runtime/startup-config-main-deps.ts` +- `src/main/runtime/startup-config-main-deps.test.ts` +- `src/main/runtime/app-ready-main-deps.ts` +- `src/main/runtime/app-ready-main-deps.test.ts` +- `src/main/runtime/startup-lifecycle-main-deps.ts` +- `src/main/runtime/startup-lifecycle-main-deps.test.ts` +- `src/main/runtime/startup-bootstrap-main-deps.ts` +- `src/main/runtime/startup-bootstrap-main-deps.test.ts` +- `src/main/runtime/cli-command-prechecks-main-deps.ts` +- `src/main/runtime/cli-command-prechecks-main-deps.test.ts` +- `src/main/runtime/field-grouping-resolver.ts` +- `src/main/runtime/field-grouping-resolver.test.ts` +- `src/main/runtime/mpv-jellyfin-defaults.ts` +- `src/main/runtime/mpv-jellyfin-defaults.test.ts` ## Open Questions / Blockers @@ -244,3 +266,27 @@ - [2026-02-20T05:15:40Z] progress: extracted MPV main-event deps assembly into `src/main/runtime/mpv-main-event-main-deps.ts` (`createBuildBindMpvMainEventHandlersMainDepsHandler`) and rewired `bindMpvClientEventHandlers` setup in `src/main.ts`. - [2026-02-20T05:15:40Z] progress: `src/main.ts` currently 2653 LOC after this slice. - [2026-02-20T05:15:40Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-lifecycle-main-activate.test.js dist/main/runtime/initial-args-main-deps.test.js dist/main/runtime/app-lifecycle-actions.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/mpv-main-event-main-deps.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js` pass (27/27). +- [2026-02-20T05:31:05Z] progress: extracted subtitle tokenization/mecab-warmup deps assembly into `src/main/runtime/subtitle-tokenization-main-deps.ts` (`createBuildTokenizerDepsMainHandler`, `createCreateMecabTokenizerAndCheckMainHandler`, `createPrewarmSubtitleDictionariesMainHandler`) and rewired `tokenizeSubtitle`, `createMecabTokenizerAndCheck`, and `prewarmSubtitleDictionaries` in `src/main.ts`. +- [2026-02-20T05:31:05Z] progress: extracted `cycleSecondarySubMode` deps assembly into `src/main/runtime/secondary-sub-mode-main-deps.ts` (`createBuildCycleSecondarySubModeMainDepsHandler`) and rewired `cycleSecondarySubMode` in `src/main.ts`. +- [2026-02-20T05:31:05Z] progress: while implementing subtitle-tokenization deps, fixed strict typing to align parser promise shapes (`Promise` ready + `Promise` init) and Mecab availability return type. +- [2026-02-20T05:31:05Z] progress: `src/main.ts` currently 2664 LOC after this slice. +- [2026-02-20T05:31:05Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/subtitle-tokenization-main-deps.test.js dist/main/runtime/secondary-sub-mode-main-deps.test.js dist/main/runtime/startup-warmups.test.js` pass (7/7). +- [2026-02-20T05:49:13Z] progress: extracted overlay runtime state/action wrappers into `src/main/runtime/overlay-runtime-main-actions.ts` (`createGetRuntimeOptionsStateHandler`, `createRestorePreviousSecondarySubVisibilityHandler`, `createBroadcastRuntimeOptionsChangedHandler`, `createSendToActiveOverlayWindowHandler`, `createSetOverlayDebugVisualizationEnabledHandler`, `createOpenRuntimeOptionsPaletteHandler`), and rewired corresponding `main.ts` functions to thin delegations. +- [2026-02-20T05:49:13Z] progress: while adding tests, corrected strict typing to import `OverlayHostedModal` from `src/main/overlay-runtime.ts` and aligned `RuntimeOptionState` fixture shape. +- [2026-02-20T05:49:13Z] progress: `src/main.ts` currently 2696 LOC after this slice. +- [2026-02-20T05:49:13Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-runtime-main-actions.test.js dist/main/runtime/subtitle-tokenization-main-deps.test.js dist/main/runtime/secondary-sub-mode-main-deps.test.js` pass (12/12). +- [2026-02-20T05:50:43Z] progress: extracted Jellyfin config/client info wrappers into `src/main/runtime/jellyfin-client-info.ts` (`createGetResolvedJellyfinConfigHandler`, `createGetJellyfinClientInfoHandler`) and rewired `getResolvedJellyfinConfig` + `getJellyfinClientInfo` in `src/main.ts`. +- [2026-02-20T05:50:43Z] progress: fixed strict typing regressions by loosening helper input types to preserve call-site inference while keeping client-info output normalized to strings. +- [2026-02-20T05:50:43Z] progress: `src/main.ts` currently 2702 LOC after this slice. +- [2026-02-20T05:50:43Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-client-info.test.js dist/main/runtime/overlay-runtime-main-actions.test.js dist/main/runtime/subtitle-tokenization-main-deps.test.js` pass (14/14). +- [2026-02-20T06:52:54Z] progress: extracted startup config handler deps assembly into `src/main/runtime/startup-config-main-deps.ts` and rewired `reloadConfigHandler` + `criticalConfigErrorHandler` setup in `src/main.ts`. +- [2026-02-20T06:52:54Z] progress: extracted app-ready runner deps assembly into `src/main/runtime/app-ready-main-deps.ts` and lifted nested `createAppReadyRuntimeRunner(...)` input block to `appReadyRuntimeRunner` constant in `src/main.ts`. +- [2026-02-20T06:52:54Z] progress: extracted app-lifecycle runner deps assembly into `src/main/runtime/startup-lifecycle-main-deps.ts` and lifted lifecycle runner wiring to `appLifecycleRuntimeRunner` constant in `src/main.ts`. +- [2026-02-20T06:52:54Z] progress: extracted startup bootstrap deps assembly into `src/main/runtime/startup-bootstrap-main-deps.ts` and rewired `buildStartupBootstrapRuntimeFactoryDepsHandler` to compose through the new builder. +- [2026-02-20T06:52:54Z] progress: `src/main.ts` currently 2723 LOC after this slice. +- [2026-02-20T06:52:54Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/startup-bootstrap-main-deps.test.js dist/main/runtime/startup-bootstrap-deps-builder.test.js dist/main/runtime/startup-lifecycle-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/startup-config-main-deps.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/app-ready.test.js` pass (21/21). +- [2026-02-20T06:56:20Z] progress: extracted CLI precheck deps assembly into `src/main/runtime/cli-command-prechecks-main-deps.ts` and rewired `handleCliCommand` to use a prebuilt `handleTexthookerOnlyModeTransitionHandler`. +- [2026-02-20T06:56:20Z] progress: extracted field-grouping resolver state wrappers into `src/main/runtime/field-grouping-resolver.ts` and rewired `getFieldGroupingResolver` + `setFieldGroupingResolver` in `src/main.ts`. +- [2026-02-20T06:56:20Z] progress: extracted `applyJellyfinMpvDefaults` and `getDefaultSocketPath` wrappers into `src/main/runtime/mpv-jellyfin-defaults.ts`; rewired both `main.ts` helper functions to thin handler delegates. +- [2026-02-20T06:56:20Z] progress: `src/main.ts` currently 2742 LOC after this slice. +- [2026-02-20T06:56:20Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-prechecks-main-deps.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/field-grouping-resolver.test.js dist/main/runtime/mpv-jellyfin-defaults.test.js dist/main/runtime/startup-bootstrap-main-deps.test.js` pass (11/11). diff --git a/src/main.ts b/src/main.ts index 017cde9..2672252 100644 --- a/src/main.ts +++ b/src/main.ts @@ -148,6 +148,14 @@ import { createRunJellyfinCommandHandler } from './main/runtime/jellyfin-command import { createHandleJellyfinListCommands } from './main/runtime/jellyfin-cli-list'; import { createHandleJellyfinPlayCommand } from './main/runtime/jellyfin-cli-play'; import { createHandleJellyfinRemoteAnnounceCommand } from './main/runtime/jellyfin-cli-remote-announce'; +import { + createGetJellyfinClientInfoHandler, + createGetResolvedJellyfinConfigHandler, +} from './main/runtime/jellyfin-client-info'; +import { + createApplyJellyfinMpvDefaultsHandler, + createGetDefaultSocketPathHandler, +} from './main/runtime/mpv-jellyfin-defaults'; import { createPlayJellyfinItemInMpvHandler } from './main/runtime/jellyfin-playback-launch'; import { createPreloadJellyfinExternalSubtitlesHandler } from './main/runtime/jellyfin-subtitle-preload'; import { @@ -157,12 +165,22 @@ import { import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler'; import { createBuildHandleInitialArgsMainDepsHandler } from './main/runtime/initial-args-main-deps'; import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks'; +import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './main/runtime/cli-command-prechecks-main-deps'; +import { + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, +} from './main/runtime/field-grouping-resolver'; import { createCliCommandContext } from './main/runtime/cli-command-context'; import { createBindMpvMainEventHandlersHandler } from './main/runtime/mpv-main-event-bindings'; import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './main/runtime/mpv-main-event-main-deps'; import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './main/runtime/mpv-client-runtime-service-main-deps'; import { createMpvClientRuntimeServiceFactory } from './main/runtime/mpv-client-runtime-service'; import { createUpdateMpvSubtitleRenderMetricsHandler } from './main/runtime/mpv-subtitle-render-metrics'; +import { + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, +} from './main/runtime/subtitle-tokenization-main-deps'; import { createLaunchBackgroundWarmupTaskHandler, createStartBackgroundWarmupsHandler, @@ -192,6 +210,7 @@ import { createBuildAppendToMpvLogMainDepsHandler, createBuildShowMpvOsdMainDepsHandler, } from './main/runtime/mpv-osd-log-main-deps'; +import { createBuildCycleSecondarySubModeMainDepsHandler } from './main/runtime/secondary-sub-mode-main-deps'; import { createCancelNumericShortcutSessionHandler, createStartNumericShortcutSessionHandler, @@ -226,6 +245,14 @@ import { createSetOverlayVisibleHandler, createToggleOverlayHandler, } from './main/runtime/overlay-main-actions'; +import { + createBroadcastRuntimeOptionsChangedHandler, + createGetRuntimeOptionsStateHandler, + createOpenRuntimeOptionsPaletteHandler, + createRestorePreviousSecondarySubVisibilityHandler, + createSendToActiveOverlayWindowHandler, + createSetOverlayDebugVisualizationEnabledHandler, +} from './main/runtime/overlay-runtime-main-actions'; import { createHandleMpvCommandFromIpcHandler, createRunSubsyncManualFromIpcHandler, @@ -279,6 +306,13 @@ import { createConfigHotReloadMessageHandler, resolveSubtitleStyleForRenderer, } from './main/runtime/config-hot-reload-handlers'; +import { + createBuildCriticalConfigErrorMainDepsHandler, + createBuildReloadConfigMainDepsHandler, +} from './main/runtime/startup-config-main-deps'; +import { createBuildAppReadyRuntimeMainDepsHandler } from './main/runtime/app-ready-main-deps'; +import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './main/runtime/startup-lifecycle-main-deps'; +import { createBuildStartupBootstrapMainDepsHandler } from './main/runtime/startup-bootstrap-main-deps'; import { enforceUnsupportedWaylandMode, forceX11Backend, @@ -429,14 +463,13 @@ let jellyfinMpvAutoLaunchInFlight: Promise | null = null; let backgroundWarmupsStarted = false; let yomitanLoadInFlight: Promise | null = null; +const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler({ + sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client as never, command), + jellyfinLangPref: JELLYFIN_LANG_PREF, +}); + function applyJellyfinMpvDefaults(client: MpvIpcClient): void { - sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']); - sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']); - sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']); - sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']); - sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']); - sendMpvCommandRuntime(client, ['set_property', 'alang', JELLYFIN_LANG_PREF]); - sendMpvCommandRuntime(client, ['set_property', 'slang', JELLYFIN_LANG_PREF]); + applyJellyfinMpvDefaultsHandler(client); } const CONFIG_DIR = resolveConfigDir({ @@ -493,11 +526,12 @@ const appLogger = { }, }; +const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler({ + platform: process.platform, +}); + function getDefaultSocketPath(): string { - if (process.platform === 'win32') { - return '\\\\.\\pipe\\subminer-socket'; - } - return '/tmp/subminer-socket'; + return getDefaultSocketPathHandler(); } if (!fs.existsSync(USER_DATA_PATH)) { @@ -795,23 +829,29 @@ const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({ }, }); +const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({ + getResolver: () => appState.fieldGroupingResolver, +}); + function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { - return appState.fieldGroupingResolver; + return getFieldGroupingResolverHandler(); } +const setFieldGroupingResolverHandler = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + appState.fieldGroupingResolver = resolver; + }, + nextSequence: () => { + appState.fieldGroupingResolverSequence += 1; + return appState.fieldGroupingResolverSequence; + }, + getSequence: () => appState.fieldGroupingResolverSequence, +}); + 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; + setFieldGroupingResolverHandler(resolver); } const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime({ @@ -880,71 +920,97 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ }, }); +const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler({ + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, +}); + function getRuntimeOptionsState(): RuntimeOptionState[] { - if (!appState.runtimeOptionsManager) return []; - return appState.runtimeOptionsManager.listOptions(); + return getRuntimeOptionsStateHandler(); } function getOverlayWindows(): BrowserWindow[] { return overlayManager.getOverlayWindows(); } +const restorePreviousSecondarySubVisibilityHandler = createRestorePreviousSecondarySubVisibilityHandler( + { + getMpvClient: () => appState.mpvClient, + }, +); + function restorePreviousSecondarySubVisibility(): void { - if (!appState.mpvClient || !appState.mpvClient.connected) return; - appState.mpvClient.restorePreviousSecondarySubVisibility(); + restorePreviousSecondarySubVisibilityHandler(); } function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { overlayManager.broadcastToOverlayWindows(channel, ...args); } +const broadcastRuntimeOptionsChangedHandler = createBroadcastRuntimeOptionsChangedHandler({ + broadcastRuntimeOptionsChangedRuntime, + getRuntimeOptionsState: () => getRuntimeOptionsState(), + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), +}); + function broadcastRuntimeOptionsChanged(): void { - broadcastRuntimeOptionsChangedRuntime( - () => getRuntimeOptionsState(), - (channel, ...args) => broadcastToOverlayWindows(channel, ...args), - ); + broadcastRuntimeOptionsChangedHandler(); } +const sendToActiveOverlayWindowHandler = createSendToActiveOverlayWindowHandler({ + sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), +}); + function sendToActiveOverlayWindow( channel: string, payload?: unknown, runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, ): boolean { - return overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions); + return sendToActiveOverlayWindowHandler(channel, payload, runtimeOptions); } -function setOverlayDebugVisualizationEnabled(enabled: boolean): void { - setOverlayDebugVisualizationEnabledRuntime( - appState.overlayDebugVisualizationEnabled, - enabled, - (next) => { +const setOverlayDebugVisualizationEnabledHandler = createSetOverlayDebugVisualizationEnabledHandler( + { + setOverlayDebugVisualizationEnabledRuntime, + getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled, + setCurrentEnabled: (next) => { appState.overlayDebugVisualizationEnabled = next; }, - (channel, ...args) => broadcastToOverlayWindows(channel, ...args), - ); + broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args), + }, +); + +function setOverlayDebugVisualizationEnabled(enabled: boolean): void { + setOverlayDebugVisualizationEnabledHandler(enabled); } +const openRuntimeOptionsPaletteHandler = createOpenRuntimeOptionsPaletteHandler({ + openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), +}); + function openRuntimeOptionsPalette(): void { - overlayModalRuntime.openRuntimeOptionsPalette(); + openRuntimeOptionsPaletteHandler(); } function getResolvedConfig() { return configService.getConfig(); } +const getResolvedJellyfinConfigHandler = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => getResolvedConfig(), +}); + function getResolvedJellyfinConfig() { - return getResolvedConfig().jellyfin; + return getResolvedJellyfinConfigHandler(); } +const getJellyfinClientInfoHandler = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, +}); + function getJellyfinClientInfo(config = getResolvedJellyfinConfig()) { - const clientName = config.clientName || DEFAULT_CONFIG.jellyfin.clientName; - const clientVersion = config.clientVersion || DEFAULT_CONFIG.jellyfin.clientVersion; - const deviceId = config.deviceId || DEFAULT_CONFIG.jellyfin.deviceId; - return { - clientName, - clientVersion, - deviceId, - }; + return getJellyfinClientInfoHandler(config); } const waitForMpvConnected = createWaitForMpvConnectedHandler({ @@ -1553,162 +1619,176 @@ const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler( })(), ); -const buildStartupBootstrapRuntimeFactoryDepsHandler = - createBuildStartupBootstrapRuntimeFactoryDepsHandler({ - argv: process.argv, - parseArgs: (argv: string[]) => parseArgs(argv), - setLogLevel: (level: string, source: LogLevelSource) => { - setLogLevel(level, source); +const reloadConfigHandler = createReloadConfigHandler( + createBuildReloadConfigMainDepsHandler({ + reloadConfigStrict: () => configService.reloadConfigStrict(), + logInfo: (message) => appLogger.logInfo(message), + logWarning: (message) => appLogger.logWarning(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + startConfigHotReload: () => configHotReloadRuntime.start(), + refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options), + failHandlers: { + logError: (details) => logger.error(details), + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + quit: () => app.quit(), }, - forceX11Backend: (args: CliArgs) => { - forceX11Backend(args); + })(), +); + +const criticalConfigErrorHandler = createCriticalConfigErrorHandler( + createBuildCriticalConfigErrorMainDepsHandler({ + getConfigPath: () => configService.getConfigPath(), + failHandlers: { + logError: (message) => logger.error(message), + showErrorBox: (title, message) => dialog.showErrorBox(title, message), + quit: () => app.quit(), }, - enforceUnsupportedWaylandMode: (args: CliArgs) => { - enforceUnsupportedWaylandMode(args); + })(), +); + +const appReadyRuntimeRunner = createAppReadyRuntimeRunner( + createBuildAppReadyRuntimeMainDepsHandler({ + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); }, - 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; + createMpvClient: () => { + appState.mpvClient = createMpvClientRuntimeService(); + }, + reloadConfig: reloadConfigHandler, + 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 () => { + await createMecabTokenizerAndCheck(); + }, + createSubtitleTimingTracker: () => { + const tracker = new SubtitleTimingTracker(); + appState.subtitleTimingTracker = tracker; + }, + createImmersionTracker: createImmersionTrackerStartupHandler({ + getResolvedConfig: () => getResolvedConfig(), + getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), + createTrackerService: (params) => new ImmersionTrackerService(params), + setTracker: (tracker) => { + appState.immersionTracker = tracker as ImmersionTrackerService | null; }, - ) => 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: createReloadConfigHandler({ - reloadConfigStrict: () => configService.reloadConfigStrict(), - logInfo: (message) => appLogger.logInfo(message), - logWarning: (message) => appLogger.logWarning(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - startConfigHotReload: () => configHotReloadRuntime.start(), - refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options), - failHandlers: { - logError: (details) => logger.error(details), - showErrorBox: (title, details) => dialog.showErrorBox(title, details), - quit: () => app.quit(), - }, - }), - 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 () => { - await createMecabTokenizerAndCheck(); - }, - createSubtitleTimingTracker: () => { - const tracker = new SubtitleTimingTracker(); - appState.subtitleTimingTracker = tracker; - }, - createImmersionTracker: createImmersionTrackerStartupHandler({ - getResolvedConfig: () => getResolvedConfig(), - getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), - createTrackerService: (params) => new ImmersionTrackerService(params), - setTracker: (tracker) => { - appState.immersionTracker = tracker as ImmersionTrackerService | null; - }, - getMpvClient: () => appState.mpvClient, - seedTrackerFromCurrentMedia: () => { - void immersionMediaRuntime.seedFromCurrentMedia(); - }, - logInfo: (message) => logger.info(message), - logDebug: (message) => logger.debug(message), - logWarn: (message, details) => logger.warn(message, details), - }), - loadYomitanExtension: async () => { - await loadYomitanExtension(); - }, - startJellyfinRemoteSession: async () => { - await startJellyfinRemoteSession(); - }, - prewarmSubtitleDictionaries: async () => { - await prewarmSubtitleDictionaries(); - }, - startBackgroundWarmups: () => { - startBackgroundWarmups(); - }, - texthookerOnlyMode: appState.texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: () => - appState.backgroundMode - ? false - : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - handleInitialArgs: () => handleInitialArgs(), - onCriticalConfigErrors: createCriticalConfigErrorHandler({ - getConfigPath: () => configService.getConfigPath(), - failHandlers: { - logError: (message) => logger.error(message), - showErrorBox: (title, message) => dialog.showErrorBox(title, message), - quit: () => app.quit(), - }, - }), - logDebug: (message: string) => { - logger.debug(message); - }, - now: () => Date.now(), - }), - onWillQuitCleanup: () => onWillQuitCleanupHandler(), - shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), - restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), - shouldQuitOnWindowAllClosed: () => !appState.backgroundMode, + getMpvClient: () => appState.mpvClient, + seedTrackerFromCurrentMedia: () => { + void immersionMediaRuntime.seedFromCurrentMedia(); + }, + logInfo: (message) => logger.info(message), + logDebug: (message) => logger.debug(message), + logWarn: (message, details) => logger.warn(message, details), }), - }); + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + startJellyfinRemoteSession: async () => { + await startJellyfinRemoteSession(); + }, + prewarmSubtitleDictionaries: async () => { + await prewarmSubtitleDictionaries(); + }, + startBackgroundWarmups: () => { + startBackgroundWarmups(); + }, + texthookerOnlyMode: appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + appState.backgroundMode + ? false + : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + handleInitialArgs: () => handleInitialArgs(), + onCriticalConfigErrors: criticalConfigErrorHandler, + logDebug: (message: string) => { + logger.debug(message); + }, + now: () => Date.now(), + })(), +); + +const appLifecycleRuntimeRunner = createAppLifecycleRuntimeRunner( + createBuildAppLifecycleRuntimeRunnerMainDepsHandler({ + 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: appReadyRuntimeRunner, + onWillQuitCleanup: () => onWillQuitCleanupHandler(), + shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), + restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(), + shouldQuitOnWindowAllClosed: () => !appState.backgroundMode, + })(), +); + +const buildStartupBootstrapRuntimeFactoryDepsHandler = + createBuildStartupBootstrapRuntimeFactoryDepsHandler( + createBuildStartupBootstrapMainDepsHandler({ + 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), + setExitCode: (code) => { + process.exitCode = code; + }, + quitApp: () => app.quit(), + logGenerateConfigError: (message) => logger.error(message), + startAppLifecycle: appLifecycleRuntimeRunner, + })(), + ); const startupState = runStartupBootstrapRuntime( createStartupBootstrapRuntimeDeps(buildStartupBootstrapRuntimeFactoryDepsHandler()), @@ -1718,16 +1798,20 @@ applyStartupState(appState, startupState); void refreshAnilistClientSecretState({ force: true }); anilistStateRuntime.refreshRetryQueueState(); -function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void { - createHandleTexthookerOnlyModeTransitionHandler({ +const handleTexthookerOnlyModeTransitionHandler = createHandleTexthookerOnlyModeTransitionHandler( + createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({ isTexthookerOnlyMode: () => appState.texthookerOnlyMode, setTexthookerOnlyMode: (enabled) => { appState.texthookerOnlyMode = enabled; }, commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs), startBackgroundWarmups: () => startBackgroundWarmups(), - logInfo: (message) => logger.info(message), - })(args); + logInfo: (message: string) => logger.info(message), + })(), +); + +function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void { + handleTexthookerOnlyModeTransitionHandler(args); const cliContext = createCliCommandContext(buildCliCommandContextDepsHandler()); handleCliCommandRuntimeServiceWithContext(args, source, cliContext); @@ -1833,59 +1917,62 @@ function updateMpvSubtitleRenderMetrics(patch: Partial updateMpvSubtitleRenderMetricsRuntime(patch); } +const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({ + getYomitanExt: () => appState.yomitanExt, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + getYomitanParserInitPromise: () => appState.yomitanParserInitPromise, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)), + recordLookup: (hit) => { + appState.immersionTracker?.recordLookup(hit); + }, + 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), + getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, + getMecabTokenizer: () => appState.mecabTokenizer, +}); + +const createMecabTokenizerAndCheckHandler = createCreateMecabTokenizerAndCheckMainHandler({ + getMecabTokenizer: () => appState.mecabTokenizer, + setMecabTokenizer: (tokenizer) => { + appState.mecabTokenizer = tokenizer; + }, + createMecabTokenizer: () => new MecabTokenizer(), + checkAvailability: async (tokenizer) => tokenizer.checkAvailability(), +}); + +const prewarmSubtitleDictionariesHandler = createPrewarmSubtitleDictionariesMainHandler({ + ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), + ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), +}); + async function tokenizeSubtitle(text: string): Promise { await jlptDictionaryRuntime.ensureJlptDictionaryLookup(); await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(); - return tokenizeSubtitleCore( - text, - createTokenizerDepsRuntime({ - 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) => - (() => { - const hit = Boolean(appState.ankiIntegration?.isKnownWord(text)); - appState.immersionTracker?.recordLookup(hit); - return hit; - })(), - 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), - getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled, - getMecabTokenizer: () => appState.mecabTokenizer, - }), - ); + return tokenizeSubtitleCore(text, createTokenizerDepsRuntime(buildTokenizerDepsHandler())); } async function createMecabTokenizerAndCheck(): Promise { - if (!appState.mecabTokenizer) { - appState.mecabTokenizer = new MecabTokenizer(); - } - await appState.mecabTokenizer.checkAvailability(); + await createMecabTokenizerAndCheckHandler(); } async function prewarmSubtitleDictionaries(): Promise { - await Promise.all([ - jlptDictionaryRuntime.ensureJlptDictionaryLookup(), - frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), - ]); + await prewarmSubtitleDictionariesHandler(); } const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({ @@ -2020,20 +2107,22 @@ function getConfiguredShortcuts() { } function cycleSecondarySubMode(): void { - cycleSecondarySubModeCore({ - 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), - }); + cycleSecondarySubModeCore( + createBuildCycleSecondarySubModeMainDepsHandler({ + getSecondarySubMode: () => appState.secondarySubMode, + setSecondarySubMode: (mode: SecondarySubMode) => { + appState.secondarySubMode = mode; + }, + getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs, + setLastSecondarySubToggleAtMs: (timestampMs: number) => { + appState.lastSecondarySubToggleAtMs = timestampMs; + }, + broadcastToOverlayWindows: (channel, mode) => { + broadcastToOverlayWindows(channel, mode); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + })(), + ); } const appendToMpvLogHandler = createAppendToMpvLogHandler({ diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts new file mode 100644 index 0000000..94df508 --- /dev/null +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps'; + +test('app-ready main deps builder returns mapped app-ready runtime deps', async () => { + const calls: string[] = []; + const onReady = createBuildAppReadyRuntimeMainDepsHandler({ + loadSubtitlePosition: () => calls.push('load-subtitle-position'), + resolveKeybindings: () => calls.push('resolve-keybindings'), + createMpvClient: () => calls.push('create-mpv-client'), + reloadConfig: () => calls.push('reload-config'), + getResolvedConfig: () => ({ websocket: {} }), + getConfigWarnings: () => [], + logConfigWarning: () => calls.push('log-config-warning'), + initRuntimeOptionsManager: () => calls.push('init-runtime-options'), + setSecondarySubMode: () => calls.push('set-secondary-sub-mode'), + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => calls.push('start-ws'), + log: () => calls.push('log'), + setLogLevel: () => calls.push('set-log-level'), + createMecabTokenizerAndCheck: async () => { + calls.push('create-mecab'); + }, + createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'), + createImmersionTracker: () => calls.push('create-immersion'), + startJellyfinRemoteSession: async () => { + calls.push('start-jellyfin'); + }, + loadYomitanExtension: async () => { + calls.push('load-yomitan'); + }, + prewarmSubtitleDictionaries: async () => { + calls.push('prewarm-dicts'); + }, + startBackgroundWarmups: () => calls.push('start-warmups'), + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + initializeOverlayRuntime: () => calls.push('init-overlay'), + handleInitialArgs: () => calls.push('handle-initial-args'), + onCriticalConfigErrors: () => { + throw new Error('should not call'); + }, + logDebug: () => calls.push('debug'), + now: () => 123, + })(); + + assert.equal(onReady.defaultSecondarySubMode, 'hover'); + assert.equal(onReady.defaultWebsocketPort, 5174); + assert.equal(onReady.texthookerOnlyMode, false); + assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); + assert.equal(onReady.now?.(), 123); + onReady.loadSubtitlePosition(); + onReady.resolveKeybindings(); + onReady.createMpvClient(); + await onReady.createMecabTokenizerAndCheck(); + await onReady.loadYomitanExtension(); + await onReady.prewarmSubtitleDictionaries?.(); + onReady.startBackgroundWarmups(); + + assert.deepEqual(calls, [ + 'load-subtitle-position', + 'resolve-keybindings', + 'create-mpv-client', + 'create-mecab', + 'load-yomitan', + 'prewarm-dicts', + 'start-warmups', + ]); +}); diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts new file mode 100644 index 0000000..74fcbd0 --- /dev/null +++ b/src/main/runtime/app-ready-main-deps.ts @@ -0,0 +1,38 @@ +import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle'; + +export function createBuildAppReadyRuntimeMainDepsHandler( + deps: AppReadyRuntimeDepsFactoryInput, +) { + return (): AppReadyRuntimeDepsFactoryInput => ({ + loadSubtitlePosition: deps.loadSubtitlePosition, + resolveKeybindings: deps.resolveKeybindings, + createMpvClient: deps.createMpvClient, + reloadConfig: deps.reloadConfig, + getResolvedConfig: deps.getResolvedConfig, + getConfigWarnings: deps.getConfigWarnings, + logConfigWarning: deps.logConfigWarning, + initRuntimeOptionsManager: deps.initRuntimeOptionsManager, + setSecondarySubMode: deps.setSecondarySubMode, + defaultSecondarySubMode: deps.defaultSecondarySubMode, + defaultWebsocketPort: deps.defaultWebsocketPort, + hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin, + startSubtitleWebsocket: deps.startSubtitleWebsocket, + log: deps.log, + setLogLevel: deps.setLogLevel, + createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck, + createSubtitleTimingTracker: deps.createSubtitleTimingTracker, + createImmersionTracker: deps.createImmersionTracker, + startJellyfinRemoteSession: deps.startJellyfinRemoteSession, + loadYomitanExtension: deps.loadYomitanExtension, + prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, + startBackgroundWarmups: deps.startBackgroundWarmups, + texthookerOnlyMode: deps.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: + deps.shouldAutoInitializeOverlayRuntimeFromConfig, + initializeOverlayRuntime: deps.initializeOverlayRuntime, + handleInitialArgs: deps.handleInitialArgs, + onCriticalConfigErrors: deps.onCriticalConfigErrors, + logDebug: deps.logDebug, + now: deps.now, + }); +} diff --git a/src/main/runtime/cli-command-prechecks-main-deps.test.ts b/src/main/runtime/cli-command-prechecks-main-deps.test.ts new file mode 100644 index 0000000..d11f00b --- /dev/null +++ b/src/main/runtime/cli-command-prechecks-main-deps.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps'; + +test('cli prechecks main deps builder maps transition handlers', () => { + const calls: string[] = []; + const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({ + isTexthookerOnlyMode: () => true, + setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`), + commandNeedsOverlayRuntime: () => true, + startBackgroundWarmups: () => calls.push('warmups'), + logInfo: (message) => calls.push(`info:${message}`), + })(); + + assert.equal(deps.isTexthookerOnlyMode(), true); + assert.equal(deps.commandNeedsOverlayRuntime({} as never), true); + deps.setTexthookerOnlyMode(false); + deps.startBackgroundWarmups(); + deps.logInfo('x'); + assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']); +}); diff --git a/src/main/runtime/cli-command-prechecks-main-deps.ts b/src/main/runtime/cli-command-prechecks-main-deps.ts new file mode 100644 index 0000000..ac3b88d --- /dev/null +++ b/src/main/runtime/cli-command-prechecks-main-deps.ts @@ -0,0 +1,17 @@ +import type { CliArgs } from '../../cli/args'; + +export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: { + isTexthookerOnlyMode: () => boolean; + setTexthookerOnlyMode: (enabled: boolean) => void; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + startBackgroundWarmups: () => void; + logInfo: (message: string) => void; +}) { + return () => ({ + isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), + setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled), + commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args), + startBackgroundWarmups: () => deps.startBackgroundWarmups(), + logInfo: (message: string) => deps.logInfo(message), + }); +} diff --git a/src/main/runtime/field-grouping-resolver.test.ts b/src/main/runtime/field-grouping-resolver.test.ts new file mode 100644 index 0000000..9bdca07 --- /dev/null +++ b/src/main/runtime/field-grouping-resolver.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetFieldGroupingResolverHandler, + createSetFieldGroupingResolverHandler, +} from './field-grouping-resolver'; + +test('get field grouping resolver returns current resolver', () => { + const resolver = () => undefined; + const getResolver = createGetFieldGroupingResolverHandler({ + getResolver: () => resolver, + }); + + assert.equal(getResolver(), resolver); +}); + +test('set field grouping resolver clears resolver when null is provided', () => { + let current: ((choice: unknown) => void) | null = () => undefined; + const setResolver = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + current = resolver as never; + }, + nextSequence: () => 1, + getSequence: () => 1, + }); + + setResolver(null); + assert.equal(current, null); +}); + +test('set field grouping resolver wraps resolver and ignores stale sequence', () => { + const calls: string[] = []; + let current: ((choice: unknown) => void) | null = null; + let sequence = 0; + + const setResolver = createSetFieldGroupingResolverHandler({ + setResolver: (resolver) => { + current = resolver as never; + }, + nextSequence: () => { + sequence += 1; + return sequence; + }, + getSequence: () => sequence, + }); + + setResolver((choice) => calls.push(`new:${choice}`)); + const firstWrapped = current!; + setResolver((choice) => calls.push(`latest:${choice}`)); + const latestWrapped = current!; + + firstWrapped('A'); + latestWrapped('B'); + + assert.deepEqual(calls, ['latest:B']); +}); diff --git a/src/main/runtime/field-grouping-resolver.ts b/src/main/runtime/field-grouping-resolver.ts new file mode 100644 index 0000000..3fcba4f --- /dev/null +++ b/src/main/runtime/field-grouping-resolver.ts @@ -0,0 +1,29 @@ +import type { KikuFieldGroupingChoice } from '../../types'; + +type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null; + +export function createGetFieldGroupingResolverHandler(deps: { + getResolver: () => FieldGroupingResolver; +}) { + return (): FieldGroupingResolver => deps.getResolver(); +} + +export function createSetFieldGroupingResolverHandler(deps: { + setResolver: (resolver: FieldGroupingResolver) => void; + nextSequence: () => number; + getSequence: () => number; +}) { + return (resolver: FieldGroupingResolver): void => { + if (!resolver) { + deps.setResolver(null); + return; + } + + const sequence = deps.nextSequence(); + const wrappedResolver = (choice: KikuFieldGroupingChoice): void => { + if (sequence !== deps.getSequence()) return; + resolver(choice); + }; + deps.setResolver(wrappedResolver); + }; +} diff --git a/src/main/runtime/jellyfin-client-info.test.ts b/src/main/runtime/jellyfin-client-info.test.ts new file mode 100644 index 0000000..f4a89e7 --- /dev/null +++ b/src/main/runtime/jellyfin-client-info.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGetJellyfinClientInfoHandler, + createGetResolvedJellyfinConfigHandler, +} from './jellyfin-client-info'; + +test('get resolved jellyfin config returns jellyfin section from resolved config', () => { + const jellyfin = { url: 'https://jellyfin.local' } as never; + const getConfig = createGetResolvedJellyfinConfigHandler({ + getResolvedConfig: () => ({ jellyfin } as never), + }); + + assert.equal(getConfig(), jellyfin); +}); + +test('jellyfin client info resolves defaults when fields are missing', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never), + getDefaultJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }) as never, + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }); +}); + +test('jellyfin client info keeps explicit config values', () => { + const getClientInfo = createGetJellyfinClientInfoHandler({ + getResolvedJellyfinConfig: () => + ({ + clientName: 'Custom', + clientVersion: '2.3.4', + deviceId: 'custom-device', + }) as never, + getDefaultJellyfinConfig: () => + ({ + clientName: 'SubMiner', + clientVersion: '1.0.0', + deviceId: 'default-device', + }) as never, + }); + + assert.deepEqual(getClientInfo(), { + clientName: 'Custom', + clientVersion: '2.3.4', + deviceId: 'custom-device', + }); +}); diff --git a/src/main/runtime/jellyfin-client-info.ts b/src/main/runtime/jellyfin-client-info.ts new file mode 100644 index 0000000..c203184 --- /dev/null +++ b/src/main/runtime/jellyfin-client-info.ts @@ -0,0 +1,33 @@ +export function createGetResolvedJellyfinConfigHandler(deps: { + getResolvedConfig: () => { jellyfin: unknown }; +}) { + return () => deps.getResolvedConfig().jellyfin as never; +} + +export function createGetJellyfinClientInfoHandler(deps: { + getResolvedJellyfinConfig: () => { + clientName?: string; + clientVersion?: string; + deviceId?: string; + }; + getDefaultJellyfinConfig: () => { + clientName?: string; + clientVersion?: string; + deviceId?: string; + }; +}) { + return ( + config = deps.getResolvedJellyfinConfig(), + ): { + clientName: string; + clientVersion: string; + deviceId: string; + } => { + const defaults = deps.getDefaultJellyfinConfig(); + return { + clientName: config.clientName || defaults.clientName || '', + clientVersion: config.clientVersion || defaults.clientVersion || '', + deviceId: config.deviceId || defaults.deviceId || '', + }; + }; +} diff --git a/src/main/runtime/mpv-jellyfin-defaults.test.ts b/src/main/runtime/mpv-jellyfin-defaults.test.ts new file mode 100644 index 0000000..06e4d08 --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createApplyJellyfinMpvDefaultsHandler, + createGetDefaultSocketPathHandler, +} from './mpv-jellyfin-defaults'; + +test('apply jellyfin mpv defaults sends expected property commands', () => { + const calls: string[] = []; + const applyDefaults = createApplyJellyfinMpvDefaultsHandler({ + sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')), + jellyfinLangPref: 'ja,jp', + }); + + applyDefaults({}); + assert.deepEqual(calls, [ + 'set_property:sub-auto:fuzzy', + 'set_property:aid:auto', + 'set_property:sid:auto', + 'set_property:secondary-sid:auto', + 'set_property:secondary-sub-visibility:no', + 'set_property:alang:ja,jp', + 'set_property:slang:ja,jp', + ]); +}); + +test('get default socket path returns platform specific value', () => { + const getWindowsPath = createGetDefaultSocketPathHandler({ platform: 'win32' }); + const getUnixPath = createGetDefaultSocketPathHandler({ platform: 'darwin' }); + assert.equal(getWindowsPath(), '\\\\.\\pipe\\subminer-socket'); + assert.equal(getUnixPath(), '/tmp/subminer-socket'); +}); diff --git a/src/main/runtime/mpv-jellyfin-defaults.ts b/src/main/runtime/mpv-jellyfin-defaults.ts new file mode 100644 index 0000000..b1a1d60 --- /dev/null +++ b/src/main/runtime/mpv-jellyfin-defaults.ts @@ -0,0 +1,30 @@ +type MpvClientLike = unknown; + +export function createApplyJellyfinMpvDefaultsHandler(deps: { + sendMpvCommandRuntime: ( + client: MpvClientLike, + command: [string, string, string], + ) => void; + jellyfinLangPref: string; +}) { + return (client: MpvClientLike): void => { + deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']); + deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']); + deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']); + deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]); + deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]); + }; +} + +export function createGetDefaultSocketPathHandler(deps: { + platform: string; +}) { + return (): string => { + if (deps.platform === 'win32') { + return '\\\\.\\pipe\\subminer-socket'; + } + return '/tmp/subminer-socket'; + }; +} diff --git a/src/main/runtime/overlay-runtime-main-actions.test.ts b/src/main/runtime/overlay-runtime-main-actions.test.ts new file mode 100644 index 0000000..f08c536 --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBroadcastRuntimeOptionsChangedHandler, + createGetRuntimeOptionsStateHandler, + createOpenRuntimeOptionsPaletteHandler, + createRestorePreviousSecondarySubVisibilityHandler, + createSendToActiveOverlayWindowHandler, + createSetOverlayDebugVisualizationEnabledHandler, +} from './overlay-runtime-main-actions'; + +test('runtime options state handler returns empty list without manager', () => { + const getState = createGetRuntimeOptionsStateHandler({ + getRuntimeOptionsManager: () => null, + }); + assert.deepEqual(getState(), []); +}); + +test('runtime options state handler returns list from manager', () => { + const getState = createGetRuntimeOptionsStateHandler({ + getRuntimeOptionsManager: () => + ({ + listOptions: () => [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ], + }) as never, + }); + assert.deepEqual(getState(), [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ]); +}); + +test('restore previous secondary subtitle visibility no-ops without connected mpv client', () => { + let restored = false; + const restore = createRestorePreviousSecondarySubVisibilityHandler({ + getMpvClient: () => ({ connected: false, restorePreviousSecondarySubVisibility: () => (restored = true) }), + }); + restore(); + assert.equal(restored, false); +}); + +test('restore previous secondary subtitle visibility calls runtime when connected', () => { + let restored = false; + const restore = createRestorePreviousSecondarySubVisibilityHandler({ + getMpvClient: () => ({ connected: true, restorePreviousSecondarySubVisibility: () => (restored = true) }), + }); + restore(); + assert.equal(restored, true); +}); + +test('broadcast runtime options changed passes through state getter and broadcaster', () => { + const calls: string[] = []; + const broadcast = createBroadcastRuntimeOptionsChangedHandler({ + broadcastRuntimeOptionsChangedRuntime: (getState, emit) => { + calls.push(`state:${JSON.stringify(getState())}`); + emit('runtime-options:changed', { id: 1 }); + }, + getRuntimeOptionsState: () => [ + { + id: 'anki.autoUpdateNewCards', + label: 'X', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ], + broadcastToOverlayWindows: (channel, payload) => calls.push(`emit:${channel}:${JSON.stringify(payload)}`), + }); + + broadcast(); + assert.deepEqual(calls, [ + 'state:[{"id":"anki.autoUpdateNewCards","label":"X","scope":"ankiConnect","valueType":"boolean","value":true,"allowedValues":[true,false],"requiresRestart":false}]', + 'emit:runtime-options:changed:{"id":1}', + ]); +}); + +test('send to active overlay window delegates to runtime sender', () => { + const send = createSendToActiveOverlayWindowHandler({ + sendToActiveOverlayWindowRuntime: (channel, payload) => channel === 'ok' && payload === 1, + }); + assert.equal(send('ok', 1), true); + assert.equal(send('no', 1), false); +}); + +test('set overlay debug visualization enabled delegates with current state and broadcast', () => { + const calls: string[] = []; + let current = false; + const setEnabled = createSetOverlayDebugVisualizationEnabledHandler({ + setOverlayDebugVisualizationEnabledRuntime: (curr, next, setCurrent, broadcast) => { + calls.push(`runtime:${curr}->${next}`); + setCurrent(next); + broadcast('overlay-debug:set', next); + }, + getCurrentEnabled: () => current, + setCurrentEnabled: (enabled) => { + current = enabled; + calls.push(`set:${enabled}`); + }, + broadcastToOverlayWindows: (channel, value) => calls.push(`emit:${channel}:${value}`), + }); + + setEnabled(true); + assert.equal(current, true); + assert.deepEqual(calls, ['runtime:false->true', 'set:true', 'emit:overlay-debug:set:true']); +}); + +test('open runtime options palette handler delegates to runtime', () => { + let opened = false; + const open = createOpenRuntimeOptionsPaletteHandler({ + openRuntimeOptionsPaletteRuntime: () => { + opened = true; + }, + }); + open(); + assert.equal(opened, true); +}); diff --git a/src/main/runtime/overlay-runtime-main-actions.ts b/src/main/runtime/overlay-runtime-main-actions.ts new file mode 100644 index 0000000..574195e --- /dev/null +++ b/src/main/runtime/overlay-runtime-main-actions.ts @@ -0,0 +1,90 @@ +import type { RuntimeOptionState } from '../../types'; +import type { OverlayHostedModal } from '../overlay-runtime'; + +type RuntimeOptionsManagerLike = { + listOptions: () => RuntimeOptionState[]; +}; + +type MpvClientLike = { + connected: boolean; + restorePreviousSecondarySubVisibility: () => void; +}; + +export function createGetRuntimeOptionsStateHandler(deps: { + getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; +}) { + return (): RuntimeOptionState[] => { + const manager = deps.getRuntimeOptionsManager(); + if (!manager) return []; + return manager.listOptions(); + }; +} + +export function createRestorePreviousSecondarySubVisibilityHandler(deps: { + getMpvClient: () => MpvClientLike | null; +}) { + return (): void => { + const client = deps.getMpvClient(); + if (!client || !client.connected) return; + client.restorePreviousSecondarySubVisibility(); + }; +} + +export function createBroadcastRuntimeOptionsChangedHandler(deps: { + broadcastRuntimeOptionsChangedRuntime: ( + getRuntimeOptionsState: () => RuntimeOptionState[], + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + getRuntimeOptionsState: () => RuntimeOptionState[]; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; +}) { + return (): void => { + deps.broadcastRuntimeOptionsChangedRuntime( + () => deps.getRuntimeOptionsState(), + (channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args), + ); + }; +} + +export function createSendToActiveOverlayWindowHandler(deps: { + sendToActiveOverlayWindowRuntime: ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ) => boolean; +}) { + return ( + channel: string, + payload?: unknown, + runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + ): boolean => deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions); +} + +export function createSetOverlayDebugVisualizationEnabledHandler(deps: { + setOverlayDebugVisualizationEnabledRuntime: ( + currentEnabled: boolean, + nextEnabled: boolean, + setCurrentEnabled: (enabled: boolean) => void, + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, + ) => void; + getCurrentEnabled: () => boolean; + setCurrentEnabled: (enabled: boolean) => void; + broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; +}) { + return (enabled: boolean): void => { + deps.setOverlayDebugVisualizationEnabledRuntime( + deps.getCurrentEnabled(), + enabled, + (next) => deps.setCurrentEnabled(next), + (channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args), + ); + }; +} + +export function createOpenRuntimeOptionsPaletteHandler(deps: { + openRuntimeOptionsPaletteRuntime: () => void; +}) { + return (): void => { + deps.openRuntimeOptionsPaletteRuntime(); + }; +} diff --git a/src/main/runtime/secondary-sub-mode-main-deps.test.ts b/src/main/runtime/secondary-sub-mode-main-deps.test.ts new file mode 100644 index 0000000..4858580 --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-main-deps.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildCycleSecondarySubModeMainDepsHandler } from './secondary-sub-mode-main-deps'; +import type { SecondarySubMode } from '../../types'; + +test('cycle secondary sub mode main deps builder maps state and broadcasts with channel', () => { + const calls: string[] = []; + let mode: SecondarySubMode = 'hover'; + let lastToggleAt = 100; + const deps = createBuildCycleSecondarySubModeMainDepsHandler({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (nextMode) => { + mode = nextMode; + calls.push(`set-mode:${nextMode}`); + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (timestampMs) => { + lastToggleAt = timestampMs; + calls.push(`set-ts:${timestampMs}`); + }, + broadcastToOverlayWindows: (channel, nextMode) => calls.push(`broadcast:${channel}:${nextMode}`), + showMpvOsd: (text) => calls.push(`osd:${text}`), + })(); + + assert.equal(deps.getSecondarySubMode(), 'hover'); + assert.equal(deps.getLastSecondarySubToggleAtMs(), 100); + deps.setSecondarySubMode('visible'); + deps.setLastSecondarySubToggleAtMs(200); + deps.broadcastSecondarySubMode('visible'); + deps.showMpvOsd('Secondary subtitle: visible'); + assert.equal(mode, 'visible'); + assert.equal(lastToggleAt, 200); + assert.deepEqual(calls, [ + 'set-mode:visible', + 'set-ts:200', + 'broadcast:secondary-subtitle:mode:visible', + 'osd:Secondary subtitle: visible', + ]); +}); diff --git a/src/main/runtime/secondary-sub-mode-main-deps.ts b/src/main/runtime/secondary-sub-mode-main-deps.ts new file mode 100644 index 0000000..d996000 --- /dev/null +++ b/src/main/runtime/secondary-sub-mode-main-deps.ts @@ -0,0 +1,21 @@ +import type { SecondarySubMode } from '../../types'; + +export function createBuildCycleSecondarySubModeMainDepsHandler(deps: { + getSecondarySubMode: () => SecondarySubMode; + setSecondarySubMode: (mode: SecondarySubMode) => void; + getLastSecondarySubToggleAtMs: () => number; + setLastSecondarySubToggleAtMs: (timestampMs: number) => void; + broadcastToOverlayWindows: (channel: string, mode: SecondarySubMode) => void; + showMpvOsd: (text: string) => void; +}) { + return () => ({ + getSecondarySubMode: () => deps.getSecondarySubMode(), + setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), + getLastSecondarySubToggleAtMs: () => deps.getLastSecondarySubToggleAtMs(), + setLastSecondarySubToggleAtMs: (timestampMs: number) => + deps.setLastSecondarySubToggleAtMs(timestampMs), + broadcastSecondarySubMode: (mode: SecondarySubMode) => + deps.broadcastToOverlayWindows('secondary-subtitle:mode', mode), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + }); +} diff --git a/src/main/runtime/startup-bootstrap-main-deps.test.ts b/src/main/runtime/startup-bootstrap-main-deps.test.ts new file mode 100644 index 0000000..08bb8a3 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-main-deps.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps'; + +test('startup bootstrap main deps builder maps deps and handles generate-config callbacks', () => { + const calls: string[] = []; + let exitCode = 0; + const deps = createBuildStartupBootstrapMainDepsHandler({ + argv: ['node', 'main.js'], + parseArgs: () => ({}) as never, + setLogLevel: (level) => calls.push(`log:${level}`), + forceX11Backend: () => calls.push('force-x11'), + enforceUnsupportedWaylandMode: () => calls.push('guard-wayland'), + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: (code) => { + exitCode = code; + calls.push(`exit:${code}`); + }, + quitApp: () => calls.push('quit'), + logGenerateConfigError: (message) => calls.push(`error:${message}`), + startAppLifecycle: () => calls.push('start-lifecycle'), + })(); + + assert.deepEqual(deps.argv, ['node', 'main.js']); + assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock'); + deps.setLogLevel('debug', 'config'); + deps.forceX11Backend({} as never); + deps.enforceUnsupportedWaylandMode({} as never); + deps.startAppLifecycle({} as never); + deps.onConfigGenerated(7); + assert.equal(exitCode, 7); + deps.onGenerateConfigError(new Error('boom')); + assert.equal(exitCode, 1); + + assert.deepEqual(calls, [ + 'log:debug', + 'force-x11', + 'guard-wayland', + 'start-lifecycle', + 'exit:7', + 'quit', + 'error:Failed to generate config: boom', + 'exit:1', + 'quit', + ]); +}); diff --git a/src/main/runtime/startup-bootstrap-main-deps.ts b/src/main/runtime/startup-bootstrap-main-deps.ts new file mode 100644 index 0000000..03b7d05 --- /dev/null +++ b/src/main/runtime/startup-bootstrap-main-deps.ts @@ -0,0 +1,62 @@ +import type { CliArgs } from '../../cli/args'; +import type { LogLevelSource } from '../../logger'; +import type { ResolvedConfig } from '../../types'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; + +export function createBuildStartupBootstrapMainDepsHandler(deps: { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevel: (level: string, source: LogLevelSource) => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + shouldStartApp: (args: CliArgs) => boolean; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + configDir: string; + defaultConfig: ResolvedConfig; + generateConfigTemplate: (config: ResolvedConfig) => string; + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => Promise; + setExitCode: (code: number) => void; + quitApp: () => void; + logGenerateConfigError: (message: string) => void; + startAppLifecycle: (args: CliArgs) => void; +}) { + return (): StartupBootstrapRuntimeFactoryDeps => ({ + argv: deps.argv, + parseArgs: (argv: string[]) => deps.parseArgs(argv), + setLogLevel: (level: string, source: LogLevelSource) => deps.setLogLevel(level, source), + forceX11Backend: (args: CliArgs) => deps.forceX11Backend(args), + enforceUnsupportedWaylandMode: (args: CliArgs) => deps.enforceUnsupportedWaylandMode(args), + shouldStartApp: (args: CliArgs) => deps.shouldStartApp(args), + getDefaultSocketPath: () => deps.getDefaultSocketPath(), + defaultTexthookerPort: deps.defaultTexthookerPort, + configDir: deps.configDir, + defaultConfig: deps.defaultConfig, + generateConfigTemplate: (config: ResolvedConfig) => deps.generateConfigTemplate(config), + generateDefaultConfigFile: ( + args: CliArgs, + options: { + configDir: string; + defaultConfig: unknown; + generateTemplate: (config: unknown) => string; + }, + ) => deps.generateDefaultConfigFile(args, options), + onConfigGenerated: (exitCode: number) => { + deps.setExitCode(exitCode); + deps.quitApp(); + }, + onGenerateConfigError: (error: Error) => { + deps.logGenerateConfigError(`Failed to generate config: ${error.message}`); + deps.setExitCode(1); + deps.quitApp(); + }, + startAppLifecycle: (args: CliArgs) => deps.startAppLifecycle(args), + }); +} diff --git a/src/main/runtime/startup-config-main-deps.test.ts b/src/main/runtime/startup-config-main-deps.test.ts new file mode 100644 index 0000000..2aebf08 --- /dev/null +++ b/src/main/runtime/startup-config-main-deps.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildCriticalConfigErrorMainDepsHandler, + createBuildReloadConfigMainDepsHandler, +} from './startup-config-main-deps'; + +test('reload config main deps builder maps callbacks and fail handlers', async () => { + const calls: string[] = []; + const deps = createBuildReloadConfigMainDepsHandler({ + reloadConfigStrict: () => ({ ok: true }), + logInfo: (message) => calls.push(`info:${message}`), + logWarning: (message) => calls.push(`warn:${message}`), + showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + startConfigHotReload: () => calls.push('start-hot-reload'), + refreshAnilistClientSecretState: async (options) => { + calls.push(`refresh:${options.force}`); + return true; + }, + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`), + quit: () => calls.push('quit'), + }, + })(); + + assert.deepEqual(deps.reloadConfigStrict(), { ok: true }); + deps.logInfo('x'); + deps.logWarning('y'); + deps.showDesktopNotification('SubMiner', { body: 'warn' }); + deps.startConfigHotReload(); + await deps.refreshAnilistClientSecretState({ force: true }); + deps.failHandlers.logError('bad'); + deps.failHandlers.showErrorBox('Oops', 'Details'); + deps.failHandlers.quit(); + assert.deepEqual(calls, [ + 'info:x', + 'warn:y', + 'notify:SubMiner:warn', + 'start-hot-reload', + 'refresh:true', + 'error:bad', + 'error-box:Oops:Details', + 'quit', + ]); +}); + +test('critical config main deps builder maps config path and fail handlers', () => { + const calls: string[] = []; + const deps = createBuildCriticalConfigErrorMainDepsHandler({ + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: (details) => calls.push(`error:${details}`), + showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`), + quit: () => calls.push('quit'), + }, + })(); + + assert.equal(deps.getConfigPath(), '/tmp/config.jsonc'); + deps.failHandlers.logError('bad'); + deps.failHandlers.showErrorBox('Oops', 'Details'); + deps.failHandlers.quit(); + assert.deepEqual(calls, ['error:bad', 'error-box:Oops:Details', 'quit']); +}); diff --git a/src/main/runtime/startup-config-main-deps.ts b/src/main/runtime/startup-config-main-deps.ts new file mode 100644 index 0000000..989a601 --- /dev/null +++ b/src/main/runtime/startup-config-main-deps.ts @@ -0,0 +1,47 @@ +export function createBuildReloadConfigMainDepsHandler(deps: { + reloadConfigStrict: () => unknown; + logInfo: (message: string) => void; + logWarning: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + startConfigHotReload: () => void; + refreshAnilistClientSecretState: (options: { force: boolean }) => Promise; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + quit: () => void; + }; +}) { + return () => ({ + reloadConfigStrict: () => deps.reloadConfigStrict() as never, + logInfo: (message: string) => deps.logInfo(message), + logWarning: (message: string) => deps.logWarning(message), + showDesktopNotification: (title: string, options: { body: string }) => + deps.showDesktopNotification(title, options), + startConfigHotReload: () => deps.startConfigHotReload(), + refreshAnilistClientSecretState: (options: { force: boolean }) => + deps.refreshAnilistClientSecretState(options), + failHandlers: { + logError: (details: string) => deps.failHandlers.logError(details), + showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details), + quit: () => deps.failHandlers.quit(), + }, + }); +} + +export function createBuildCriticalConfigErrorMainDepsHandler(deps: { + getConfigPath: () => string; + failHandlers: { + logError: (details: string) => void; + showErrorBox: (title: string, details: string) => void; + quit: () => void; + }; +}) { + return () => ({ + getConfigPath: () => deps.getConfigPath(), + failHandlers: { + logError: (details: string) => deps.failHandlers.logError(details), + showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details), + quit: () => deps.failHandlers.quit(), + }, + }); +} diff --git a/src/main/runtime/startup-lifecycle-main-deps.test.ts b/src/main/runtime/startup-lifecycle-main-deps.test.ts new file mode 100644 index 0000000..29c2b71 --- /dev/null +++ b/src/main/runtime/startup-lifecycle-main-deps.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './startup-lifecycle-main-deps'; + +test('app lifecycle runtime runner main deps builder maps lifecycle callbacks', async () => { + const calls: string[] = []; + const deps = createBuildAppLifecycleRuntimeRunnerMainDepsHandler({ + app: {} as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => calls.push('handle-cli'), + printHelp: () => calls.push('help'), + logNoRunningInstance: () => calls.push('no-instance'), + onReady: async () => { + calls.push('ready'); + }, + onWillQuitCleanup: () => calls.push('cleanup'), + shouldRestoreWindowsOnActivate: () => true, + restoreWindowsOnActivate: () => calls.push('restore'), + shouldQuitOnWindowAllClosed: () => false, + })(); + + assert.equal(deps.platform, 'darwin'); + assert.equal(deps.shouldStartApp({} as never), true); + deps.handleCliCommand({} as never, 'initial'); + deps.printHelp(); + deps.logNoRunningInstance(); + await deps.onReady(); + deps.onWillQuitCleanup(); + deps.restoreWindowsOnActivate(); + assert.equal(deps.shouldRestoreWindowsOnActivate(), true); + assert.equal(deps.shouldQuitOnWindowAllClosed(), false); + assert.deepEqual(calls, ['handle-cli', 'help', 'no-instance', 'ready', 'cleanup', 'restore']); +}); diff --git a/src/main/runtime/startup-lifecycle-main-deps.ts b/src/main/runtime/startup-lifecycle-main-deps.ts new file mode 100644 index 0000000..ebe63c4 --- /dev/null +++ b/src/main/runtime/startup-lifecycle-main-deps.ts @@ -0,0 +1,20 @@ +import type { AppLifecycleRuntimeRunnerParams } from '../startup-lifecycle'; + +export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler( + deps: AppLifecycleRuntimeRunnerParams, +) { + return (): AppLifecycleRuntimeRunnerParams => ({ + app: deps.app, + platform: deps.platform, + shouldStartApp: deps.shouldStartApp, + parseArgs: deps.parseArgs, + handleCliCommand: deps.handleCliCommand, + printHelp: deps.printHelp, + logNoRunningInstance: deps.logNoRunningInstance, + onReady: deps.onReady, + onWillQuitCleanup: deps.onWillQuitCleanup, + shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate, + restoreWindowsOnActivate: deps.restoreWindowsOnActivate, + shouldQuitOnWindowAllClosed: deps.shouldQuitOnWindowAllClosed, + }); +} diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts new file mode 100644 index 0000000..17c733b --- /dev/null +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createBuildTokenizerDepsMainHandler, + createCreateMecabTokenizerAndCheckMainHandler, + createPrewarmSubtitleDictionariesMainHandler, +} from './subtitle-tokenization-main-deps'; + +test('tokenizer deps builder records known-word lookups and maps readers', () => { + const calls: string[] = []; + const deps = createBuildTokenizerDepsMainHandler({ + getYomitanExt: () => ({ id: 'ext' }), + getYomitanParserWindow: () => ({ id: 'window' }), + setYomitanParserWindow: () => calls.push('set-window'), + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => calls.push('set-ready'), + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => calls.push('set-init'), + isKnownWord: (text) => text === 'known', + recordLookup: (hit) => calls.push(`lookup:${hit}`), + getKnownWordMatchMode: () => 'exact', + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => 'N2', + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: () => 5, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => ({ id: 'mecab' }), + })(); + + assert.equal(deps.isKnownWord('known'), true); + assert.equal(deps.isKnownWord('unknown'), false); + deps.setYomitanParserWindow({}); + deps.setYomitanParserReadyPromise(null); + deps.setYomitanParserInitPromise(null); + assert.equal(deps.getMinSentenceWordsForNPlusOne(), 3); + assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']); +}); + +test('mecab tokenizer check creates tokenizer once and runs availability check', async () => { + const calls: string[] = []; + type Tokenizer = { id: string }; + let tokenizer: Tokenizer | null = null; + const run = createCreateMecabTokenizerAndCheckMainHandler({ + getMecabTokenizer: () => tokenizer, + setMecabTokenizer: (next) => { + tokenizer = next; + calls.push('set'); + }, + createMecabTokenizer: () => { + calls.push('create'); + return { id: 'mecab' }; + }, + checkAvailability: async () => { + calls.push('check'); + }, + }); + + await run(); + await run(); + assert.deepEqual(calls, ['create', 'set', 'check', 'check']); +}); + +test('dictionary prewarm runs both dictionary loaders', async () => { + const calls: string[] = []; + const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + ensureJlptDictionaryLookup: async () => { + calls.push('jlpt'); + }, + ensureFrequencyDictionaryLookup: async () => { + calls.push('freq'); + }, + }); + + await prewarm(); + assert.deepEqual(calls.sort(), ['freq', 'jlpt']); +}); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts new file mode 100644 index 0000000..baa1ee7 --- /dev/null +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -0,0 +1,69 @@ +export function createBuildTokenizerDepsMainHandler(deps: { + getYomitanExt: () => unknown; + getYomitanParserWindow: () => unknown; + setYomitanParserWindow: (window: unknown) => void; + getYomitanParserReadyPromise: () => Promise | null; + setYomitanParserReadyPromise: (promise: Promise | null) => void; + getYomitanParserInitPromise: () => Promise | null; + setYomitanParserInitPromise: (promise: Promise | null) => void; + isKnownWord: (text: string) => boolean; + recordLookup: (hit: boolean) => void; + getKnownWordMatchMode: () => unknown; + getMinSentenceWordsForNPlusOne: () => number; + getJlptLevel: (text: string) => unknown; + getJlptEnabled: () => boolean; + getFrequencyDictionaryEnabled: () => boolean; + getFrequencyRank: (text: string) => unknown; + getYomitanGroupDebugEnabled: () => boolean; + getMecabTokenizer: () => unknown; +}) { + return () => ({ + getYomitanExt: () => deps.getYomitanExt() as never, + getYomitanParserWindow: () => deps.getYomitanParserWindow() as never, + setYomitanParserWindow: (window: unknown) => deps.setYomitanParserWindow(window), + getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise() as never, + setYomitanParserReadyPromise: (promise: Promise | null) => + deps.setYomitanParserReadyPromise(promise), + getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise() as never, + setYomitanParserInitPromise: (promise: Promise | null) => + deps.setYomitanParserInitPromise(promise), + isKnownWord: (text: string) => { + const hit = deps.isKnownWord(text); + deps.recordLookup(hit); + return hit; + }, + getKnownWordMatchMode: () => deps.getKnownWordMatchMode() as never, + getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(), + getJlptLevel: (text: string) => deps.getJlptLevel(text) as never, + getJlptEnabled: () => deps.getJlptEnabled(), + getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), + getFrequencyRank: (text: string) => deps.getFrequencyRank(text) as never, + getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(), + getMecabTokenizer: () => deps.getMecabTokenizer() as never, + }); +} + +export function createCreateMecabTokenizerAndCheckMainHandler(deps: { + getMecabTokenizer: () => TMecab | null; + setMecabTokenizer: (tokenizer: TMecab) => void; + createMecabTokenizer: () => TMecab; + checkAvailability: (tokenizer: TMecab) => Promise; +}) { + return async (): Promise => { + let tokenizer = deps.getMecabTokenizer(); + if (!tokenizer) { + tokenizer = deps.createMecabTokenizer(); + deps.setMecabTokenizer(tokenizer); + } + await deps.checkAvailability(tokenizer); + }; +} + +export function createPrewarmSubtitleDictionariesMainHandler(deps: { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; +}) { + return async (): Promise => { + await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]); + }; +}