From 90fc7cf940f5f4eb4be56094ae3f432b74f2bd8b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 31 Oct 2026 14:07:07 -0700 Subject: [PATCH] refactor(main): fold remaining main-deps builders into their handlers (#308) * refactor(main): drop remaining pass-through main-deps builders Jellyfin client-info/setup-window/remote, overlay shortcuts/visibility/ layout/window-factory, config hot-reload, and startup bootstrap factory builders only forwarded deps. Callers now pass deps to the handlers directly; createWatchConfigPathHandler moves into config-hot-reload-handlers. * refactor(main): fold small-logic main-deps builders into their handlers Tray click, mpv client factory, startup bootstrap exit callbacks, overlay runtime options, and dictionary roots/runtime builders move their logic into the owning handler or call site. The two duplicated dictionary-root builders become one createBuildDictionaryRootsHandler(vendorDirName). * refactor(main): fold cli command context builders into the factory createCliCommandContextFactory now adapts appState and main services to the CLI command context deps directly (socket-path change notification, texthooker port/websocket URL, browser-open default). The pass-through cli-command-context-main-deps and cli-command-context-deps layers are removed; their behavioural tests now run against the factory's context. * refactor(main): fold subtitle tokenization builder into the mpv composer The tokenizer deps builder's logic (known-word lookup recording and the character-dictionary gate on name matching) becomes buildTokenizerRuntimeOptions in mpv-runtime-composer, tested through the composer. The MeCab check and dictionary prewarm handlers move to subtitle-tokenization-warmup.ts, and the unused BuiltMainDeps type is removed from composer contracts. * refactor(main): drop redundant tokenizer deps keys and cast * refactor(main): end the main-deps builder pattern mpv-main-event-main-deps becomes mpv-main-event-binding-deps (createMpvMainEventBindingDeps), returning the binding deps directly instead of a thunk; it owns the subtitle-recording state. The will-quit cleanup adapter likewise returns its deps directly, and the pass-through app-lifecycle activate builders are removed. * docs: drop references to main-deps builders --- docs-site/architecture.md | 2 +- docs/architecture/layering.md | 4 +- docs/workflow/testing.md | 4 +- src/main.ts | 485 ++++++++---------- .../runtime/app-lifecycle-main-activate.ts | 21 - .../app-lifecycle-main-cleanup.test.ts | 8 +- .../runtime/app-lifecycle-main-cleanup.ts | 8 +- .../runtime/app-runtime-main-deps.test.ts | 25 - src/main/runtime/app-runtime-main-deps.ts | 103 ---- src/main/runtime/cli-command-context-deps.ts | 128 ----- ...ts => cli-command-context-factory.test.ts} | 27 +- .../runtime/cli-command-context-factory.ts | 175 ++++++- .../runtime/cli-command-context-main-deps.ts | 165 ------ src/main/runtime/composers/contracts.ts | 8 - .../composers/jellyfin-remote-composer.ts | 128 ++--- .../composers/jellyfin-runtime-composer.ts | 39 +- .../composers/mpv-runtime-composer.test.ts | 60 +++ .../runtime/composers/mpv-runtime-composer.ts | 119 +++-- .../composers/startup-lifecycle-composer.ts | 22 +- .../config-hot-reload-handlers.test.ts | 36 ++ .../runtime/config-hot-reload-handlers.ts | 29 ++ .../config-hot-reload-main-deps.test.ts | 38 -- .../runtime/config-hot-reload-main-deps.ts | 134 ----- src/main/runtime/dictionary-roots.test.ts | 49 ++ src/main/runtime/dictionary-roots.ts | 47 ++ .../dictionary-runtime-main-deps.test.ts | 78 --- .../runtime/dictionary-runtime-main-deps.ts | 136 ----- src/main/runtime/domains/jellyfin.ts | 3 - src/main/runtime/domains/mpv.ts | 5 +- src/main/runtime/domains/overlay.ts | 3 - src/main/runtime/domains/shortcuts.ts | 1 - src/main/runtime/domains/startup.ts | 3 +- .../runtime/jellyfin-client-info-main-deps.ts | 30 -- src/main/runtime/jellyfin-remote-main-deps.ts | 78 --- .../jellyfin-setup-window-main-deps.ts | 39 -- .../mpv-client-runtime-service-main-deps.ts | 38 -- .../mpv-client-runtime-service.test.ts | 50 ++ .../runtime/mpv-client-runtime-service.ts | 39 +- ...ts => mpv-main-event-binding-deps.test.ts} | 8 +- ...deps.ts => mpv-main-event-binding-deps.ts} | 8 +- src/main/runtime/overlay-geometry-runtime.ts | 145 +++--- .../overlay-runtime-bootstrap-handlers.ts | 36 +- .../overlay-runtime-options-main-deps.ts | 96 ---- src/main/runtime/overlay-runtime-options.ts | 96 ++-- .../overlay-shortcuts-runtime-main-deps.ts | 29 -- .../overlay-visibility-actions-main-deps.ts | 29 -- .../overlay-visibility-runtime-main-deps.ts | 36 -- .../runtime/overlay-visibility-runtime.ts | 22 +- .../overlay-window-factory-main-deps.ts | 79 --- .../overlay-window-layout-main-deps.ts | 46 -- .../overlay-window-runtime-handlers.ts | 37 +- .../runtime/startup-bootstrap-deps-builder.ts | 32 -- .../startup-bootstrap-main-deps.test.ts | 44 -- .../runtime/startup-bootstrap-main-deps.ts | 62 --- .../runtime/startup-runtime-handlers.test.ts | 58 +++ src/main/runtime/startup-runtime-handlers.ts | 35 +- .../subtitle-tokenization-main-deps.ts | 215 -------- ...s => subtitle-tokenization-warmup.test.ts} | 67 +-- .../runtime/subtitle-tokenization-warmup.ts | 115 +++++ src/main/runtime/tray-lifecycle.test.ts | 21 +- src/main/runtime/tray-lifecycle.ts | 14 + src/main/runtime/tray-runtime-handlers.ts | 51 +- src/main/runtime/yomitan-settings-runtime.ts | 14 +- 63 files changed, 1282 insertions(+), 2480 deletions(-) delete mode 100644 src/main/runtime/app-lifecycle-main-activate.ts delete mode 100644 src/main/runtime/app-runtime-main-deps.test.ts delete mode 100644 src/main/runtime/app-runtime-main-deps.ts delete mode 100644 src/main/runtime/cli-command-context-deps.ts rename src/main/runtime/{cli-command-context-main-deps.test.ts => cli-command-context-factory.test.ts} (86%) delete mode 100644 src/main/runtime/cli-command-context-main-deps.ts delete mode 100644 src/main/runtime/config-hot-reload-main-deps.test.ts delete mode 100644 src/main/runtime/config-hot-reload-main-deps.ts create mode 100644 src/main/runtime/dictionary-roots.test.ts create mode 100644 src/main/runtime/dictionary-roots.ts delete mode 100644 src/main/runtime/dictionary-runtime-main-deps.test.ts delete mode 100644 src/main/runtime/dictionary-runtime-main-deps.ts delete mode 100644 src/main/runtime/jellyfin-client-info-main-deps.ts delete mode 100644 src/main/runtime/jellyfin-remote-main-deps.ts delete mode 100644 src/main/runtime/jellyfin-setup-window-main-deps.ts delete mode 100644 src/main/runtime/mpv-client-runtime-service-main-deps.ts create mode 100644 src/main/runtime/mpv-client-runtime-service.test.ts rename src/main/runtime/{mpv-main-event-main-deps.test.ts => mpv-main-event-binding-deps.test.ts} (98%) rename src/main/runtime/{mpv-main-event-main-deps.ts => mpv-main-event-binding-deps.ts} (98%) delete mode 100644 src/main/runtime/overlay-runtime-options-main-deps.ts delete mode 100644 src/main/runtime/overlay-shortcuts-runtime-main-deps.ts delete mode 100644 src/main/runtime/overlay-visibility-actions-main-deps.ts delete mode 100644 src/main/runtime/overlay-visibility-runtime-main-deps.ts delete mode 100644 src/main/runtime/overlay-window-factory-main-deps.ts delete mode 100644 src/main/runtime/overlay-window-layout-main-deps.ts delete mode 100644 src/main/runtime/startup-bootstrap-deps-builder.ts delete mode 100644 src/main/runtime/startup-bootstrap-main-deps.test.ts delete mode 100644 src/main/runtime/startup-bootstrap-main-deps.ts create mode 100644 src/main/runtime/startup-runtime-handlers.test.ts delete mode 100644 src/main/runtime/subtitle-tokenization-main-deps.ts rename src/main/runtime/{subtitle-tokenization-main-deps.test.ts => subtitle-tokenization-warmup.test.ts} (67%) create mode 100644 src/main/runtime/subtitle-tokenization-warmup.ts diff --git a/docs-site/architecture.md b/docs-site/architecture.md index f4d9531b..ede635b3 100644 --- a/docs-site/architecture.md +++ b/docs-site/architecture.md @@ -138,7 +138,7 @@ Runtime code uses dependency injection: 2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused. 3. Call the service from lifecycle or command wiring. -`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Many handlers take a `*MainDeps` object built by a `createBuild*MainDepsHandler` builder, which keeps side effects out of the unit under test. +`main.ts` gets domain handlers through `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`), which exposes the barrels in `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test. Composers declare their inputs with `ComposerInputs` and results with `ComposerOutputs` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time. diff --git a/docs/architecture/layering.md b/docs/architecture/layering.md index 8dc4b78b..3d7f7e6e 100644 --- a/docs/architecture/layering.md +++ b/docs/architecture/layering.md @@ -20,7 +20,9 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho - Keep `src/main.ts` thin; wire, do not implement. - Pass deps straight to a handler factory (`createXHandler({...})`). Do not add - `*-main-deps.ts` builders that only re-wrap deps; existing ones are being removed. + deps builders (`createBuild*MainDepsHandler`, `*-main-deps.ts`) that re-wrap deps. + Adaptation logic (defaults, appState getters/setters, shared state) belongs in the + handler or in a named adapter that returns the deps directly. - Prefer injecting dependencies from `src/main/` instead of reaching outward from core services. - Keep side effects explicit and close to composition boundaries. - Put reusable business logic in focused services, not in top-level lifecycle files. diff --git a/docs/workflow/testing.md b/docs/workflow/testing.md index ac36d2ed..a00e4842 100644 --- a/docs/workflow/testing.md +++ b/docs/workflow/testing.md @@ -21,8 +21,8 @@ good test looks like here. ## Do Not Write -- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`, - `*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape. +- Pass-through tests: a handler or adapter that only forwards deps + (`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape. - Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML and asserting with regexes. If the invariant matters, extract the logic and run it (see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the diff --git a/src/main.ts b/src/main.ts index af92e93d..93fb8ae3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -176,12 +176,7 @@ import { type ActiveJellyfinRemotePlaybackState, } from './main/runtime/domains/jellyfin'; import { - createBuildConfigHotReloadMessageMainDepsHandler, - createBuildConfigHotReloadAppliedMainDepsHandler, - createBuildConfigHotReloadRuntimeMainDepsHandler, - createBuildWatchConfigPathMainDepsHandler, createWatchConfigPathHandler, - createBuildOverlayVisibilityRuntimeMainDepsHandler, createTrayRuntimeHandlers, createOverlayVisibilityRuntime, createGetRuntimeOptionsStateHandler, @@ -201,10 +196,7 @@ import { resolveSubtitleStyleForRenderer, } from './main/runtime/domains/overlay'; import { - createBuildDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRuntimeMainDepsHandler, - createBuildJlptDictionaryRuntimeMainDepsHandler, + createBuildDictionaryRootsHandler, createImmersionMediaRuntime, createConfigDerivedRuntime, appendClipboardVideoToQueueRuntime, @@ -420,7 +412,6 @@ import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions'; -import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts'; import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createEnsureOverlayMpvSubtitlesHiddenHandler, @@ -2260,67 +2251,64 @@ const subtitlePrefetchRuntime = { autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(), } as const; -const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( - createBuildOverlayShortcutsRuntimeMainDepsHandler({ - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getShortcutsRegistered: () => appState.shortcutsRegistered, - setShortcutsRegistered: (registered: boolean) => { - appState.shortcutsRegistered = registered; - }, - isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, - isOverlayShortcutContextActive: () => { - if (process.platform !== 'win32') { - return true; - } +const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getShortcutsRegistered: () => appState.shortcutsRegistered, + setShortcutsRegistered: (registered: boolean) => { + appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + isOverlayShortcutContextActive: () => { + if (process.platform !== 'win32') { + return true; + } - if (!overlayManager.getVisibleOverlayVisible()) { - return false; - } + if (!overlayManager.getVisibleOverlayVisible()) { + return false; + } - const windowTracker = appState.windowTracker; - if (!windowTracker || !windowTracker.isTracking()) { - return false; - } + const windowTracker = appState.windowTracker; + if (!windowTracker || !windowTracker.isTracking()) { + return false; + } - return windowTracker.isTargetWindowFocused(); - }, - showMpvOsd: (text: string) => - overlayNotificationsRuntime.showConfiguredStatusNotification(text), - openRuntimeOptionsPalette: () => { - openRuntimeOptionsPalette(); - }, - openCharacterDictionaryManager: () => { - openCharacterDictionaryManagerOverlay(); - }, - openJimaku: () => { - openJimakuOverlay(); - }, - openTsukihime: () => { - openTsukihimeOverlay(); - }, - markAudioCard: () => markLastCardAsAudioCardHandler(), - copySubtitleMultiple: (timeoutMs: number) => { - startPendingMultiCopy(timeoutMs); - }, - copySubtitle: () => { - copyCurrentSubtitleHandler(); - }, - toggleSecondarySubMode: () => cycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), - triggerFieldGrouping: () => triggerFieldGroupingHandler(), - triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), - mineSentenceCard: () => mineSentenceCardHandler(), - mineSentenceMultiple: (timeoutMs: number) => { - startPendingMineSentenceMultiple(timeoutMs); - }, - cancelPendingMultiCopy: () => { - cancelPendingMultiCopy(); - }, - cancelPendingMineSentenceMultiple: () => { - cancelPendingMineSentenceMultiple(); - }, - })(), -); + return windowTracker.isTargetWindowFocused(); + }, + showMpvOsd: (text: string) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), + openRuntimeOptionsPalette: () => { + openRuntimeOptionsPalette(); + }, + openCharacterDictionaryManager: () => { + openCharacterDictionaryManagerOverlay(); + }, + openJimaku: () => { + openJimakuOverlay(); + }, + openTsukihime: () => { + openTsukihimeOverlay(); + }, + markAudioCard: () => markLastCardAsAudioCardHandler(), + copySubtitleMultiple: (timeoutMs: number) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + copyCurrentSubtitleHandler(); + }, + toggleSecondarySubMode: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboardHandler(), + triggerFieldGrouping: () => triggerFieldGroupingHandler(), + triggerSubsyncFromConfig: () => subsyncRuntime.triggerFromConfig(), + mineSentenceCard: () => mineSentenceCardHandler(), + mineSentenceMultiple: (timeoutMs: number) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, +}); syncOverlayShortcutsForModal = (isActive: boolean): void => { if (isActive) { overlayShortcutsRuntime.unregisterOverlayShortcuts(); @@ -2329,106 +2317,89 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => { } }; -const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( - { - getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), - showMpvOsd: (message) => showMpvOsd(message), - showOverlayNotification: (payload) => - overlayNotificationsRuntime.showOverlayNotification(payload), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), - }, -); -const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); -const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler( - configHotReloadMessageMainDeps, -); -const buildWatchConfigPathMainDepsHandler = createBuildWatchConfigPathMainDepsHandler({ +const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler({ + getNotificationType: () => overlayNotificationsRuntime.getConfiguredStatusNotificationType(), + showMpvOsd: (message) => showMpvOsd(message), + showOverlayNotification: (payload) => + overlayNotificationsRuntime.showOverlayNotification(payload), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), +}); +const watchConfigPathHandler = createWatchConfigPathHandler({ fileExists: (targetPath) => fs.existsSync(targetPath), dirname: (targetPath) => path.dirname(targetPath), watchPath: (targetPath, listener) => fs.watch(targetPath, listener), }); -const watchConfigPathHandler = createWatchConfigPathHandler(buildWatchConfigPathMainDepsHandler()); -const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler( - { - setKeybindings: (keybindings) => { - appState.keybindings = keybindings; - }, - setSessionBindings: (sessionBindings, sessionBindingWarnings) => { - persistSessionBindings(sessionBindings, sessionBindingWarnings); - }, - refreshGlobalAndOverlayShortcuts: () => { - refreshGlobalAndOverlayShortcuts(); - }, - setSecondarySubMode: (mode) => { - setSecondarySubMode(mode); - }, - broadcastToOverlayWindows: (channel, payload) => { - overlayManager.broadcastToOverlayWindows(channel, payload); - }, - applyAnkiRuntimeConfigPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) { - void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) => - logger.error('Failed to auto-populate Hachidori Anki settings', error), - ); - } - }, - invalidateTokenizationCache: () => { - subtitleProcessingController.invalidateTokenizationCache(); - }, - refreshSubtitlePrefetch: () => { - subtitlePrefetchService?.onSeek(lastObservedTimePos); - }, - refreshCurrentSubtitle: () => { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - }, - setLogLevel: (level) => { - setLogLevel(level, 'config'); - }, - setLogRotation: (rotation) => { - setLogRotation(rotation); - }, - setLogFileToggles: (files) => { - setLogFileToggles(files); - }, - applyAniSkipConfig: () => { - aniSkipRuntime.applyConfigChange(); - }, +const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler({ + setKeybindings: (keybindings) => { + appState.keybindings = keybindings; }, -); -const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( - buildConfigHotReloadAppliedMainDepsHandler(), -); -const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler( - { - getCurrentConfig: () => configService.getConfig(), - reloadConfigStrict: () => configService.reloadConfigStrict(), - watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), - setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimeout: (timeout) => clearTimeout(timeout), - debounceMs: 250, - onHotReloadApplied: applyConfigHotReloadDiff, - onRestartRequired: (fields) => - notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), - onInvalidConfig: notifyConfigHotReloadMessage, - onValidationWarnings: (configPath, warnings) => { - showDesktopNotification('SubMiner', { - body: buildConfigWarningNotificationBody(configPath, warnings), - }); - if (process.platform === 'darwin') { - dialog.showErrorBox( - 'SubMiner config validation warning', - buildConfigWarningDialogDetails(configPath, warnings), - ); - } - }, + setSessionBindings: (sessionBindings, sessionBindingWarnings) => { + persistSessionBindings(sessionBindings, sessionBindingWarnings); }, -); -const configHotReloadRuntime = createConfigHotReloadRuntime( - buildConfigHotReloadRuntimeMainDepsHandler(), -); + refreshGlobalAndOverlayShortcuts: () => { + refreshGlobalAndOverlayShortcuts(); + }, + setSecondarySubMode: (mode) => { + setSecondarySubMode(mode); + }, + broadcastToOverlayWindows: (channel, payload) => { + overlayManager.broadcastToOverlayWindows(channel, payload); + }, + applyAnkiRuntimeConfigPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) { + void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) => + logger.error('Failed to auto-populate Hachidori Anki settings', error), + ); + } + }, + invalidateTokenizationCache: () => { + subtitleProcessingController.invalidateTokenizationCache(); + }, + refreshSubtitlePrefetch: () => { + subtitlePrefetchService?.onSeek(lastObservedTimePos); + }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + setLogLevel: (level) => { + setLogLevel(level, 'config'); + }, + setLogRotation: (rotation) => { + setLogRotation(rotation); + }, + setLogFileToggles: (files) => { + setLogFileToggles(files); + }, + applyAniSkipConfig: () => { + aniSkipRuntime.applyConfigChange(); + }, +}); +const configHotReloadRuntime = createConfigHotReloadRuntime({ + getCurrentConfig: () => configService.getConfig(), + reloadConfigStrict: () => configService.reloadConfigStrict(), + watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timeout) => clearTimeout(timeout), + debounceMs: 250, + onHotReloadApplied: applyConfigHotReloadDiff, + onRestartRequired: (fields) => + notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)), + onInvalidConfig: notifyConfigHotReloadMessage, + onValidationWarnings: (configPath, warnings) => { + showDesktopNotification('SubMiner', { + body: buildConfigWarningNotificationBody(configPath, warnings), + }); + if (process.platform === 'darwin') { + dialog.showErrorBox( + 'SubMiner config validation warning', + buildConfigWarningDialogDetails(configPath, warnings), + ); + } + }, +}); async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise { await yomitanExtensionRuntime.ensureYomitanExtensionLoaded(); @@ -2540,7 +2511,7 @@ const syncAutoScheduler = createSyncAutoScheduler({ }); syncAutoScheduler.start(); -const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ +const dictionaryRootsDeps = { platform: process.platform, dirname: __dirname, appPath: app.getAppPath(), @@ -2550,46 +2521,41 @@ const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({ homeDir: os.homedir(), appDataDir: process.env.APPDATA, cwd: process.cwd(), - joinPath: (...parts) => path.join(...parts), + joinPath: (...parts: string[]) => path.join(...parts), +}; +const buildJlptDictionaryRootsHandler = createBuildDictionaryRootsHandler({ + ...dictionaryRootsDeps, + vendorDirName: 'yomitan-jlpt-vocab', }); -const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({ - platform: process.platform, - dirname: __dirname, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - userDataPath: USER_DATA_PATH, - appUserDataPath: app.getPath('userData'), - homeDir: os.homedir(), - appDataDir: process.env.APPDATA, - cwd: process.cwd(), - joinPath: (...parts) => path.join(...parts), +const buildFrequencyDictionaryRootsHandler = createBuildDictionaryRootsHandler({ + ...dictionaryRootsDeps, + vendorDirName: 'frequency-dictionary', }); -const jlptDictionaryRuntime = createJlptDictionaryRuntimeService( - createBuildJlptDictionaryRuntimeMainDepsHandler({ - isJlptEnabled: () => configService.getConfig().subtitleStyle.enableJlpt, - getDictionaryRoots: () => buildDictionaryRootsHandler(), - getJlptDictionarySearchPaths, - setJlptLevelLookup: (lookup) => { - appState.jlptLevelLookup = lookup; - }, - logInfo: (message) => logger.info(message), - })(), -); +const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({ + isJlptEnabled: () => configService.getConfig().subtitleStyle.enableJlpt, + getSearchPaths: () => + getJlptDictionarySearchPaths({ getDictionaryRoots: () => buildJlptDictionaryRootsHandler() }), + setJlptLevelLookup: (lookup) => { + appState.jlptLevelLookup = lookup; + }, + log: (message) => logger.info(`[JLPT] ${message}`), +}); -const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService( - createBuildFrequencyDictionaryRuntimeMainDepsHandler({ - isFrequencyDictionaryEnabled: () => - configService.getConfig().subtitleStyle.frequencyDictionary.enabled, - getDictionaryRoots: () => buildFrequencyDictionaryRootsHandler(), - getFrequencyDictionarySearchPaths, - getSourcePath: () => configService.getConfig().subtitleStyle.frequencyDictionary.sourcePath, - setFrequencyRankLookup: (lookup) => { - appState.frequencyRankLookup = lookup; - }, - logInfo: (message) => logger.info(message), - })(), -); +const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({ + isFrequencyDictionaryEnabled: () => + configService.getConfig().subtitleStyle.frequencyDictionary.enabled, + getSearchPaths: () => + getFrequencyDictionarySearchPaths({ + getDictionaryRoots: () => + buildFrequencyDictionaryRootsHandler().filter((dictionaryRoot) => dictionaryRoot), + getSourcePath: () => configService.getConfig().subtitleStyle.frequencyDictionary.sourcePath, + }), + setFrequencyRankLookup: (lookup) => { + appState.frequencyRankLookup = lookup; + }, + log: (message) => logger.info(`[Frequency] ${message}`), +}); const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({ getResolver: () => appState.fieldGroupingResolver, @@ -2816,66 +2782,64 @@ const characterNameCandidateLookup = createCharacterNameCandidateLookup({ getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(), }); -const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( - createBuildOverlayVisibilityRuntimeMainDepsHandler({ - getMainWindow: () => overlayManager.getMainWindow(), - getModalActive: () => overlayModalInputState.getModalInputExclusive(), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getForceMousePassthrough: () => appState.statsOverlayVisible, - getNonNativeInputRegionActive: () => - process.platform === 'linux' && - visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), - getSuspendVisibleOverlay: () => appState.statsOverlayVisible, - getOverlayInteractionActive: () => - visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(), - getWindowTracker: () => appState.windowTracker, - getMacOSForegroundProbeActive: () => - visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), - getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, - setTrackerNotReadyWarningShown: (shown: boolean) => { - appState.trackerNotReadyWarningShown = shown; - }, - updateVisibleOverlayBounds: (geometry: WindowGeometry) => - overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), - ensureOverlayWindowLevel: (window) => { - overlayGeometryRuntime.ensureOverlayWindowLevel(window); - }, - syncWindowsOverlayToMpvZOrder: (_window) => { - visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); - }, - syncPrimaryOverlayWindowLayer: (layer) => { - overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); - }, - enforceOverlayLayerOrder: () => { - overlayGeometryRuntime.enforceOverlayLayerOrder(); - }, - syncOverlayShortcuts: () => { - overlayShortcutsRuntime.syncOverlayShortcuts(); - }, - isMacOSPlatform: () => process.platform === 'darwin', - isWindowsPlatform: () => process.platform === 'win32', - showOverlayLoadingOsd: (_message: string) => { - overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); - }, - dismissOverlayLoadingOsd: () => { - overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); - }, - hideNonNativeOverlayWhenTargetUnfocused: () => - shouldRunLinuxOverlayZOrderKeepAlive() && - linuxOverlayModeRuntime.mode === 'fullscreen-override', - resolveFallbackBounds: () => { - const cursorPoint = screen.getCursorScreenPoint(); - const display = screen.getDisplayNearestPoint(cursorPoint); - const fallbackBounds = display.workArea; - return { - x: fallbackBounds.x, - y: fallbackBounds.y, - width: fallbackBounds.width, - height: fallbackBounds.height, - }; - }, - })(), -); +const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalActive: () => overlayModalInputState.getModalInputExclusive(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getForceMousePassthrough: () => appState.statsOverlayVisible, + getNonNativeInputRegionActive: () => + process.platform === 'linux' && + visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(), + getSuspendVisibleOverlay: () => appState.statsOverlayVisible, + getOverlayInteractionActive: () => + visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(), + getWindowTracker: () => appState.windowTracker, + getMacOSForegroundProbeActive: () => + visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), + getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + appState.trackerNotReadyWarningShown = shown; + }, + updateVisibleOverlayBounds: (geometry: WindowGeometry) => + overlayGeometryRuntime.updateVisibleOverlayBounds(geometry), + ensureOverlayWindowLevel: (window) => { + overlayGeometryRuntime.ensureOverlayWindowLevel(window); + }, + syncWindowsOverlayToMpvZOrder: (_window) => { + visibleOverlayInteractionRuntime.requestWindowsVisibleOverlayZOrderSync(); + }, + syncPrimaryOverlayWindowLayer: (layer) => { + overlayGeometryRuntime.syncPrimaryOverlayWindowLayer(layer); + }, + enforceOverlayLayerOrder: () => { + overlayGeometryRuntime.enforceOverlayLayerOrder(); + }, + syncOverlayShortcuts: () => { + overlayShortcutsRuntime.syncOverlayShortcuts(); + }, + isMacOSPlatform: () => process.platform === 'darwin', + isWindowsPlatform: () => process.platform === 'win32', + showOverlayLoadingOsd: (_message: string) => { + overlayNotificationsRuntime.showOverlayLoadingStatusNotification(); + }, + dismissOverlayLoadingOsd: () => { + overlayNotificationsRuntime.dismissOverlayLoadingStatusNotification(); + }, + hideNonNativeOverlayWhenTargetUnfocused: () => + shouldRunLinuxOverlayZOrderKeepAlive() && + linuxOverlayModeRuntime.mode === 'fullscreen-override', + resolveFallbackBounds: () => { + const cursorPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursorPoint); + const fallbackBounds = display.workArea; + return { + x: fallbackBounds.x, + y: fallbackBounds.y, + width: fallbackBounds.width, + height: fallbackBounds.height, + }; + }, +}); const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime({ overlayManager: { @@ -5053,8 +5017,7 @@ const { startupOsdSequencer.markTokenizationReady(); }, }, - createTokenizerRuntimeDeps: (deps) => - createTokenizerDepsRuntime(deps as Parameters[0]), + createTokenizerRuntimeDeps: (deps) => createTokenizerDepsRuntime(deps), tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps), createMecabTokenizerAndCheckMainDeps: { getMecabTokenizer: () => appState.mecabTokenizer, diff --git a/src/main/runtime/app-lifecycle-main-activate.ts b/src/main/runtime/app-lifecycle-main-activate.ts deleted file mode 100644 index 38168a0d..00000000 --- a/src/main/runtime/app-lifecycle-main-activate.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: { - isOverlayRuntimeInitialized: () => boolean; - getAllWindowCount: () => number; -}) { - return () => ({ - isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), - getAllWindowCount: () => deps.getAllWindowCount(), - }); -} - -export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: { - createMainWindow: () => void; - updateVisibleOverlayVisibility: () => void; - syncOverlayMpvSubtitleSuppression: () => void; -}) { - return () => ({ - createMainWindow: () => deps.createMainWindow(), - updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), - syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), - }); -} diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 9bc04239..9571cad8 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -1,15 +1,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup'; +import { createOnWillQuitCleanupDeps } from './app-lifecycle-main-cleanup'; import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions'; -type BuilderDeps = Parameters[0]; +type BuilderDeps = Parameters[0]; // Defaults model an app that is ready but has no live runtime objects. function buildCleanupDeps(overrides: Partial = {}) { const noop = () => {}; const none = () => null; - return createBuildOnWillQuitCleanupDepsHandler({ + return createOnWillQuitCleanupDeps({ destroyTray: noop, stopConfigHotReload: noop, restorePreviousSecondarySubVisibility: noop, @@ -53,7 +53,7 @@ function buildCleanupDeps(overrides: Partial = {}) { cleanupJellyfinSubtitleCache: noop, stopDiscordPresenceService: noop, ...overrides, - })(); + }); } function fakeWindow(calls: string[], destroyed: boolean) { diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 888c0a1f..f77aaf3a 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -17,7 +17,9 @@ type SocketLike = { type TimerLike = ReturnType; -export function createBuildOnWillQuitCleanupDepsHandler(deps: { +// Adapts main-process state to the will-quit cleanup steps: each destroy/stop step looks up +// its current resource and skips it when absent or already destroyed. +export function createOnWillQuitCleanupDeps(deps: { destroyTray: () => void; stopConfigHotReload: () => void; restorePreviousSecondarySubVisibility: () => void; @@ -66,7 +68,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { - return () => ({ + return { destroyTray: () => deps.destroyTray(), stopConfigHotReload: () => deps.stopConfigHotReload(), restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(), @@ -154,5 +156,5 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), - }); + }; } diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts deleted file mode 100644 index f513459c..00000000 --- a/src/main/runtime/app-runtime-main-deps.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps'; - -test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => { - const calls: string[] = []; - const deps = createBuildEnsureTrayMainDepsHandler({ - getTray: () => null, - setTray: () => calls.push('set-tray'), - buildTrayMenu: () => ({}), - resolveTrayIconPath: () => null, - createImageFromPath: () => ({}), - createEmptyImage: () => ({}), - createTray: () => ({}), - trayTooltip: 'SubMiner', - platform: 'darwin', - logWarn: (message) => calls.push(`warn:${message}`), - initializeOverlayRuntime: () => calls.push('init-overlay'), - isOverlayRuntimeInitialized: () => false, - setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), - })(); - - deps.ensureOverlayVisibleFromTrayClick(); - assert.deepEqual(calls, ['init-overlay', 'set-visible:true']); -}); diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts deleted file mode 100644 index dd152aca..00000000 --- a/src/main/runtime/app-runtime-main-deps.ts +++ /dev/null @@ -1,103 +0,0 @@ -export function createBuildEnsureTrayMainDepsHandler(deps: { - getTray: () => TTray | null; - setTray: (tray: TTray | null) => void; - buildTrayMenu: () => TTrayMenu; - resolveTrayIconPath: () => string | null; - createImageFromPath: (iconPath: string) => TTrayIcon; - createEmptyImage: () => TTrayIcon; - createTray: (icon: TTrayIcon) => TTray; - trayTooltip: string; - platform: string; - logWarn: (message: string) => void; - initializeOverlayRuntime: () => void; - isOverlayRuntimeInitialized: () => boolean; - setVisibleOverlayVisible: (visible: boolean) => void; -}) { - return () => ({ - getTray: () => deps.getTray(), - setTray: (tray: TTray | null) => deps.setTray(tray), - buildTrayMenu: () => deps.buildTrayMenu(), - resolveTrayIconPath: () => deps.resolveTrayIconPath(), - createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath), - createEmptyImage: () => deps.createEmptyImage(), - createTray: (icon: TTrayIcon) => deps.createTray(icon), - trayTooltip: deps.trayTooltip, - platform: deps.platform, - logWarn: (message: string) => deps.logWarn(message), - ensureOverlayVisibleFromTrayClick: () => { - if (!deps.isOverlayRuntimeInitialized()) { - deps.initializeOverlayRuntime(); - } - deps.setVisibleOverlayVisible(true); - }, - }); -} - -export function createBuildDestroyTrayMainDepsHandler(deps: { - getTray: () => TTray | null; - setTray: (tray: TTray | null) => void; -}) { - return () => ({ - getTray: () => deps.getTray(), - setTray: (tray: TTray | null) => deps.setTray(tray), - }); -} - -export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: { - isOverlayRuntimeInitialized: () => boolean; - initializeOverlayRuntimeCore: (options: TOptions) => void; - buildOptions: () => TOptions; - setOverlayRuntimeInitialized: (initialized: boolean) => void; - startBackgroundWarmups: () => void; -}) { - return () => ({ - isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), - initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options), - buildOptions: () => deps.buildOptions(), - setOverlayRuntimeInitialized: (initialized: boolean) => - deps.setOverlayRuntimeInitialized(initialized), - startBackgroundWarmups: () => deps.startBackgroundWarmups(), - }); -} - -export function createBuildOpenYomitanSettingsMainDepsHandler(deps: { - ensureYomitanExtensionLoaded: () => Promise; - getYomitanExtension?: () => TYomitanExt | null; - getYomitanExtensionLoadInFlight?: () => Promise | null; - openYomitanSettingsWindow: (params: { - yomitanExt: TYomitanExt; - getExistingWindow: () => TWindow | null; - setWindow: (window: TWindow | null) => void; - yomitanSession?: unknown | null; - onWindowClosed?: () => void; - }) => void; - getExistingWindow: () => TWindow | null; - setWindow: (window: TWindow | null) => void; - getYomitanSession?: () => unknown | null; - logWarn: (message: string) => void; - logError: (message: string, error: unknown) => void; -}) { - return () => ({ - ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(), - ...(deps.getYomitanExtension - ? { getYomitanExtension: () => deps.getYomitanExtension?.() ?? null } - : {}), - ...(deps.getYomitanExtensionLoadInFlight - ? { - getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null, - } - : {}), - openYomitanSettingsWindow: (params: { - yomitanExt: TYomitanExt; - getExistingWindow: () => TWindow | null; - setWindow: (window: TWindow | null) => void; - yomitanSession?: unknown | null; - onWindowClosed?: () => void; - }) => deps.openYomitanSettingsWindow(params), - getExistingWindow: () => deps.getExistingWindow(), - setWindow: (window: TWindow | null) => deps.setWindow(window), - getYomitanSession: () => deps.getYomitanSession?.() ?? null, - logWarn: (message: string) => deps.logWarn(message), - logError: (message: string, error: unknown) => deps.logError(message, error), - }); -} diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts deleted file mode 100644 index 1de16bf2..00000000 --- a/src/main/runtime/cli-command-context-deps.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { CliArgs } from '../../cli/args'; -import type { CliCommandContextFactoryDeps } from './cli-command-context'; - -export function createBuildCliCommandContextDepsHandler(deps: { - setLogLevel?: (level: NonNullable) => void; - getSocketPath: () => string; - setSocketPath: (socketPath: string) => void; - getMpvClient: CliCommandContextFactoryDeps['getMpvClient']; - showOsd: (text: string) => void; - showPlaybackFeedback?: (text: string) => void; - texthookerService: CliCommandContextFactoryDeps['texthookerService']; - getTexthookerPort: () => number; - setTexthookerPort: (port: number) => void; - getTexthookerWebsocketUrl: () => string | undefined; - shouldOpenBrowser: () => boolean; - openExternal: (url: string) => Promise; - logBrowserOpenError: (url: string, error: unknown) => void; - isOverlayInitialized: () => boolean; - initializeOverlay: () => void; - toggleVisibleOverlay: () => void; - togglePrimarySubtitleBar: () => void; - openFirstRunSetup: (force?: boolean) => void; - setVisibleOverlay: (visible: boolean) => void; - copyCurrentSubtitle: () => void; - startPendingMultiCopy: (timeoutMs: number) => void; - mineSentenceCard: () => Promise; - startPendingMineSentenceMultiple: (timeoutMs: number) => void; - updateLastCardFromClipboard: () => Promise; - refreshKnownWordCache: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsyncFromConfig: () => Promise; - markLastCardAsAudioCard: () => Promise; - dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; - getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; - clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; - openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; - openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup']; - getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; - retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; - generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; - getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; - setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; - runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; - runJellyfinCommand: (args: CliArgs) => Promise; - runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; - runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand']; - runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; - ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; - openYomitanSettings: () => void; - openHachidoriSettings: () => void; - openConfigSettingsWindow: () => void; - openSyncUiWindow: () => void; - openYoutubeBrowserWindow: () => void; - cycleSecondarySubMode: () => void; - openRuntimeOptionsPalette: () => void; - printHelp: () => void; - stopApp: () => void; - hasMainWindow: () => boolean; - getMultiCopyTimeoutMs: () => number; - schedule: (fn: () => void, delayMs: number) => ReturnType; - logInfo: (message: string) => void; - logDebug: (message: string) => void; - logWarn: (message: string) => void; - logError: (message: string, err: unknown) => void; -}) { - return (): CliCommandContextFactoryDeps => ({ - setLogLevel: deps.setLogLevel, - getSocketPath: deps.getSocketPath, - setSocketPath: deps.setSocketPath, - getMpvClient: deps.getMpvClient, - showOsd: deps.showOsd, - showPlaybackFeedback: deps.showPlaybackFeedback, - texthookerService: deps.texthookerService, - getTexthookerPort: deps.getTexthookerPort, - setTexthookerPort: deps.setTexthookerPort, - getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl, - shouldOpenBrowser: deps.shouldOpenBrowser, - openExternal: deps.openExternal, - logBrowserOpenError: deps.logBrowserOpenError, - isOverlayInitialized: deps.isOverlayInitialized, - initializeOverlay: deps.initializeOverlay, - toggleVisibleOverlay: deps.toggleVisibleOverlay, - togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar, - openFirstRunSetup: deps.openFirstRunSetup, - setVisibleOverlay: deps.setVisibleOverlay, - copyCurrentSubtitle: deps.copyCurrentSubtitle, - startPendingMultiCopy: deps.startPendingMultiCopy, - mineSentenceCard: deps.mineSentenceCard, - startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple, - updateLastCardFromClipboard: deps.updateLastCardFromClipboard, - refreshKnownWordCache: deps.refreshKnownWordCache, - triggerFieldGrouping: deps.triggerFieldGrouping, - triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, - markLastCardAsAudioCard: deps.markLastCardAsAudioCard, - dispatchSessionAction: deps.dispatchSessionAction, - getAnilistStatus: deps.getAnilistStatus, - clearAnilistToken: deps.clearAnilistToken, - openAnilistSetup: deps.openAnilistSetup, - openJellyfinSetup: deps.openJellyfinSetup, - getAnilistQueueStatus: deps.getAnilistQueueStatus, - retryAnilistQueueNow: deps.retryAnilistQueueNow, - generateCharacterDictionary: deps.generateCharacterDictionary, - getCharacterDictionarySelection: deps.getCharacterDictionarySelection, - setCharacterDictionarySelection: deps.setCharacterDictionarySelection, - runStatsCommand: deps.runStatsCommand, - runJellyfinCommand: deps.runJellyfinCommand, - runUpdateCommand: deps.runUpdateCommand, - runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand, - runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, - ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, - openYomitanSettings: deps.openYomitanSettings, - openHachidoriSettings: deps.openHachidoriSettings, - openConfigSettingsWindow: deps.openConfigSettingsWindow, - openSyncUiWindow: deps.openSyncUiWindow, - openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow, - cycleSecondarySubMode: deps.cycleSecondarySubMode, - openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, - printHelp: deps.printHelp, - stopApp: deps.stopApp, - hasMainWindow: deps.hasMainWindow, - getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, - schedule: deps.schedule, - logInfo: deps.logInfo, - logDebug: deps.logDebug, - logWarn: deps.logWarn, - logError: deps.logError, - }); -} diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-factory.test.ts similarity index 86% rename from src/main/runtime/cli-command-context-main-deps.test.ts rename to src/main/runtime/cli-command-context-factory.test.ts index 0d52a0ce..4360d431 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -1,13 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; +import { + createCliCommandContextFactory, + type CliCommandContextMainDeps, +} from './cli-command-context-factory'; -type MainDeps = Parameters[0]; - -function buildContextDeps(overrides: Partial = {}) { +function buildContext(overrides: Partial = {}) { const noop = () => {}; const noopAsync = async () => {}; - return createBuildCliCommandContextMainDepsHandler({ + return createCliCommandContextFactory({ appState: { mpvSocketPath: '/tmp/mpv.sock', mpvClient: null, @@ -100,16 +101,16 @@ test('cli command context setSocketPath notifies only when the socket path chang texthookerPort: 5174, overlayRuntimeInitialized: false, }; - const deps = buildContextDeps({ + const context = buildContext({ appState, onMpvSocketPathChanged: (next, previous) => changes.push(`${previous}->${next}`), }); - deps.setSocketPath('/tmp/next.sock'); - deps.setSocketPath('/tmp/next.sock'); + context.setSocketPath('/tmp/next.sock'); + context.setSocketPath('/tmp/next.sock'); assert.equal(appState.mpvSocketPath, '/tmp/next.sock'); - assert.equal(deps.getSocketPath(), '/tmp/next.sock'); + assert.equal(context.getSocketPath(), '/tmp/next.sock'); assert.deepEqual(changes, ['/tmp/mpv.sock->/tmp/next.sock']); }); @@ -119,10 +120,10 @@ for (const c of [ { openBrowser: false, expected: false }, ]) { test(`cli command context shouldOpenBrowser is ${c.expected} when texthooker.openBrowser is ${c.openBrowser}`, () => { - const deps = buildContextDeps({ + const context = buildContext({ getResolvedConfig: () => ({ texthooker: { openBrowser: c.openBrowser } }), }); - assert.equal(deps.shouldOpenBrowser(), c.expected); + assert.equal(context.shouldOpenBrowser(), c.expected); }); } @@ -147,10 +148,10 @@ for (const c of [ }, ]) { test(`cli command context texthooker websocket url ${c.name}`, () => { - const deps = buildContextDeps({ + const context = buildContext({ getResolvedConfig: () => c.config, hasMpvWebsocketPlugin: () => c.hasPlugin, }); - assert.equal(deps.getTexthookerWebsocketUrl(), c.expected); + assert.equal(context.getTexthookerWebsocketUrl(), c.expected); }); } diff --git a/src/main/runtime/cli-command-context-factory.ts b/src/main/runtime/cli-command-context-factory.ts index de62ff6c..87c43151 100644 --- a/src/main/runtime/cli-command-context-factory.ts +++ b/src/main/runtime/cli-command-context-factory.ts @@ -1,14 +1,171 @@ -import { createCliCommandContext } from './cli-command-context'; -import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps'; -import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; +import type { CliArgs, CliCommandSource } from '../../cli/args'; +import { resolveTexthookerWebsocketUrl } from '../../core/services/startup'; +import { createCliCommandContext, type CliCommandContextFactoryDeps } from './cli-command-context'; -type CliCommandContextMainDeps = Parameters[0]; +type CliCommandContextMainState = { + mpvSocketPath: string; + mpvClient: ReturnType; + texthookerPort: number; + overlayRuntimeInitialized: boolean; +}; +export type CliCommandContextMainDeps = { + appState: CliCommandContextMainState; + setLogLevel?: (level: NonNullable) => void; + onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void; + texthookerService: CliCommandContextFactoryDeps['texthookerService']; + getResolvedConfig: () => { + texthooker?: { openBrowser?: boolean }; + websocket?: { enabled?: boolean | 'auto'; port?: number }; + annotationWebsocket?: { enabled?: boolean; port?: number }; + }; + defaultWebsocketPort: number; + defaultAnnotationWebsocketPort: number; + hasMpvWebsocketPlugin: () => boolean; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + showMpvOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; + + initializeOverlayRuntime: () => void; + toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; + openFirstRunSetupWindow: (force?: boolean) => void; + setVisibleOverlayVisible: (visible: boolean) => void; + + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + refreshKnownWordCache: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; + + getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; + clearAnilistToken: () => void; + openAnilistSetupWindow: () => void; + openJellyfinSetupWindow: () => void; + getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; + processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; + generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; + runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; + runJellyfinCommand: (args: CliArgs) => Promise; + runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; + runEnsureLinuxRuntimePluginAssetsCommand: ( + args: CliArgs, + source: CliCommandSource, + ) => Promise; + runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; + ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; + + openYomitanSettings: () => void; + openHachidoriSettings: () => void; + openConfigSettingsWindow: () => void; + openSyncUiWindow: () => void; + openYoutubeBrowserWindow: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logDebug: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}; + +// Adapts main-process state and services to the CLI command context. The context deps are +// built once; each call creates a fresh context over them. export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) { - const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps); - const cliCommandContextMainDeps = buildCliCommandContextMainDepsHandler(); - const buildCliCommandContextDepsHandler = - createBuildCliCommandContextDepsHandler(cliCommandContextMainDeps); + const contextDeps: CliCommandContextFactoryDeps = { + setLogLevel: deps.setLogLevel, + getSocketPath: () => deps.appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + const previousSocketPath = deps.appState.mpvSocketPath; + deps.appState.mpvSocketPath = socketPath; + if (socketPath !== previousSocketPath) { + deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath); + } + }, + getMpvClient: () => deps.appState.mpvClient, + showOsd: (text: string) => deps.showMpvOsd(text), + showPlaybackFeedback: deps.showPlaybackFeedback, + texthookerService: deps.texthookerService, + getTexthookerPort: () => deps.appState.texthookerPort, + setTexthookerPort: (port: number) => { + deps.appState.texthookerPort = port; + }, + getTexthookerWebsocketUrl: () => + resolveTexthookerWebsocketUrl( + deps.getResolvedConfig(), + { + defaultWebsocketPort: deps.defaultWebsocketPort, + defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort, + }, + deps.hasMpvWebsocketPlugin(), + ), + shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, + openExternal: (url: string) => deps.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), + isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, + initializeOverlay: () => deps.initializeOverlayRuntime(), + toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), + togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(), + openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force), + setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), + copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => deps.mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + deps.startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), + refreshKnownWordCache: () => deps.refreshKnownWordCache(), + triggerFieldGrouping: () => deps.triggerFieldGrouping(), + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), + dispatchSessionAction: (request) => deps.dispatchSessionAction(request), + getAnilistStatus: () => deps.getAnilistStatus(), + clearAnilistToken: () => deps.clearAnilistToken(), + openAnilistSetup: () => deps.openAnilistSetupWindow(), + openJellyfinSetup: () => deps.openJellyfinSetupWindow(), + getAnilistQueueStatus: () => deps.getAnilistQueueStatus(), + retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), + generateCharacterDictionary: (targetPath?: string) => + deps.generateCharacterDictionary(targetPath), + getCharacterDictionarySelection: deps.getCharacterDictionarySelection, + setCharacterDictionarySelection: deps.setCharacterDictionarySelection, + runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), + runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), + runUpdateCommand: (args: CliArgs, source: CliCommandSource) => + deps.runUpdateCommand(args, source), + runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) => + deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source), + runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), + ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, + openYomitanSettings: () => deps.openYomitanSettings(), + openHachidoriSettings: () => deps.openHachidoriSettings(), + openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), + openSyncUiWindow: () => deps.openSyncUiWindow(), + openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(), + cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + printHelp: () => deps.printHelp(), + stopApp: () => deps.stopApp(), + hasMainWindow: () => deps.hasMainWindow(), + getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), + schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), + logInfo: (message: string) => deps.logInfo(message), + logDebug: (message: string) => deps.logDebug(message), + logWarn: (message: string) => deps.logWarn(message), + logError: (message: string, err: unknown) => deps.logError(message, err), + }; - return () => createCliCommandContext(buildCliCommandContextDepsHandler()); + return () => createCliCommandContext(contextDeps); } diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts deleted file mode 100644 index bd01174e..00000000 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { CliArgs, CliCommandSource } from '../../cli/args'; -import { resolveTexthookerWebsocketUrl } from '../../core/services/startup'; -import type { CliCommandContextFactoryDeps } from './cli-command-context'; - -type CliCommandContextMainState = { - mpvSocketPath: string; - mpvClient: ReturnType; - texthookerPort: number; - overlayRuntimeInitialized: boolean; -}; - -export function createBuildCliCommandContextMainDepsHandler(deps: { - appState: CliCommandContextMainState; - setLogLevel?: (level: NonNullable) => void; - onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void; - texthookerService: CliCommandContextFactoryDeps['texthookerService']; - getResolvedConfig: () => { - texthooker?: { openBrowser?: boolean }; - websocket?: { enabled?: boolean | 'auto'; port?: number }; - annotationWebsocket?: { enabled?: boolean; port?: number }; - }; - defaultWebsocketPort: number; - defaultAnnotationWebsocketPort: number; - hasMpvWebsocketPlugin: () => boolean; - openExternal: (url: string) => Promise; - logBrowserOpenError: (url: string, error: unknown) => void; - showMpvOsd: (text: string) => void; - showPlaybackFeedback?: (text: string) => void; - - initializeOverlayRuntime: () => void; - toggleVisibleOverlay: () => void; - togglePrimarySubtitleBar: () => void; - openFirstRunSetupWindow: (force?: boolean) => void; - setVisibleOverlayVisible: (visible: boolean) => void; - - copyCurrentSubtitle: () => void; - startPendingMultiCopy: (timeoutMs: number) => void; - mineSentenceCard: () => Promise; - startPendingMineSentenceMultiple: (timeoutMs: number) => void; - updateLastCardFromClipboard: () => Promise; - refreshKnownWordCache: () => Promise; - triggerFieldGrouping: () => Promise; - triggerSubsyncFromConfig: () => Promise; - markLastCardAsAudioCard: () => Promise; - dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; - - getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; - clearAnilistToken: () => void; - openAnilistSetupWindow: () => void; - openJellyfinSetupWindow: () => void; - getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; - processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; - generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; - getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; - setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; - runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; - runJellyfinCommand: (args: CliArgs) => Promise; - runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; - runEnsureLinuxRuntimePluginAssetsCommand: ( - args: CliArgs, - source: CliCommandSource, - ) => Promise; - runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; - ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; - - openYomitanSettings: () => void; - openHachidoriSettings: () => void; - openConfigSettingsWindow: () => void; - openSyncUiWindow: () => void; - openYoutubeBrowserWindow: () => void; - cycleSecondarySubMode: () => void; - openRuntimeOptionsPalette: () => void; - printHelp: () => void; - stopApp: () => void; - hasMainWindow: () => boolean; - getMultiCopyTimeoutMs: () => number; - schedule: (fn: () => void, delayMs: number) => ReturnType; - logInfo: (message: string) => void; - logDebug: (message: string) => void; - logWarn: (message: string) => void; - logError: (message: string, err: unknown) => void; -}) { - return (): CliCommandContextFactoryDeps => ({ - setLogLevel: deps.setLogLevel, - getSocketPath: () => deps.appState.mpvSocketPath, - setSocketPath: (socketPath: string) => { - const previousSocketPath = deps.appState.mpvSocketPath; - deps.appState.mpvSocketPath = socketPath; - if (socketPath !== previousSocketPath) { - deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath); - } - }, - getMpvClient: () => deps.appState.mpvClient, - showOsd: (text: string) => deps.showMpvOsd(text), - showPlaybackFeedback: deps.showPlaybackFeedback, - texthookerService: deps.texthookerService, - getTexthookerPort: () => deps.appState.texthookerPort, - setTexthookerPort: (port: number) => { - deps.appState.texthookerPort = port; - }, - getTexthookerWebsocketUrl: () => - resolveTexthookerWebsocketUrl( - deps.getResolvedConfig(), - { - defaultWebsocketPort: deps.defaultWebsocketPort, - defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort, - }, - deps.hasMpvWebsocketPlugin(), - ), - shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false, - openExternal: (url: string) => deps.openExternal(url), - logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error), - isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized, - initializeOverlay: () => deps.initializeOverlayRuntime(), - toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), - togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(), - openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force), - setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), - copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => deps.mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - deps.startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), - refreshKnownWordCache: () => deps.refreshKnownWordCache(), - triggerFieldGrouping: () => deps.triggerFieldGrouping(), - triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), - dispatchSessionAction: (request) => deps.dispatchSessionAction(request), - getAnilistStatus: () => deps.getAnilistStatus(), - clearAnilistToken: () => deps.clearAnilistToken(), - openAnilistSetup: () => deps.openAnilistSetupWindow(), - openJellyfinSetup: () => deps.openJellyfinSetupWindow(), - getAnilistQueueStatus: () => deps.getAnilistQueueStatus(), - retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), - generateCharacterDictionary: (targetPath?: string) => - deps.generateCharacterDictionary(targetPath), - getCharacterDictionarySelection: deps.getCharacterDictionarySelection, - setCharacterDictionarySelection: deps.setCharacterDictionarySelection, - runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), - runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), - runUpdateCommand: (args: CliArgs, source: CliCommandSource) => - deps.runUpdateCommand(args, source), - runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) => - deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source), - runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), - ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, - openYomitanSettings: () => deps.openYomitanSettings(), - openHachidoriSettings: () => deps.openHachidoriSettings(), - openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), - openSyncUiWindow: () => deps.openSyncUiWindow(), - openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(), - cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), - printHelp: () => deps.printHelp(), - stopApp: () => deps.stopApp(), - hasMainWindow: () => deps.hasMainWindow(), - getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), - schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), - logInfo: (message: string) => deps.logInfo(message), - logDebug: (message: string) => deps.logDebug(message), - logWarn: (message: string) => deps.logWarn(message), - logError: (message: string, err: unknown) => deps.logError(message, err), - }); -} diff --git a/src/main/runtime/composers/contracts.ts b/src/main/runtime/composers/contracts.ts index 354b1702..d8528ab5 100644 --- a/src/main/runtime/composers/contracts.ts +++ b/src/main/runtime/composers/contracts.ts @@ -3,11 +3,3 @@ type ComposerShape = Record; export type ComposerInputs = Readonly>; export type ComposerOutputs = Readonly; - -export type BuiltMainDeps = TFactory extends ( - ...args: infer _TFactoryArgs -) => infer TBuilder - ? TBuilder extends (...args: infer _TBuilderArgs) => infer TDeps - ? TDeps - : never - : never; diff --git a/src/main/runtime/composers/jellyfin-remote-composer.ts b/src/main/runtime/composers/jellyfin-remote-composer.ts index 51def58d..9e9a0783 100644 --- a/src/main/runtime/composers/jellyfin-remote-composer.ts +++ b/src/main/runtime/composers/jellyfin-remote-composer.ts @@ -1,15 +1,14 @@ import { - createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler, - createBuildHandleJellyfinRemotePlayMainDepsHandler, - createBuildHandleJellyfinRemotePlaystateMainDepsHandler, - createBuildReportJellyfinRemoteProgressMainDepsHandler, - createBuildReportJellyfinRemoteStoppedMainDepsHandler, createHandleJellyfinRemoteGeneralCommand, createHandleJellyfinRemotePlay, createHandleJellyfinRemotePlaystate, createJellyfinRemoteReportTracker, createReportJellyfinRemoteProgressHandler, createReportJellyfinRemoteStoppedHandler, + type JellyfinRemoteGeneralCommandHandlerDeps, + type JellyfinRemotePlayHandlerDeps, + type JellyfinRemotePlaystateHandlerDeps, + type JellyfinRemoteProgressReporterDeps, } from '../domains/jellyfin'; import type { ComposerInputs, ComposerOutputs } from './contracts'; @@ -18,44 +17,24 @@ type RemotePlaystatePayload = Parameters >[0]; -type JellyfinRemotePlayMainDeps = Parameters< - typeof createBuildHandleJellyfinRemotePlayMainDepsHandler ->[0]; -type JellyfinRemotePlaystateMainDeps = Parameters< - typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler ->[0]; -type JellyfinRemoteGeneralMainDeps = Parameters< - typeof createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler ->[0]; -type JellyfinRemoteProgressMainDeps = Parameters< - typeof createBuildReportJellyfinRemoteProgressMainDepsHandler ->[0]; export type JellyfinRemoteComposerOptions = ComposerInputs<{ - getConfiguredSession: JellyfinRemotePlayMainDeps['getConfiguredSession']; - getClientInfo: JellyfinRemotePlayMainDeps['getClientInfo']; - getJellyfinConfig: JellyfinRemotePlayMainDeps['getJellyfinConfig']; - playJellyfinItem: JellyfinRemotePlayMainDeps['playJellyfinItem']; - logWarn: JellyfinRemotePlayMainDeps['logWarn']; - getMpvClient: JellyfinRemoteProgressMainDeps['getMpvClient']; - sendMpvCommand: JellyfinRemotePlaystateMainDeps['sendMpvCommand']; - jellyfinTicksToSeconds: Parameters< - typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler - >[0]['jellyfinTicksToSeconds']; - getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback']; - clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback']; - getNow: JellyfinRemoteProgressMainDeps['getNow']; - getLastProgressAtMs: Parameters< - typeof createBuildReportJellyfinRemoteProgressMainDepsHandler - >[0]['getLastProgressAtMs']; - setLastProgressAtMs: Parameters< - typeof createBuildReportJellyfinRemoteProgressMainDepsHandler - >[0]['setLastProgressAtMs']; + getConfiguredSession: JellyfinRemotePlayHandlerDeps['getConfiguredSession']; + getClientInfo: JellyfinRemotePlayHandlerDeps['getClientInfo']; + getJellyfinConfig: JellyfinRemotePlayHandlerDeps['getJellyfinConfig']; + playJellyfinItem: JellyfinRemotePlayHandlerDeps['playJellyfinItem']; + logWarn: JellyfinRemotePlayHandlerDeps['logWarn']; + getMpvClient: JellyfinRemoteProgressReporterDeps['getMpvClient']; + sendMpvCommand: JellyfinRemotePlaystateHandlerDeps['sendMpvCommand']; + jellyfinTicksToSeconds: JellyfinRemotePlaystateHandlerDeps['jellyfinTicksToSeconds']; + getActivePlayback: JellyfinRemoteGeneralCommandHandlerDeps['getActivePlayback']; + clearActivePlayback: JellyfinRemoteProgressReporterDeps['clearActivePlayback']; + getNow: JellyfinRemoteProgressReporterDeps['getNow']; + getLastProgressAtMs: JellyfinRemoteProgressReporterDeps['getLastProgressAtMs']; + setLastProgressAtMs: JellyfinRemoteProgressReporterDeps['setLastProgressAtMs']; progressIntervalMs: number; ticksPerSecond: number; - logDebug: Parameters< - typeof createBuildReportJellyfinRemoteProgressMainDepsHandler - >[0]['logDebug']; + logDebug: JellyfinRemoteProgressReporterDeps['logDebug']; }>; export type JellyfinRemoteComposerResult = ComposerOutputs<{ @@ -69,75 +48,54 @@ export type JellyfinRemoteComposerResult = ComposerOutputs<{ export function composeJellyfinRemoteHandlers( options: JellyfinRemoteComposerOptions, ): JellyfinRemoteComposerResult { - const buildReportJellyfinRemoteProgressMainDepsHandler = - createBuildReportJellyfinRemoteProgressMainDepsHandler({ - getActivePlayback: options.getActivePlayback, - clearActivePlayback: options.clearActivePlayback, - getMpvClient: options.getMpvClient, - getNow: options.getNow, - getLastProgressAtMs: options.getLastProgressAtMs, - setLastProgressAtMs: options.setLastProgressAtMs, - progressIntervalMs: options.progressIntervalMs, - ticksPerSecond: options.ticksPerSecond, - logDebug: options.logDebug, - }); - const buildReportJellyfinRemoteStoppedMainDepsHandler = - createBuildReportJellyfinRemoteStoppedMainDepsHandler({ - getActivePlayback: options.getActivePlayback, - clearActivePlayback: options.clearActivePlayback, - getMpvClient: options.getMpvClient, - getNow: options.getNow, - ticksPerSecond: options.ticksPerSecond, - logDebug: options.logDebug, - logWarn: options.logWarn, - }); const reportTracker = createJellyfinRemoteReportTracker(); const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({ - ...buildReportJellyfinRemoteProgressMainDepsHandler(), + getActivePlayback: options.getActivePlayback, + clearActivePlayback: options.clearActivePlayback, + getMpvClient: options.getMpvClient, + getNow: options.getNow, + getLastProgressAtMs: options.getLastProgressAtMs, + setLastProgressAtMs: options.setLastProgressAtMs, + progressIntervalMs: options.progressIntervalMs, + ticksPerSecond: options.ticksPerSecond, + logDebug: options.logDebug, reportTracker, }); const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({ - ...buildReportJellyfinRemoteStoppedMainDepsHandler(), + getActivePlayback: options.getActivePlayback, + clearActivePlayback: options.clearActivePlayback, + getMpvClient: options.getMpvClient, + getNow: options.getNow, + ticksPerSecond: options.ticksPerSecond, + logDebug: options.logDebug, + logWarn: options.logWarn, reportTracker, }); - const buildHandleJellyfinRemotePlayMainDepsHandler = - createBuildHandleJellyfinRemotePlayMainDepsHandler({ + return { + reportJellyfinRemoteProgress, + reportJellyfinRemoteStopped, + handleJellyfinRemotePlay: createHandleJellyfinRemotePlay({ getConfiguredSession: options.getConfiguredSession, getClientInfo: options.getClientInfo, getJellyfinConfig: options.getJellyfinConfig, getActivePlayback: options.getActivePlayback, playJellyfinItem: options.playJellyfinItem, logWarn: options.logWarn, - }); - const buildHandleJellyfinRemotePlaystateMainDepsHandler = - createBuildHandleJellyfinRemotePlaystateMainDepsHandler({ + }), + handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate({ getMpvClient: options.getMpvClient, sendMpvCommand: options.sendMpvCommand, reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(), jellyfinTicksToSeconds: options.jellyfinTicksToSeconds, - }); - const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler = - createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({ + }), + handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand({ getMpvClient: options.getMpvClient, sendMpvCommand: options.sendMpvCommand, getActivePlayback: options.getActivePlayback, reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force), logDebug: (message) => options.logDebug(message, undefined), - }); - - return { - reportJellyfinRemoteProgress, - reportJellyfinRemoteStopped, - handleJellyfinRemotePlay: createHandleJellyfinRemotePlay( - buildHandleJellyfinRemotePlayMainDepsHandler(), - ), - handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate( - buildHandleJellyfinRemotePlaystateMainDepsHandler(), - ), - handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand( - buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(), - ), + }), }; } diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts index 59eb64c9..d084ee44 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -1,9 +1,6 @@ import { buildJellyfinSetupFormHtml, createEnsureMpvConnectedForJellyfinPlaybackHandler, - createBuildGetJellyfinClientInfoMainDepsHandler, - createBuildGetResolvedJellyfinConfigMainDepsHandler, - createBuildOpenJellyfinSetupWindowMainDepsHandler, createGetJellyfinClientInfoHandler, createGetResolvedJellyfinConfigHandler, createHandleJellyfinAuthCommands, @@ -35,17 +32,11 @@ type HandlePlayCommandMainDeps = Parameters[0]; type StartRemoteSessionMainDeps = Parameters[0]; type RunJellyfinCommandMainDeps = Parameters[0]; -type OpenJellyfinSetupWindowMainDeps = Parameters< - typeof createBuildOpenJellyfinSetupWindowMainDepsHandler ->[0]; +type OpenJellyfinSetupWindowMainDeps = Parameters[0]; export type JellyfinRuntimeComposerOptions = ComposerInputs<{ - getResolvedJellyfinConfigMainDeps: Parameters< - typeof createBuildGetResolvedJellyfinConfigMainDepsHandler - >[0]; - getJellyfinClientInfoMainDeps: Parameters< - typeof createBuildGetJellyfinClientInfoMainDepsHandler - >[0]; + getResolvedJellyfinConfigMainDeps: Parameters[0]; + getJellyfinClientInfoMainDeps: Parameters[0]; waitForMpvConnectedMainDeps: Parameters[0]; launchMpvIdleForJellyfinPlaybackMainDeps: Parameters< typeof createLaunchMpvIdleForJellyfinPlaybackHandler @@ -141,12 +132,10 @@ export function composeJellyfinRuntimeHandlers( options: JellyfinRuntimeComposerOptions, ): JellyfinRuntimeComposerResult { const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler( - createBuildGetResolvedJellyfinConfigMainDepsHandler( - options.getResolvedJellyfinConfigMainDeps, - )(), + options.getResolvedJellyfinConfigMainDeps, ); const getJellyfinClientInfo = createGetJellyfinClientInfoHandler( - createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(), + options.getJellyfinClientInfoMainDeps, ); const waitForMpvConnected = createWaitForMpvConnectedHandler(options.waitForMpvConnectedMainDeps); @@ -233,16 +222,14 @@ export function composeJellyfinRuntimeHandlers( getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(), startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions), }); - const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler( - createBuildOpenJellyfinSetupWindowMainDepsHandler({ - ...options.openJellyfinSetupWindowMainDeps, - maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow, - getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), - getJellyfinClientInfo: () => getJellyfinClientInfo(), - restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(), - stopRemoteSession: () => stopJellyfinRemoteSession(), - })(), - ); + const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler({ + ...options.openJellyfinSetupWindowMainDeps, + maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow, + getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), + getJellyfinClientInfo: () => getJellyfinClientInfo(), + restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(), + stopRemoteSession: () => stopJellyfinRemoteSession(), + }); return { getResolvedJellyfinConfig, diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index c42e9718..341abcfe 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -423,3 +423,63 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups assert.equal(jlptWarmupCalls, 1); assert.equal(frequencyWarmupCalls, 1); }); + +type TokenizerMainDeps = Parameters< + typeof composeMpvRuntimeHandlers +>[0]['tokenizer']['buildTokenizerDepsMainDeps']; +type TokenizerRuntimeOptions = Parameters< + Parameters[0]['tokenizer']['createTokenizerRuntimeDeps'] +>[0]; + +async function captureTokenizerRuntimeOptions( + overrides: Partial, +): Promise { + const fixture = createDefaultMpvFixture(); + let captured: TokenizerRuntimeOptions | null = null; + const composed = composeMpvRuntimeHandlers< + { connect: () => void; on: () => void }, + TokenizerRuntimeOptions, + { text: string } + >({ + ...fixture, + tokenizer: { + ...fixture.tokenizer, + buildTokenizerDepsMainDeps: { ...fixture.tokenizer.buildTokenizerDepsMainDeps, ...overrides }, + createTokenizerRuntimeDeps: (deps) => { + captured = deps; + return deps; + }, + tokenizeSubtitle: async (text) => ({ text }), + }, + }); + await composed.tokenizeSubtitle('line'); + assert.ok(captured); + return captured; +} + +test('composeMpvRuntimeHandlers records every known-word lookup result', async () => { + const lookups: boolean[] = []; + const runtimeOptions = await captureTokenizerRuntimeOptions({ + isKnownWord: (text) => text === 'known', + recordLookup: (hit) => lookups.push(hit), + }); + + assert.equal(runtimeOptions.isKnownWord('known'), true); + assert.equal(runtimeOptions.isKnownWord('unknown'), false); + assert.deepEqual(lookups, [true, false]); +}); + +test('composeMpvRuntimeHandlers disables name matching while the character dictionary is disabled', async () => { + let characterDictionaryEnabled = false; + const runtimeOptions = await captureTokenizerRuntimeOptions({ + getCharacterDictionaryEnabled: () => characterDictionaryEnabled, + getNameMatchEnabled: () => true, + getNameMatchImagesEnabled: () => true, + }); + + assert.equal(runtimeOptions.getNameMatchEnabled?.(), false); + assert.equal(runtimeOptions.getNameMatchImagesEnabled?.(), false); + characterDictionaryEnabled = true; + assert.equal(runtimeOptions.getNameMatchEnabled?.(), true); + assert.equal(runtimeOptions.getNameMatchImagesEnabled?.(), true); +}); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts index 43183cf7..9468ad04 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -1,47 +1,54 @@ import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings'; -import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps'; -import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps'; +import { createMpvMainEventBindingDeps } from '../mpv-main-event-binding-deps'; import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service'; -import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service'; -import type { Config } from '../../../types'; import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics'; import { - createBuildTokenizerDepsMainHandler, - createCreateMecabTokenizerAndCheckMainHandler, - createPrewarmSubtitleDictionariesMainHandler, -} from '../subtitle-tokenization-main-deps'; + createMecabTokenizerAndCheckHandler, + createPrewarmSubtitleDictionariesHandler, +} from '../subtitle-tokenization-warmup'; +import type { TokenizerDepsRuntimeOptions } from '../../../core/services/tokenizer'; import { createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup, createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup, } from '../startup-warmups'; -import type { BuiltMainDeps, ComposerInputs, ComposerOutputs } from './contracts'; +import type { ComposerInputs, ComposerOutputs } from './contracts'; -type BindMpvMainEventHandlersMainDeps = Parameters< - typeof createBuildBindMpvMainEventHandlersMainDepsHandler ->[0]; +type BindMpvMainEventHandlersMainDeps = Parameters[0]; type BindMpvMainEventHandlers = ReturnType; type BoundMpvClient = Parameters[0]; type RuntimeMpvClient = BoundMpvClient & { connect: () => void }; type MpvClientRuntimeServiceFactoryMainDeps = Omit< - Parameters< - typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler< - TMpvClient, - Config, - MpvClientRuntimeServiceOptions - > - >[0], + Parameters>[0], 'bindEventHandlers' >; type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters< typeof createUpdateMpvSubtitleRenderMetricsHandler >[0]; -type BuildTokenizerDepsMainDeps = Parameters[0]; -type TokenizerMainDeps = BuiltMainDeps; +// Tokenizer deps as main.ts supplies them: the core runtime options plus lookup recording and +// the character-dictionary gate applied in buildTokenizerRuntimeOptions. +type BuildTokenizerDepsMainDeps = TokenizerDepsRuntimeOptions & { + getJlptEnabled: NonNullable; + getCharacterDictionaryEnabled?: () => boolean; + getFrequencyDictionaryEnabled: NonNullable< + TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] + >; + getFrequencyDictionaryMatchMode: NonNullable< + TokenizerDepsRuntimeOptions['getFrequencyDictionaryMatchMode'] + >; + getFrequencyRank: NonNullable; + getMinSentenceWordsForNPlusOne: NonNullable< + TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne'] + >; + getYomitanGroupDebugEnabled: NonNullable< + TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled'] + >; + recordLookup: (hit: boolean) => void; +}; type CreateMecabTokenizerAndCheckMainDeps = Parameters< - typeof createCreateMecabTokenizerAndCheckMainHandler + typeof createMecabTokenizerAndCheckHandler >[0]; type PrewarmSubtitleDictionariesMainDeps = Parameters< - typeof createPrewarmSubtitleDictionariesMainHandler + typeof createPrewarmSubtitleDictionariesHandler >[0]; type LaunchBackgroundWarmupTaskMainDeps = Parameters< typeof createLaunchBackgroundWarmupTaskFromStartup @@ -61,7 +68,7 @@ export type MpvRuntimeComposerOptions< updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps; tokenizer: { buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps; - createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps; + createTokenizerRuntimeDeps: (deps: TokenizerDepsRuntimeOptions) => TTokenizerRuntimeDeps; tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise; createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps; prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps; @@ -88,6 +95,39 @@ export type MpvRuntimeComposerResult< startBackgroundWarmups: ReturnType; }>; +// Adapts main's tokenizer deps to the core runtime options: every known-word lookup is +// recorded, and name matching is off while the character dictionary is disabled. Returns a +// fresh object, since the composer wraps onTokenizationReady on it. +function buildTokenizerRuntimeOptions( + deps: BuildTokenizerDepsMainDeps, +): TokenizerDepsRuntimeOptions { + const { + recordLookup, + getCharacterDictionaryEnabled, + getNameMatchEnabled, + getNameMatchImagesEnabled, + ...runtimeOptions + } = deps; + const characterDictionaryEnabled = (): boolean => getCharacterDictionaryEnabled?.() !== false; + return { + ...runtimeOptions, + isKnownWord: (text, reading, options) => { + const hit = deps.isKnownWord(text, reading, options); + recordLookup(hit); + return hit; + }, + ...(getNameMatchEnabled + ? { getNameMatchEnabled: () => characterDictionaryEnabled() && getNameMatchEnabled() } + : {}), + ...(getNameMatchImagesEnabled + ? { + getNameMatchImagesEnabled: () => + characterDictionaryEnabled() && getNameMatchImagesEnabled(), + } + : {}), + }; +} + export function composeMpvRuntimeHandlers< TMpvClient extends RuntimeMpvClient, TTokenizerRuntimeDeps, @@ -95,36 +135,23 @@ export function composeMpvRuntimeHandlers< >( options: MpvRuntimeComposerOptions, ): MpvRuntimeComposerResult { - const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler( - options.bindMpvMainEventHandlersMainDeps, - )(); const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler( - bindMpvMainEventHandlersMainDeps, + createMpvMainEventBindingDeps(options.bindMpvMainEventHandlersMainDeps), ); - const buildMpvClientRuntimeServiceFactoryMainDepsHandler = - createBuildMpvClientRuntimeServiceFactoryDepsHandler< - TMpvClient, - Config, - MpvClientRuntimeServiceOptions - >({ - ...options.mpvClientRuntimeServiceFactoryMainDeps, - bindEventHandlers: (client) => bindMpvClientEventHandlers(client), - }); - const createMpvClientRuntimeService = (): TMpvClient => - createMpvClientRuntimeServiceFactory(buildMpvClientRuntimeServiceFactoryMainDepsHandler())(); + const createMpvClientRuntimeService = createMpvClientRuntimeServiceFactory({ + ...options.mpvClientRuntimeServiceFactoryMainDeps, + bindEventHandlers: (client) => bindMpvClientEventHandlers(client), + }); const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler( options.updateMpvSubtitleRenderMetricsMainDeps, ); - const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler( - options.tokenizer.buildTokenizerDepsMainDeps, - ); - const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler( + const createMecabTokenizerAndCheck = createMecabTokenizerAndCheckHandler( options.tokenizer.createMecabTokenizerAndCheckMainDeps, ); - const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler( + const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesHandler( options.tokenizer.prewarmSubtitleDictionariesMainDeps, ); const shouldInitializeMecabForAnnotations = (): boolean => { @@ -227,7 +254,9 @@ export function composeMpvRuntimeHandlers< if (cachedTokenizerRuntimeDeps) { return cachedTokenizerRuntimeDeps; } - const tokenizerMainDeps = buildTokenizerDepsHandler(); + const tokenizerMainDeps = buildTokenizerRuntimeOptions( + options.tokenizer.buildTokenizerDepsMainDeps, + ); const baseOnTokenizationReady = tokenizerMainDeps.onTokenizationReady; tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => { if (!shouldWarmupAnnotationDictionaries()) { diff --git a/src/main/runtime/composers/startup-lifecycle-composer.ts b/src/main/runtime/composers/startup-lifecycle-composer.ts index adcf5a1c..1bd60993 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.ts @@ -3,22 +3,16 @@ import { createRestoreWindowsOnActivateHandler, createShouldRestoreWindowsOnActivateHandler, } from '../app-lifecycle-actions'; -import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup'; -import { - createBuildRestoreWindowsOnActivateMainDepsHandler, - createBuildShouldRestoreWindowsOnActivateMainDepsHandler, -} from '../app-lifecycle-main-activate'; +import { createOnWillQuitCleanupDeps } from '../app-lifecycle-main-cleanup'; import { registerProtocolUrlHandlers } from '../protocol-url-handlers'; import type { ComposerInputs, ComposerOutputs } from './contracts'; type RegisterProtocolUrlHandlersMainDeps = Parameters[0]; -type OnWillQuitCleanupDeps = Parameters[0]; +type OnWillQuitCleanupDeps = Parameters[0]; type ShouldRestoreWindowsOnActivateMainDeps = Parameters< - typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler ->[0]; -type RestoreWindowsOnActivateMainDeps = Parameters< - typeof createBuildRestoreWindowsOnActivateMainDepsHandler + typeof createShouldRestoreWindowsOnActivateHandler >[0]; +type RestoreWindowsOnActivateMainDeps = Parameters[0]; export type StartupLifecycleComposerOptions = ComposerInputs<{ registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps; @@ -38,15 +32,13 @@ export function composeStartupLifecycleHandlers( options: StartupLifecycleComposerOptions, ): StartupLifecycleComposerResult { const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler( - createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(), + createOnWillQuitCleanupDeps(options.onWillQuitCleanupMainDeps), ); const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler( - createBuildShouldRestoreWindowsOnActivateMainDepsHandler( - options.shouldRestoreWindowsOnActivateMainDeps, - )(), + options.shouldRestoreWindowsOnActivateMainDeps, ); const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler( - createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(), + options.restoreWindowsOnActivateMainDeps, ); return { diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 9d7bcd0a..2f068a43 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -5,6 +5,7 @@ import { buildConfigHotReloadPayload, createConfigHotReloadAppliedHandler, createConfigHotReloadMessageHandler, + createWatchConfigPathHandler, } from './config-hot-reload-handlers'; test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { @@ -328,3 +329,38 @@ test('createConfigHotReloadMessageHandler routes message through configured noti 'notify:SubMiner:Config reload failed', ]); }); + +test('watch config path handler watches file directly when config exists', () => { + const calls: string[] = []; + const watchConfigPath = createWatchConfigPathHandler({ + fileExists: () => true, + dirname: (path) => path.split('/').slice(0, -1).join('/'), + watchPath: (targetPath, nextListener) => { + calls.push(`watch:${targetPath}`); + nextListener('change', 'ignored'); + return { close: () => calls.push('close') }; + }, + }); + + const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); + watcher.close(); + assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']); +}); + +test('watch config path handler filters directory events to config files only', () => { + const calls: string[] = []; + const watchConfigPath = createWatchConfigPathHandler({ + fileExists: () => false, + dirname: (path) => path.split('/').slice(0, -1).join('/'), + watchPath: (_targetPath, nextListener) => { + nextListener('change', 'foo.txt'); + nextListener('change', 'config.json'); + nextListener('change', 'config.jsonc'); + nextListener('change', null); + return { close: () => {} }; + }, + }); + + watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); + assert.deepEqual(calls, ['change', 'change', 'change']); +}); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 9aece9d2..a434b59a 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -228,6 +228,35 @@ export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessage }; } +type ConfigWatchListener = (eventType: string, filename: string | null) => void; + +// Watches the config file, or its directory (filtered to config.json[c]) when the file +// does not exist yet, and calls onChange for relevant events. +export function createWatchConfigPathHandler(deps: { + fileExists: (path: string) => boolean; + dirname: (path: string) => string; + watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void }; +}) { + return (configPath: string, onChange: () => void): { close: () => void } => { + const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath); + const watcher = deps.watchPath(watchTarget, (_eventType, filename) => { + if (watchTarget === configPath) { + onChange(); + return; + } + + const normalized = + typeof filename === 'string' ? filename : filename ? String(filename) : undefined; + if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') { + onChange(); + } + }); + return { + close: () => watcher.close(), + }; + }; +} + export function buildRestartRequiredConfigMessage(fields: string[]): string { return `Config updated; restart required for: ${fields.join(', ')}`; } diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts deleted file mode 100644 index 39512a79..00000000 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createWatchConfigPathHandler } from './config-hot-reload-main-deps'; - -test('watch config path handler watches file directly when config exists', () => { - const calls: string[] = []; - const watchConfigPath = createWatchConfigPathHandler({ - fileExists: () => true, - dirname: (path) => path.split('/').slice(0, -1).join('/'), - watchPath: (targetPath, nextListener) => { - calls.push(`watch:${targetPath}`); - nextListener('change', 'ignored'); - return { close: () => calls.push('close') }; - }, - }); - - const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); - watcher.close(); - assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']); -}); - -test('watch config path handler filters directory events to config files only', () => { - const calls: string[] = []; - const watchConfigPath = createWatchConfigPathHandler({ - fileExists: () => false, - dirname: (path) => path.split('/').slice(0, -1).join('/'), - watchPath: (_targetPath, nextListener) => { - nextListener('change', 'foo.txt'); - nextListener('change', 'config.json'); - nextListener('change', 'config.jsonc'); - nextListener('change', null); - return { close: () => {} }; - }, - }); - - watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); - assert.deepEqual(calls, ['change', 'change', 'change']); -}); diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts deleted file mode 100644 index 0e504408..00000000 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - ConfigHotReloadDiff, - ConfigHotReloadRuntimeDeps, -} from '../../core/services/config-hot-reload'; -import type { ReloadConfigStrictResult } from '../../config'; -import type { AnkiConnectConfig } from '../../types/anki'; -import type { - ConfigHotReloadPayload, - ConfigValidationWarning, - ResolvedConfig, - SecondarySubMode, -} from '../../types'; -import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers'; - -type ConfigWatchListener = (eventType: string, filename: string | null) => void; - -export function createWatchConfigPathHandler(deps: { - fileExists: (path: string) => boolean; - dirname: (path: string) => string; - watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void }; -}) { - return (configPath: string, onChange: () => void): { close: () => void } => { - const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath); - const watcher = deps.watchPath(watchTarget, (_eventType, filename) => { - if (watchTarget === configPath) { - onChange(); - return; - } - - const normalized = - typeof filename === 'string' ? filename : filename ? String(filename) : undefined; - if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') { - onChange(); - } - }); - return { - close: () => watcher.close(), - }; - }; -} - -type WatchConfigPathMainDeps = Parameters[0]; -type ConfigHotReloadMessageMainDeps = Parameters[0]; - -export function createBuildWatchConfigPathMainDepsHandler(deps: WatchConfigPathMainDeps) { - return (): WatchConfigPathMainDeps => ({ - fileExists: (targetPath: string) => deps.fileExists(targetPath), - dirname: (targetPath: string) => deps.dirname(targetPath), - watchPath: (targetPath: string, listener: ConfigWatchListener) => - deps.watchPath(targetPath, listener), - }); -} - -export function createBuildConfigHotReloadMessageMainDepsHandler( - deps: ConfigHotReloadMessageMainDeps, -) { - return (): ConfigHotReloadMessageMainDeps => ({ - getNotificationType: () => deps.getNotificationType?.(), - showMpvOsd: (message: string) => deps.showMpvOsd(message), - showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload), - showDesktopNotification: (title: string, options: { body: string }) => - deps.showDesktopNotification(title, options), - }); -} - -export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { - setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; - setSessionBindings: ( - sessionBindings: ConfigHotReloadPayload['sessionBindings'], - sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], - ) => void; - refreshGlobalAndOverlayShortcuts: () => void; - setSecondarySubMode: (mode: SecondarySubMode) => void; - broadcastToOverlayWindows: (channel: string, payload: unknown) => void; - applyAnkiRuntimeConfigPatch: (patch: Partial) => void; - invalidateTokenizationCache?: () => void; - refreshSubtitlePrefetch?: () => void; - refreshCurrentSubtitle?: () => void; - setLogLevel?: (level: ResolvedConfig['logging']['level']) => void; - setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void; - setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void; - applyAniSkipConfig?: () => void; -}) { - return () => ({ - setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => - deps.setKeybindings(keybindings), - setSessionBindings: ( - sessionBindings: ConfigHotReloadPayload['sessionBindings'], - sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], - ) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings), - refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), - setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), - broadcastToOverlayWindows: (channel: string, payload: unknown) => - deps.broadcastToOverlayWindows(channel, payload), - applyAnkiRuntimeConfigPatch: (patch: Partial) => - deps.applyAnkiRuntimeConfigPatch(patch), - invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(), - refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(), - refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), - setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level), - setLogRotation: (rotation: ResolvedConfig['logging']['rotation']) => - deps.setLogRotation?.(rotation), - setLogFileToggles: (files: ResolvedConfig['logging']['files']) => - deps.setLogFileToggles?.(files), - applyAniSkipConfig: () => deps.applyAniSkipConfig?.(), - }); -} - -export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: { - getCurrentConfig: () => ResolvedConfig; - reloadConfigStrict: () => ReloadConfigStrictResult; - watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath']; - setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout; - clearTimeout: (timeout: NodeJS.Timeout) => void; - debounceMs: number; - onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void; - onRestartRequired: (fields: string[]) => void; - onInvalidConfig: (message: string) => void; - onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void; -}) { - return (): ConfigHotReloadRuntimeDeps => ({ - getCurrentConfig: () => deps.getCurrentConfig(), - reloadConfigStrict: () => deps.reloadConfigStrict(), - watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange), - setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs), - clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout), - debounceMs: deps.debounceMs, - onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config), - onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields), - onInvalidConfig: (message: string) => deps.onInvalidConfig(message), - onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => - deps.onValidationWarnings(configPath, warnings), - }); -} diff --git a/src/main/runtime/dictionary-roots.test.ts b/src/main/runtime/dictionary-roots.test.ts new file mode 100644 index 00000000..b1db9178 --- /dev/null +++ b/src/main/runtime/dictionary-roots.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createBuildDictionaryRootsHandler } from './dictionary-roots'; + +const darwinDeps = { + platform: 'darwin', + dirname: '/repo/dist/main', + appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', + resourcesPath: '/Applications/SubMiner.app/Contents/Resources', + userDataPath: '/Users/a/.config/SubMiner', + appUserDataPath: '/Users/a/Library/Application Support/SubMiner', + homeDir: '/Users/a', + cwd: '/repo', + joinPath: (...parts: string[]) => parts.join('/'), +} as const; + +const win32Deps = { + platform: 'win32', + dirname: 'C:\\repo\\dist\\main', + appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar', + resourcesPath: 'C:\\Program Files\\SubMiner\\resources', + userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', + appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', + homeDir: 'C:\\Users\\a', + appDataDir: 'C:\\Users\\a\\AppData\\Roaming', + cwd: 'C:\\repo', + joinPath: (...parts: string[]) => parts.join('\\'), +} as const; + +for (const vendorDirName of ['yomitan-jlpt-vocab', 'frequency-dictionary']) { + test(`dictionary roots for ${vendorDirName} list vendor, user data, platform and cwd roots`, () => { + const roots = createBuildDictionaryRootsHandler({ ...darwinDeps, vendorDirName })(); + + assert.equal(roots.length, 11); + assert.equal(roots[0], `/repo/dist/main/../../vendor/${vendorDirName}`); + assert.equal(roots[2], `/Applications/SubMiner.app/Contents/Resources/${vendorDirName}`); + assert.equal(roots[10], '/repo'); + }); +} + +test('dictionary roots use APPDATA-style roots on windows', () => { + const roots = createBuildDictionaryRootsHandler({ + ...win32Deps, + vendorDirName: 'yomitan-jlpt-vocab', + })(); + + assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false); + assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true); +}); diff --git a/src/main/runtime/dictionary-roots.ts b/src/main/runtime/dictionary-roots.ts new file mode 100644 index 00000000..91812bdd --- /dev/null +++ b/src/main/runtime/dictionary-roots.ts @@ -0,0 +1,47 @@ +// Candidate root directories searched for a bundled dictionary (JLPT vocab, frequency, ...). +// `vendorDirName` is the dictionary's folder under `vendor/` in dev and in packaged resources. +export function createBuildDictionaryRootsHandler(deps: { + vendorDirName: string; + platform: NodeJS.Platform; + dirname: string; + appPath: string; + resourcesPath: string; + userDataPath: string; + appUserDataPath: string; + homeDir: string; + appDataDir?: string; + cwd: string; + joinPath: (...parts: string[]) => string; +}) { + return (): string[] => { + const platformRoots = + deps.platform === 'win32' + ? [ + deps.joinPath( + deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), + 'SubMiner', + ), + deps.joinPath( + deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), + 'subminer', + ), + ] + : [ + deps.joinPath(deps.homeDir, '.config', 'SubMiner'), + deps.joinPath(deps.homeDir, '.config', 'subminer'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'), + deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'), + ]; + + return [ + deps.joinPath(deps.dirname, '..', '..', 'vendor', deps.vendorDirName), + deps.joinPath(deps.appPath, 'vendor', deps.vendorDirName), + deps.joinPath(deps.resourcesPath, deps.vendorDirName), + deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', deps.vendorDirName), + deps.userDataPath, + deps.appUserDataPath, + ...platformRoots, + deps.cwd, + ]; + }; +} diff --git a/src/main/runtime/dictionary-runtime-main-deps.test.ts b/src/main/runtime/dictionary-runtime-main-deps.test.ts deleted file mode 100644 index e41f01b5..00000000 --- a/src/main/runtime/dictionary-runtime-main-deps.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRootsMainHandler, -} from './dictionary-runtime-main-deps'; - -test('dictionary roots main handler returns expected root list', () => { - const roots = createBuildDictionaryRootsMainHandler({ - platform: 'darwin', - dirname: '/repo/dist/main', - appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', - resourcesPath: '/Applications/SubMiner.app/Contents/Resources', - userDataPath: '/Users/a/.config/SubMiner', - appUserDataPath: '/Users/a/Library/Application Support/SubMiner', - homeDir: '/Users/a', - cwd: '/repo', - joinPath: (...parts) => parts.join('/'), - })(); - - assert.equal(roots.length, 11); - assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab'); - assert.equal(roots[10], '/repo'); -}); - -test('frequency dictionary roots main handler returns expected root list', () => { - const roots = createBuildFrequencyDictionaryRootsMainHandler({ - platform: 'darwin', - dirname: '/repo/dist/main', - appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', - resourcesPath: '/Applications/SubMiner.app/Contents/Resources', - userDataPath: '/Users/a/.config/SubMiner', - appUserDataPath: '/Users/a/Library/Application Support/SubMiner', - homeDir: '/Users/a', - cwd: '/repo', - joinPath: (...parts) => parts.join('/'), - })(); - - assert.equal(roots.length, 11); - assert.equal(roots[0], '/repo/dist/main/../../vendor/frequency-dictionary'); - assert.equal(roots[10], '/repo'); -}); - -test('dictionary roots main handler uses APPDATA-style roots on windows', () => { - const roots = createBuildDictionaryRootsMainHandler({ - platform: 'win32', - dirname: 'C:\\repo\\dist\\main', - appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar', - resourcesPath: 'C:\\Program Files\\SubMiner\\resources', - userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', - appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', - homeDir: 'C:\\Users\\a', - appDataDir: 'C:\\Users\\a\\AppData\\Roaming', - cwd: 'C:\\repo', - joinPath: (...parts) => parts.join('\\'), - })(); - - assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false); - assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true); -}); - -test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => { - const roots = createBuildFrequencyDictionaryRootsMainHandler({ - platform: 'win32', - dirname: 'C:\\repo\\dist\\main', - appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar', - resourcesPath: 'C:\\Program Files\\SubMiner\\resources', - userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', - appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner', - homeDir: 'C:\\Users\\a', - appDataDir: 'C:\\Users\\a\\AppData\\Roaming', - cwd: 'C:\\repo', - joinPath: (...parts) => parts.join('\\'), - })(); - - assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false); - assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true); -}); diff --git a/src/main/runtime/dictionary-runtime-main-deps.ts b/src/main/runtime/dictionary-runtime-main-deps.ts deleted file mode 100644 index 0a9dfd29..00000000 --- a/src/main/runtime/dictionary-runtime-main-deps.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { FrequencyDictionaryLookup, JlptLevel } from '../../types'; - -type JlptLookup = (term: string) => JlptLevel | null; - -export function createBuildDictionaryRootsMainHandler(deps: { - platform: NodeJS.Platform; - dirname: string; - appPath: string; - resourcesPath: string; - userDataPath: string; - appUserDataPath: string; - homeDir: string; - appDataDir?: string; - cwd: string; - joinPath: (...parts: string[]) => string; -}) { - return () => { - const platformRoots = - deps.platform === 'win32' - ? [ - deps.joinPath( - deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), - 'SubMiner', - ), - deps.joinPath( - deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), - 'subminer', - ), - ] - : [ - deps.joinPath(deps.homeDir, '.config', 'SubMiner'), - deps.joinPath(deps.homeDir, '.config', 'subminer'), - deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'), - deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'), - ]; - - return [ - deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'), - deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'), - deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'), - deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'), - deps.userDataPath, - deps.appUserDataPath, - ...platformRoots, - deps.cwd, - ]; - }; -} - -export function createBuildFrequencyDictionaryRootsMainHandler(deps: { - platform: NodeJS.Platform; - dirname: string; - appPath: string; - resourcesPath: string; - userDataPath: string; - appUserDataPath: string; - homeDir: string; - appDataDir?: string; - cwd: string; - joinPath: (...parts: string[]) => string; -}) { - return () => { - const platformRoots = - deps.platform === 'win32' - ? [ - deps.joinPath( - deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), - 'SubMiner', - ), - deps.joinPath( - deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'), - 'subminer', - ), - ] - : [ - deps.joinPath(deps.homeDir, '.config', 'SubMiner'), - deps.joinPath(deps.homeDir, '.config', 'subminer'), - deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'), - deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'), - ]; - - return [ - deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'), - deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'), - deps.joinPath(deps.resourcesPath, 'frequency-dictionary'), - deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'), - deps.userDataPath, - deps.appUserDataPath, - ...platformRoots, - deps.cwd, - ]; - }; -} - -export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: { - isJlptEnabled: () => boolean; - getDictionaryRoots: () => string[]; - getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[]; - setJlptLevelLookup: (lookup: JlptLookup) => void; - logInfo: (message: string) => void; -}) { - return () => ({ - isJlptEnabled: () => deps.isJlptEnabled(), - getSearchPaths: () => - deps.getJlptDictionarySearchPaths({ - getDictionaryRoots: () => deps.getDictionaryRoots(), - }), - setJlptLevelLookup: (lookup: JlptLookup) => deps.setJlptLevelLookup(lookup), - log: (message: string) => deps.logInfo(`[JLPT] ${message}`), - }); -} - -export function createBuildFrequencyDictionaryRuntimeMainDepsHandler(deps: { - isFrequencyDictionaryEnabled: () => boolean; - getDictionaryRoots: () => string[]; - getFrequencyDictionarySearchPaths: (deps: { - getDictionaryRoots: () => string[]; - getSourcePath: () => string | undefined; - }) => string[]; - getSourcePath: () => string | undefined; - setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void; - logInfo: (message: string) => void; -}) { - return () => ({ - isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(), - getSearchPaths: () => - deps.getFrequencyDictionarySearchPaths({ - getDictionaryRoots: () => - deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot), - getSourcePath: () => deps.getSourcePath(), - }), - setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => - deps.setFrequencyRankLookup(lookup), - log: (message: string) => deps.logInfo(`[Frequency] ${message}`), - }); -} diff --git a/src/main/runtime/domains/jellyfin.ts b/src/main/runtime/domains/jellyfin.ts index ab408ecb..771bd737 100644 --- a/src/main/runtime/domains/jellyfin.ts +++ b/src/main/runtime/domains/jellyfin.ts @@ -3,16 +3,13 @@ export * from '../jellyfin-cli-list'; export * from '../jellyfin-cli-play'; export * from '../jellyfin-cli-remote-announce'; export * from '../jellyfin-client-info'; -export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-device-identity'; export * from '../jellyfin-playback-launch'; export * from '../jellyfin-remote-commands'; export * from '../jellyfin-remote-connection'; -export * from '../jellyfin-remote-main-deps'; export * from '../jellyfin-remote-playback'; export * from '../jellyfin-remote-session-lifecycle'; export * from '../jellyfin-setup-window'; -export * from '../jellyfin-setup-window-main-deps'; export * from '../jellyfin-subtitle-preload'; export * from '../mpv-jellyfin-defaults'; diff --git a/src/main/runtime/domains/mpv.ts b/src/main/runtime/domains/mpv.ts index 77c79da8..669a5bb1 100644 --- a/src/main/runtime/domains/mpv.ts +++ b/src/main/runtime/domains/mpv.ts @@ -1,8 +1,7 @@ export * from '../mpv-client-runtime-service'; -export * from '../mpv-client-runtime-service-main-deps'; export * from '../mpv-main-event-bindings'; -export * from '../mpv-main-event-main-deps'; +export * from '../mpv-main-event-binding-deps'; export * from '../mpv-osd-runtime-handlers'; export * from '../mpv-subtitle-render-metrics'; export * from '../secondary-sub-mode-runtime-handler'; -export * from '../subtitle-tokenization-main-deps'; +export * from '../subtitle-tokenization-warmup'; diff --git a/src/main/runtime/domains/overlay.ts b/src/main/runtime/domains/overlay.ts index 3cb9df43..296593f0 100644 --- a/src/main/runtime/domains/overlay.ts +++ b/src/main/runtime/domains/overlay.ts @@ -1,13 +1,10 @@ export * from '../config-hot-reload-handlers'; -export * from '../config-hot-reload-main-deps'; export * from '../field-grouping-resolver'; export * from '../overlay-main-actions'; export * from '../overlay-runtime-bootstrap-handlers'; export * from '../overlay-runtime-main-actions'; export * from '../overlay-visibility-runtime'; -export * from '../overlay-visibility-runtime-main-deps'; export * from '../overlay-window-layout'; -export * from '../overlay-window-layout-main-deps'; export * from '../overlay-window-runtime-handlers'; export * from '../subtitle-position'; export * from '../tray-runtime'; diff --git a/src/main/runtime/domains/shortcuts.ts b/src/main/runtime/domains/shortcuts.ts index 5278ec1f..4b0aa725 100644 --- a/src/main/runtime/domains/shortcuts.ts +++ b/src/main/runtime/domains/shortcuts.ts @@ -1,4 +1,3 @@ export * from '../global-shortcuts-runtime-handlers'; export * from '../numeric-shortcut-session-runtime-handlers'; export * from '../overlay-shortcuts-runtime-handlers'; -export * from '../overlay-shortcuts-runtime-main-deps'; diff --git a/src/main/runtime/domains/startup.ts b/src/main/runtime/domains/startup.ts index 30b12dca..8e755bec 100644 --- a/src/main/runtime/domains/startup.ts +++ b/src/main/runtime/domains/startup.ts @@ -1,9 +1,8 @@ export * from '../app-lifecycle-actions'; -export * from '../app-lifecycle-main-activate'; export * from '../app-lifecycle-main-cleanup'; export * from '../clipboard-queue'; export * from '../config-derived'; -export * from '../dictionary-runtime-main-deps'; +export * from '../dictionary-roots'; export * from '../immersion-media'; export * from '../immersion-startup'; export * from '../startup-config'; diff --git a/src/main/runtime/jellyfin-client-info-main-deps.ts b/src/main/runtime/jellyfin-client-info-main-deps.ts deleted file mode 100644 index c6fbee0f..00000000 --- a/src/main/runtime/jellyfin-client-info-main-deps.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { - createGetJellyfinClientInfoHandler, - createGetResolvedJellyfinConfigHandler, -} from './jellyfin-client-info'; - -type GetResolvedJellyfinConfigMainDeps = Parameters< - typeof createGetResolvedJellyfinConfigHandler ->[0]; -type GetJellyfinClientInfoMainDeps = Parameters[0]; - -export function createBuildGetResolvedJellyfinConfigMainDepsHandler( - deps: GetResolvedJellyfinConfigMainDeps, -) { - return (): GetResolvedJellyfinConfigMainDeps => ({ - getResolvedConfig: () => deps.getResolvedConfig(), - loadStoredSession: () => deps.loadStoredSession(), - getEnv: (name: string) => deps.getEnv(name), - }); -} - -export function createBuildGetJellyfinClientInfoMainDepsHandler( - deps: GetJellyfinClientInfoMainDeps, -) { - return (): GetJellyfinClientInfoMainDeps => ({ - getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), - getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined, - defaultClientName: deps.defaultClientName, - defaultClientVersion: deps.defaultClientVersion, - }); -} diff --git a/src/main/runtime/jellyfin-remote-main-deps.ts b/src/main/runtime/jellyfin-remote-main-deps.ts deleted file mode 100644 index f4592d9a..00000000 --- a/src/main/runtime/jellyfin-remote-main-deps.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - JellyfinRemoteGeneralCommandHandlerDeps, - JellyfinRemotePlayHandlerDeps, - JellyfinRemotePlaystateHandlerDeps, -} from './jellyfin-remote-commands'; -import type { - JellyfinRemoteProgressReporterDeps, - JellyfinRemoteStoppedReporterDeps, -} from './jellyfin-remote-playback'; - -export function createBuildHandleJellyfinRemotePlayMainDepsHandler( - deps: JellyfinRemotePlayHandlerDeps, -) { - return (): JellyfinRemotePlayHandlerDeps => ({ - getConfiguredSession: () => deps.getConfiguredSession(), - getClientInfo: () => deps.getClientInfo(), - getJellyfinConfig: () => deps.getJellyfinConfig(), - ...(deps.getActivePlayback - ? { getActivePlayback: () => deps.getActivePlayback?.() ?? null } - : {}), - playJellyfinItem: (params) => deps.playJellyfinItem(params), - logWarn: (message: string) => deps.logWarn(message), - }); -} - -export function createBuildHandleJellyfinRemotePlaystateMainDepsHandler( - deps: JellyfinRemotePlaystateHandlerDeps, -) { - return (): JellyfinRemotePlaystateHandlerDeps => ({ - getMpvClient: () => deps.getMpvClient(), - sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command), - reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force), - reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), - jellyfinTicksToSeconds: (ticks: number) => deps.jellyfinTicksToSeconds(ticks), - }); -} - -export function createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler( - deps: JellyfinRemoteGeneralCommandHandlerDeps, -) { - return (): JellyfinRemoteGeneralCommandHandlerDeps => ({ - getMpvClient: () => deps.getMpvClient(), - sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command), - getActivePlayback: () => deps.getActivePlayback(), - reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force), - logDebug: (message: string) => deps.logDebug(message), - }); -} - -export function createBuildReportJellyfinRemoteProgressMainDepsHandler( - deps: JellyfinRemoteProgressReporterDeps, -) { - return (): JellyfinRemoteProgressReporterDeps => ({ - getActivePlayback: () => deps.getActivePlayback(), - clearActivePlayback: () => deps.clearActivePlayback(), - getMpvClient: () => deps.getMpvClient(), - getNow: () => deps.getNow(), - getLastProgressAtMs: () => deps.getLastProgressAtMs(), - setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), - progressIntervalMs: deps.progressIntervalMs, - ticksPerSecond: deps.ticksPerSecond, - logDebug: (message: string, error: unknown) => deps.logDebug(message, error), - }); -} - -export function createBuildReportJellyfinRemoteStoppedMainDepsHandler( - deps: JellyfinRemoteStoppedReporterDeps, -) { - return (): JellyfinRemoteStoppedReporterDeps => ({ - getActivePlayback: () => deps.getActivePlayback(), - clearActivePlayback: () => deps.clearActivePlayback(), - getMpvClient: () => deps.getMpvClient(), - getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined, - ticksPerSecond: deps.ticksPerSecond, - logDebug: (message: string, error: unknown) => deps.logDebug(message, error), - ...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}), - }); -} diff --git a/src/main/runtime/jellyfin-setup-window-main-deps.ts b/src/main/runtime/jellyfin-setup-window-main-deps.ts deleted file mode 100644 index 66ef9480..00000000 --- a/src/main/runtime/jellyfin-setup-window-main-deps.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { createOpenJellyfinSetupWindowHandler } from './jellyfin-setup-window'; - -type OpenJellyfinSetupWindowMainDeps = Parameters[0]; - -export function createBuildOpenJellyfinSetupWindowMainDepsHandler( - deps: OpenJellyfinSetupWindowMainDeps, -) { - return (): OpenJellyfinSetupWindowMainDeps => ({ - maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(), - createSetupWindow: () => deps.createSetupWindow(), - getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), - buildSetupFormHtml: (state) => deps.buildSetupFormHtml(state), - parseSubmissionUrl: (rawUrl: string) => deps.parseSubmissionUrl(rawUrl), - authenticateWithPassword: (server: string, username: string, password: string, clientInfo) => - deps.authenticateWithPassword(server, username, password, clientInfo), - getJellyfinClientInfo: () => deps.getJellyfinClientInfo(), - saveStoredSession: (session) => deps.saveStoredSession(session), - clearStoredSession: () => deps.clearStoredSession(), - patchJellyfinConfig: (session) => deps.patchJellyfinConfig(session), - persistAuthenticatedSession: deps.persistAuthenticatedSession - ? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo) - : undefined, - restartRemoteSession: deps.restartRemoteSession - ? () => deps.restartRemoteSession?.() - : undefined, - stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined, - logInfo: (message: string) => deps.logInfo(message), - logError: (message: string, error: unknown) => deps.logError(message, error), - showMpvOsd: (message: string) => deps.showMpvOsd(message), - clearSetupWindow: () => deps.clearSetupWindow(), - setSetupWindow: (window) => deps.setSetupWindow(window), - registerSetupIpcHandler: deps.registerSetupIpcHandler - ? (handler) => deps.registerSetupIpcHandler?.(handler) ?? (() => undefined) - : undefined, - encodeURIComponent: (value: string) => deps.encodeURIComponent(value), - defaultServerUrl: deps.defaultServerUrl, - hasStoredSession: () => deps.hasStoredSession(), - }); -} diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.ts deleted file mode 100644 index 5ef9b1b3..00000000 --- a/src/main/runtime/mpv-client-runtime-service-main-deps.ts +++ /dev/null @@ -1,38 +0,0 @@ -export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< - TClient, - TResolvedConfig, - TOptions, ->(deps: { - createClient: new (socketPath: string, options: TOptions) => TClient; - getSocketPath: () => string; - getResolvedConfig: () => TResolvedConfig; - isAutoStartOverlayEnabled: () => boolean; - setOverlayVisible: (visible: boolean) => void; - isVisibleOverlayVisible: () => boolean; - getReconnectTimer: () => ReturnType | null; - setReconnectTimer: (timer: ReturnType | null) => void; - shouldAutoLoadSecondarySubTrack?: (path: string) => boolean; - shouldQuitOnMpvShutdown?: () => boolean; - requestAppQuit?: () => void; - bindEventHandlers: (client: TClient) => void; -}) { - return () => ({ - createClient: deps.createClient, - socketPath: deps.getSocketPath(), - options: { - getResolvedConfig: () => deps.getResolvedConfig(), - autoStartOverlay: deps.isAutoStartOverlayEnabled(), - setOverlayVisible: (visible: boolean) => deps.setOverlayVisible(visible), - isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(), - getReconnectTimer: () => deps.getReconnectTimer(), - setReconnectTimer: (timer: ReturnType | null) => - deps.setReconnectTimer(timer), - shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack - ? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true - : undefined, - shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false, - requestAppQuit: () => deps.requestAppQuit?.(), - }, - bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client), - }); -} diff --git a/src/main/runtime/mpv-client-runtime-service.test.ts b/src/main/runtime/mpv-client-runtime-service.test.ts new file mode 100644 index 00000000..094c8fba --- /dev/null +++ b/src/main/runtime/mpv-client-runtime-service.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG } from '../../config'; +import { + createMpvClientRuntimeServiceFactory, + type MpvClientRuntimeServiceOptions, +} from './mpv-client-runtime-service'; + +test('mpv client factory reads socket path and auto-start per client, binds before connect', () => { + const calls: string[] = []; + let socketPath = '/tmp/a.sock'; + let autoStart = false; + + class FakeClient { + constructor( + readonly socketPath: string, + readonly options: MpvClientRuntimeServiceOptions, + ) {} + connect() { + calls.push(`connect:${this.socketPath}`); + } + } + + const createClient = createMpvClientRuntimeServiceFactory({ + createClient: FakeClient, + getSocketPath: () => socketPath, + getResolvedConfig: () => DEFAULT_CONFIG, + isAutoStartOverlayEnabled: () => autoStart, + setOverlayVisible: () => {}, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + bindEventHandlers: (client) => calls.push(`bind:${client.socketPath}`), + }); + + const first = createClient(); + socketPath = '/tmp/b.sock'; + autoStart = true; + const second = createClient(); + + assert.deepEqual(calls, [ + 'bind:/tmp/a.sock', + 'connect:/tmp/a.sock', + 'bind:/tmp/b.sock', + 'connect:/tmp/b.sock', + ]); + assert.equal(first.options.autoStartOverlay, false); + assert.equal(second.options.autoStartOverlay, true); + assert.equal(first.options.shouldQuitOnMpvShutdown?.(), false); +}); diff --git a/src/main/runtime/mpv-client-runtime-service.ts b/src/main/runtime/mpv-client-runtime-service.ts index 10b6cf63..8f88b1a5 100644 --- a/src/main/runtime/mpv-client-runtime-service.ts +++ b/src/main/runtime/mpv-client-runtime-service.ts @@ -16,22 +16,35 @@ type MpvClientLike = { connect: () => void; }; -type MpvClientCtor< - TClient extends MpvClientLike, - TOptions extends MpvClientRuntimeServiceOptions, -> = new (socketPath: string, options: TOptions) => TClient; - -export function createMpvClientRuntimeServiceFactory< - TClient extends MpvClientLike, - TOptions extends MpvClientRuntimeServiceOptions, ->(deps: { - createClient: MpvClientCtor; - socketPath: string; - options: TOptions; +// Creates, binds and connects a new mpv client. Socket path and auto-start state are read +// on each call, so a reconnect picks up the current values. +export function createMpvClientRuntimeServiceFactory(deps: { + createClient: new (socketPath: string, options: MpvClientRuntimeServiceOptions) => TClient; + getSocketPath: () => string; + getResolvedConfig: () => Config; + isAutoStartOverlayEnabled: () => boolean; + setOverlayVisible: (visible: boolean) => void; + isVisibleOverlayVisible: () => boolean; + getReconnectTimer: () => ReturnType | null; + setReconnectTimer: (timer: ReturnType | null) => void; + shouldAutoLoadSecondarySubTrack?: (path: string) => boolean; + shouldQuitOnMpvShutdown?: () => boolean; + requestAppQuit?: () => void; bindEventHandlers: (client: TClient) => void; }) { return (): TClient => { - const mpvClient = new deps.createClient(deps.socketPath, deps.options); + const socketPath = deps.getSocketPath(); + const mpvClient = new deps.createClient(socketPath, { + getResolvedConfig: () => deps.getResolvedConfig(), + autoStartOverlay: deps.isAutoStartOverlayEnabled(), + setOverlayVisible: (visible) => deps.setOverlayVisible(visible), + isVisibleOverlayVisible: () => deps.isVisibleOverlayVisible(), + getReconnectTimer: () => deps.getReconnectTimer(), + setReconnectTimer: (timer) => deps.setReconnectTimer(timer), + shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack, + shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false, + requestAppQuit: () => deps.requestAppQuit?.(), + }); deps.bindEventHandlers(mpvClient); mpvClient.connect(); return mpvClient; diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-binding-deps.test.ts similarity index 98% rename from src/main/runtime/mpv-main-event-main-deps.test.ts rename to src/main/runtime/mpv-main-event-binding-deps.test.ts index aeb9f5f4..abafeadf 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-binding-deps.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { parseAssCues } from '../../core/services/subtitle-cue-parser'; -import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps'; +import { createMpvMainEventBindingDeps } from './mpv-main-event-binding-deps'; -type MainDeps = Parameters[0]; +type MainDeps = Parameters[0]; /** * Builds the handlers with inert deps. `appStateOverrides` is merged over a blank app @@ -24,7 +24,7 @@ function buildMainDeps( previousSecondarySubVisibility: false, ...appStateOverrides, }; - const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({ + const handlers = createMpvMainEventBindingDeps({ appState, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -49,7 +49,7 @@ function buildMainDeps( updateSubtitleRenderMetrics: () => {}, refreshDiscordPresence: () => {}, ...depsOverrides, - })(); + }); return { handlers, appState }; } diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-binding-deps.ts similarity index 98% rename from src/main/runtime/mpv-main-event-main-deps.ts rename to src/main/runtime/mpv-main-event-binding-deps.ts index 426e0f64..448a7f24 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-binding-deps.ts @@ -11,7 +11,9 @@ type AnilistPostWatchRunOptions = { watchedSeconds?: number; }; -export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { +// Adapts main-process state to the mpv main-event bindings and owns the subtitle-recording +// state (dedup gates, recorded cue keys, session epoch) that those bindings share. +export function createMpvMainEventBindingDeps(deps: { appState: { initialArgs?: { jellyfinPlay?: unknown; @@ -149,7 +151,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { deps.appState.initialArgs?.youtubePlay, ); - return () => ({ + return { reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined, @@ -407,5 +409,5 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { setPreviousSecondarySubVisibility: (visible: boolean) => { deps.appState.previousSecondarySubVisibility = visible; }, - }); + }; } diff --git a/src/main/runtime/overlay-geometry-runtime.ts b/src/main/runtime/overlay-geometry-runtime.ts index 1b4a0449..444b1ffd 100644 --- a/src/main/runtime/overlay-geometry-runtime.ts +++ b/src/main/runtime/overlay-geometry-runtime.ts @@ -20,11 +20,6 @@ import { createUpdateVisibleOverlayBoundsHandler, hasLiveOverlayWindowBoundsMismatch, } from './overlay-window-layout'; -import { - createBuildEnforceOverlayLayerOrderMainDepsHandler, - createBuildEnsureOverlayWindowLevelMainDepsHandler, - createBuildUpdateVisibleOverlayBoundsMainDepsHandler, -} from './overlay-window-layout-main-deps'; import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order'; const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200; @@ -147,68 +142,57 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) { }); } - const buildUpdateVisibleOverlayBoundsMainDepsHandler = - createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ - getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry, - shouldRefreshUnchangedGeometry: (geometry) => - shouldExitLinuxFullscreenOverrideForGeometry(geometry) || - (process.platform === 'linux' && - (hasLiveOverlayWindowBoundsMismatch( - [overlayManager.getMainWindow(), overlayManager.getModalWindow()], - geometry, - ) || - hasHyprlandOverlayWindowPlacementMismatch(geometry))), - setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), - afterSetOverlayWindowBounds: () => { - if (!overlayManager.getVisibleOverlayVisible()) { - return; - } - if (process.platform === 'win32') { - deps.scheduleWindowsVisibleOverlayZOrderSyncBurst(); - return; - } - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - if (process.platform === 'linux') { - restoreLinuxOverlayWindowShape(mainWindow); - } - ensureOverlayWindowLevel(mainWindow); - }, - }); - const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); - const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( - updateVisibleOverlayBoundsMainDeps, - ); + const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler({ + getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry, + shouldRefreshUnchangedGeometry: (geometry) => + shouldExitLinuxFullscreenOverrideForGeometry(geometry) || + (process.platform === 'linux' && + (hasLiveOverlayWindowBoundsMismatch( + [overlayManager.getMainWindow(), overlayManager.getModalWindow()], + geometry, + ) || + hasHyprlandOverlayWindowPlacementMismatch(geometry))), + setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + afterSetOverlayWindowBounds: () => { + if (!overlayManager.getVisibleOverlayVisible()) { + return; + } + if (process.platform === 'win32') { + deps.scheduleWindowsVisibleOverlayZOrderSyncBurst(); + return; + } + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + if (process.platform === 'linux') { + restoreLinuxOverlayWindowShape(mainWindow); + } + ensureOverlayWindowLevel(mainWindow); + }, + }); - const buildEnsureOverlayWindowLevelMainDepsHandler = - createBuildEnsureOverlayWindowLevelMainDepsHandler({ - shouldSuppressOverlayWindowLevel: (window) => { - const mainWindow = overlayManager.getMainWindow(); - return ( - (deps.getStatsOverlayVisible() && window === mainWindow) || - shouldSuppressVisibleOverlayRaiseForSeparateWindow({ - window, - mainWindow, - separateWindows: deps.getOverlayForegroundSeparateWindows(), - }) - ); - }, - ensureOverlayWindowLevelCore: (window) => - ensureOverlayWindowLevelCore(window as BrowserWindow), - afterEnsureOverlayWindowLevel: () => { - const mainWindow = overlayManager.getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow); - } - promoteStatsOverlayAbovePlayback(); - }, - }); - const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); - const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( - ensureOverlayWindowLevelMainDeps, - ); + const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler({ + shouldSuppressOverlayWindowLevel: (window) => { + const mainWindow = overlayManager.getMainWindow(); + return ( + (deps.getStatsOverlayVisible() && window === mainWindow) || + shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window, + mainWindow, + separateWindows: deps.getOverlayForegroundSeparateWindows(), + }) + ); + }, + ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), + afterEnsureOverlayWindowLevel: () => { + const mainWindow = overlayManager.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow); + } + promoteStatsOverlayAbovePlayback(); + }, + }); function syncPrimaryOverlayWindowLayer(layer: 'visible'): void { const mainWindow = overlayManager.getMainWindow(); @@ -276,23 +260,18 @@ export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) { ); } - const buildEnforceOverlayLayerOrderMainDepsHandler = - createBuildEnforceOverlayLayerOrderMainDepsHandler({ - enforceOverlayLayerOrderCore: (params) => - enforceOverlayLayerOrderCore({ - visibleOverlayVisible: params.visibleOverlayVisible, - mainWindow: params.mainWindow as BrowserWindow | null, - ensureOverlayWindowLevel: (window) => - params.ensureOverlayWindowLevel(window as BrowserWindow), - }), - getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getMainWindow: () => overlayManager.getMainWindow(), - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), - }); - const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler(); - const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler( - enforceOverlayLayerOrderMainDeps, - ); + const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler({ + enforceOverlayLayerOrderCore: (params) => + enforceOverlayLayerOrderCore({ + visibleOverlayVisible: params.visibleOverlayVisible, + mainWindow: params.mainWindow as BrowserWindow | null, + ensureOverlayWindowLevel: (window) => + params.ensureOverlayWindowLevel(window as BrowserWindow), + }), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow), + }); return { getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry, diff --git a/src/main/runtime/overlay-runtime-bootstrap-handlers.ts b/src/main/runtime/overlay-runtime-bootstrap-handlers.ts index 21cbe431..03810dcf 100644 --- a/src/main/runtime/overlay-runtime-bootstrap-handlers.ts +++ b/src/main/runtime/overlay-runtime-bootstrap-handlers.ts @@ -1,35 +1,21 @@ -import { createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler } from './app-runtime-main-deps'; import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap'; import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; -import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps'; -type InitializeOverlayRuntimeMainDeps = Parameters< - typeof createBuildInitializeOverlayRuntimeMainDepsHandler ->[0]; -type InitializeOverlayRuntimeOptions = ReturnType< - ReturnType ->; -type InitializeOverlayRuntimeBootstrapMainDeps = Parameters< - typeof createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler ->[0]; +type InitializeOverlayRuntimeDeps = Parameters[0]; export function createOverlayRuntimeBootstrapHandlers(deps: { - initializeOverlayRuntimeMainDeps: InitializeOverlayRuntimeMainDeps; - initializeOverlayRuntimeBootstrapDeps: Omit< - InitializeOverlayRuntimeBootstrapMainDeps, - 'buildOptions' - >; + initializeOverlayRuntimeMainDeps: Parameters< + typeof createBuildInitializeOverlayRuntimeOptionsHandler + >[0]; + initializeOverlayRuntimeBootstrapDeps: Omit; }) { - const buildInitializeOverlayRuntimeOptionsHandler = - createBuildInitializeOverlayRuntimeOptionsHandler( - createBuildInitializeOverlayRuntimeMainDepsHandler(deps.initializeOverlayRuntimeMainDeps)(), - ); - const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler( - createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ - ...deps.initializeOverlayRuntimeBootstrapDeps, - buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(), - })(), + const buildInitializeOverlayRuntimeOptions = createBuildInitializeOverlayRuntimeOptionsHandler( + deps.initializeOverlayRuntimeMainDeps, ); + const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler({ + ...deps.initializeOverlayRuntimeBootstrapDeps, + buildOptions: () => buildInitializeOverlayRuntimeOptions(), + }); return { initializeOverlayRuntime, diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts deleted file mode 100644 index d3000b2d..00000000 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { AnkiConnectConfig } from '../../types'; -import type { OverlayNotificationPayload } from '../../types/notification'; -import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; - -type OverlayRuntimeOptionsMainDeps = Parameters< - typeof createBuildInitializeOverlayRuntimeOptionsHandler ->[0]; - -export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { - appState: { - backendOverride: string | null; - windowTracker: Parameters[0]; - subtitleTimingTracker: ReturnType; - mpvClient: ReturnType; - mpvSocketPath: string; - runtimeOptionsManager: ReturnType; - ankiIntegration: Parameters[0]; - }; - overlayManager: { - getVisibleOverlayVisible: () => boolean; - }; - overlayVisibilityRuntime: { - updateVisibleOverlayVisibility: () => void; - }; - refreshCurrentSubtitle?: () => void; - overlayShortcutsRuntime: { - syncOverlayShortcuts: () => void; - }; - createMainWindow: () => void; - registerGlobalShortcuts: () => void; - updateVisibleOverlayBounds: (geometry: { - x: number; - y: number; - width: number; - height: number; - }) => void; - getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows']; - createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker']; - getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; - showOverlayNotification?: (payload: OverlayNotificationPayload) => void; - dismissOverlayNotification?: (id: string) => void; - createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; - getKnownWordCacheStatePath: () => string; - getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; - shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache']; - getYoutubeMediaSourceUrl?: OverlayRuntimeOptionsMainDeps['getYoutubeMediaSourceUrl']; - shouldStartAnkiIntegration: () => boolean; - bindOverlayOwner?: () => void; - releaseOverlayOwner?: () => void; -}) { - return (): OverlayRuntimeOptionsMainDeps => ({ - getBackendOverride: () => deps.appState.backendOverride, - createMainWindow: () => deps.createMainWindow(), - registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), - updateVisibleOverlayBounds: (geometry: { - x: number; - y: number; - width: number; - height: number; - }) => deps.updateVisibleOverlayBounds(geometry), - isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), - updateVisibleOverlayVisibility: () => - deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), - getOverlayWindows: () => deps.getOverlayWindows(), - syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), - setWindowTracker: (tracker) => { - deps.appState.windowTracker = tracker; - }, - createWindowTracker: deps.createWindowTracker, - getResolvedConfig: () => deps.getResolvedConfig(), - getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker, - getMpvClient: () => deps.appState.mpvClient, - getMpvSocketPath: () => deps.appState.mpvSocketPath, - getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager, - setAnkiIntegration: (integration) => { - deps.appState.ankiIntegration = integration; - }, - showDesktopNotification: deps.showDesktopNotification, - showOverlayNotification: deps.showOverlayNotification, - dismissOverlayNotification: deps.dismissOverlayNotification, - createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), - getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), - ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), - ...(deps.shouldRequireRemoteMediaCache - ? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache } - : {}), - ...(deps.getYoutubeMediaSourceUrl - ? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl } - : {}), - shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), - bindOverlayOwner: deps.bindOverlayOwner, - releaseOverlayOwner: deps.releaseOverlayOwner, - }); -} diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 98e7e08a..6e41a665 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -49,69 +49,75 @@ type OverlayRuntimeOptions = { releaseOverlayOwner?: () => void; }; +// Builds the options for core initializeOverlayRuntime from main-process state. appState +// fields are read when the options are built (or lazily via getters), and the window tracker +// and Anki integration produced by the core are written back to appState. export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { - getBackendOverride: () => string | null; + appState: { + backendOverride: string | null; + windowTracker: Parameters[0]; + subtitleTimingTracker: ReturnType; + mpvClient: ReturnType; + mpvSocketPath: string; + runtimeOptionsManager: ReturnType; + ankiIntegration: Parameters[0]; + }; + overlayManager: { + getVisibleOverlayVisible: () => boolean; + }; + overlayVisibilityRuntime: { + updateVisibleOverlayVisibility: () => void; + }; + refreshCurrentSubtitle?: () => void; + overlayShortcutsRuntime: { + syncOverlayShortcuts: () => void; + }; createMainWindow: () => void; registerGlobalShortcuts: () => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; - isVisibleOverlayVisible: () => boolean; - updateVisibleOverlayVisibility: () => void; - refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; - syncOverlayShortcuts: () => void; - setWindowTracker: (tracker: BaseWindowTracker | null) => void; - createWindowTracker?: ( - override?: string | null, - targetMpvSocketPath?: string | null, - ) => BaseWindowTracker | null; + createWindowTracker?: OverlayRuntimeOptions['createWindowTracker']; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; - getSubtitleTimingTracker: () => unknown | null; - getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; - getMpvSocketPath: () => string; - getRuntimeOptionsManager: () => { - getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; - } | null; - setAnkiIntegration: (integration: unknown | null) => void; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showDesktopNotification: OverlayRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; dismissOverlayNotification?: (id: string) => void; - createFieldGroupingCallback: () => ( - data: KikuFieldGroupingRequestData, - ) => Promise; + createFieldGroupingCallback: OverlayRuntimeOptions['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; - getCachedMediaPath?: ( - currentVideoPath: string, - kind: 'audio' | 'video', - ) => Promise; + getCachedMediaPath?: OverlayRuntimeOptions['getCachedMediaPath']; shouldRequireRemoteMediaCache?: () => boolean; - getYoutubeMediaSourceUrl?: () => Promise | string | null | undefined; + getYoutubeMediaSourceUrl?: OverlayRuntimeOptions['getYoutubeMediaSourceUrl']; shouldStartAnkiIntegration: () => boolean; bindOverlayOwner?: () => void; releaseOverlayOwner?: () => void; }) { return (): OverlayRuntimeOptions => ({ - backendOverride: deps.getBackendOverride(), - createMainWindow: deps.createMainWindow, - registerGlobalShortcuts: deps.registerGlobalShortcuts, - updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, - isVisibleOverlayVisible: deps.isVisibleOverlayVisible, - updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, - refreshCurrentSubtitle: deps.refreshCurrentSubtitle, - getOverlayWindows: deps.getOverlayWindows, - syncOverlayShortcuts: deps.syncOverlayShortcuts, - setWindowTracker: deps.setWindowTracker, + backendOverride: deps.appState.backendOverride, + createMainWindow: () => deps.createMainWindow(), + registerGlobalShortcuts: () => deps.registerGlobalShortcuts(), + updateVisibleOverlayBounds: (geometry) => deps.updateVisibleOverlayBounds(geometry), + isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => + deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), + getOverlayWindows: () => deps.getOverlayWindows(), + syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), + setWindowTracker: (tracker) => { + deps.appState.windowTracker = tracker; + }, createWindowTracker: deps.createWindowTracker, - getResolvedConfig: deps.getResolvedConfig, - getSubtitleTimingTracker: deps.getSubtitleTimingTracker, - getMpvClient: deps.getMpvClient, - getMpvSocketPath: deps.getMpvSocketPath, - getRuntimeOptionsManager: deps.getRuntimeOptionsManager, - setAnkiIntegration: deps.setAnkiIntegration, + getResolvedConfig: () => deps.getResolvedConfig(), + getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker, + getMpvClient: () => deps.appState.mpvClient, + getMpvSocketPath: () => deps.appState.mpvSocketPath, + getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager, + setAnkiIntegration: (integration) => { + deps.appState.ankiIntegration = integration; + }, showDesktopNotification: deps.showDesktopNotification, showOverlayNotification: deps.showOverlayNotification, dismissOverlayNotification: deps.dismissOverlayNotification, - createFieldGroupingCallback: deps.createFieldGroupingCallback, - getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, + createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), + getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), ...(deps.shouldRequireRemoteMediaCache ? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache } @@ -119,7 +125,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { ...(deps.getYoutubeMediaSourceUrl ? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl } : {}), - shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, + shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), bindOverlayOwner: deps.bindOverlayOwner, releaseOverlayOwner: deps.releaseOverlayOwner, }); diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts deleted file mode 100644 index 238cffe0..00000000 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { OverlayShortcutRuntimeServiceInput } from '../overlay-shortcuts-runtime'; - -export function createBuildOverlayShortcutsRuntimeMainDepsHandler( - deps: OverlayShortcutRuntimeServiceInput, -) { - return (): OverlayShortcutRuntimeServiceInput => ({ - getConfiguredShortcuts: () => deps.getConfiguredShortcuts(), - getShortcutsRegistered: () => deps.getShortcutsRegistered(), - setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered), - isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), - isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true, - showMpvOsd: (text: string) => deps.showMpvOsd(text), - openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), - openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(), - openJimaku: () => deps.openJimaku(), - openTsukihime: () => deps.openTsukihime(), - markAudioCard: () => deps.markAudioCard(), - copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), - copySubtitle: () => deps.copySubtitle(), - toggleSecondarySubMode: () => deps.toggleSecondarySubMode(), - updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(), - triggerFieldGrouping: () => deps.triggerFieldGrouping(), - triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), - mineSentenceCard: () => deps.mineSentenceCard(), - mineSentenceMultiple: (timeoutMs: number) => deps.mineSentenceMultiple(timeoutMs), - cancelPendingMultiCopy: () => deps.cancelPendingMultiCopy(), - cancelPendingMineSentenceMultiple: () => deps.cancelPendingMineSentenceMultiple(), - }); -} diff --git a/src/main/runtime/overlay-visibility-actions-main-deps.ts b/src/main/runtime/overlay-visibility-actions-main-deps.ts deleted file mode 100644 index 518d0b3b..00000000 --- a/src/main/runtime/overlay-visibility-actions-main-deps.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { - createSetVisibleOverlayVisibleHandler, - createToggleVisibleOverlayHandler, -} from './overlay-visibility-actions'; - -type SetVisibleOverlayVisibleMainDeps = Parameters[0]; -type ToggleVisibleOverlayMainDeps = Parameters[0]; - -export function createBuildSetVisibleOverlayVisibleMainDepsHandler( - deps: SetVisibleOverlayVisibleMainDeps, -) { - return (): SetVisibleOverlayVisibleMainDeps => ({ - getVisibleOverlayVisible: deps.getVisibleOverlayVisible - ? () => deps.getVisibleOverlayVisible?.() ?? false - : undefined, - setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options), - setVisibleOverlayVisibleState: (visible: boolean) => - deps.setVisibleOverlayVisibleState(visible), - updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(), - onVisibleOverlayEnabled: deps.onVisibleOverlayEnabled, - }); -} - -export function createBuildToggleVisibleOverlayMainDepsHandler(deps: ToggleVisibleOverlayMainDeps) { - return (): ToggleVisibleOverlayMainDeps => ({ - getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), - setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible), - }); -} diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts deleted file mode 100644 index 7c151717..00000000 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { BrowserWindow } from 'electron'; -import type { WindowGeometry } from '../../types'; -import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime'; - -export function createBuildOverlayVisibilityRuntimeMainDepsHandler( - deps: OverlayVisibilityRuntimeDeps, -) { - return (): OverlayVisibilityRuntimeDeps => ({ - getMainWindow: () => deps.getMainWindow(), - getModalActive: () => deps.getModalActive(), - getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), - getForceMousePassthrough: () => deps.getForceMousePassthrough(), - getNonNativeInputRegionActive: () => deps.getNonNativeInputRegionActive?.() ?? false, - getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false, - getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false, - getWindowTracker: () => deps.getWindowTracker(), - getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false, - getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), - setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), - updateVisibleOverlayBounds: (geometry: WindowGeometry) => - deps.updateVisibleOverlayBounds(geometry), - ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), - syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) => - deps.syncWindowsOverlayToMpvZOrder?.(window), - syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), - enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), - syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), - isMacOSPlatform: () => deps.isMacOSPlatform(), - isWindowsPlatform: () => deps.isWindowsPlatform(), - showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message), - dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(), - hideNonNativeOverlayWhenTargetUnfocused: () => - deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false, - resolveFallbackBounds: () => deps.resolveFallbackBounds(), - }); -} diff --git a/src/main/runtime/overlay-visibility-runtime.ts b/src/main/runtime/overlay-visibility-runtime.ts index bf6babb6..ff3f091b 100644 --- a/src/main/runtime/overlay-visibility-runtime.ts +++ b/src/main/runtime/overlay-visibility-runtime.ts @@ -2,35 +2,25 @@ import { createSetVisibleOverlayVisibleHandler, createToggleVisibleOverlayHandler, } from './overlay-visibility-actions'; -import { - createBuildSetVisibleOverlayVisibleMainDepsHandler, - createBuildToggleVisibleOverlayMainDepsHandler, -} from './overlay-visibility-actions-main-deps'; import { createSetOverlayVisibleHandler, createToggleOverlayHandler } from './overlay-main-actions'; -type SetVisibleOverlayVisibleMainDeps = Parameters< - typeof createBuildSetVisibleOverlayVisibleMainDepsHandler ->[0]; +type SetVisibleOverlayVisibleDeps = Parameters[0]; export type OverlayVisibilityRuntimeDeps = { - setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleMainDeps; + setVisibleOverlayVisibleDeps: SetVisibleOverlayVisibleDeps; getVisibleOverlayVisible: () => boolean; }; export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) { - const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({ + const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler({ ...deps.setVisibleOverlayVisibleDeps, getVisibleOverlayVisible: deps.getVisibleOverlayVisible, - })(); - const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler( - setVisibleOverlayVisibleMainDeps, - ); + }); - const toggleVisibleOverlayMainDeps = createBuildToggleVisibleOverlayMainDepsHandler({ + const toggleVisibleOverlay = createToggleVisibleOverlayHandler({ getVisibleOverlayVisible: deps.getVisibleOverlayVisible, setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), - })(); - const toggleVisibleOverlay = createToggleVisibleOverlayHandler(toggleVisibleOverlayMainDeps); + }); const setOverlayVisible = createSetOverlayVisibleHandler({ setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts deleted file mode 100644 index 8a6a99e6..00000000 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Session } from 'electron'; - -export function createBuildCreateOverlayWindowMainDepsHandler(deps: { - createOverlayWindowCore: ( - kind: 'visible' | 'modal', - options: { - isDev: boolean; - ensureOverlayWindowLevel: (window: TWindow) => void; - onRuntimeOptionsChanged: () => void; - setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; - tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - forwardTabToMpv: () => void; - linuxX11FullscreenOverlay?: boolean; - onVisibleWindowBlurred?: () => void; - onVisibleWindowFocused?: () => void; - onWindowDidFinishLoad?: () => void; - onWindowContentReady?: () => void; - onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; - yomitanSession?: Session | null; - }, - ) => TWindow; - isDev: boolean; - ensureOverlayWindowLevel: (window: TWindow) => void; - onRuntimeOptionsChanged: () => void; - setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; - isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; - tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; - forwardTabToMpv: () => void; - getLinuxX11FullscreenOverlay?: () => boolean; - onVisibleWindowBlurred?: () => void; - onVisibleWindowFocused?: () => void; - onWindowDidFinishLoad?: () => void; - onWindowContentReady?: () => void; - onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; - getYomitanSession?: () => Session | null; -}) { - return () => ({ - createOverlayWindowCore: deps.createOverlayWindowCore, - isDev: deps.isDev, - ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, - onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, - setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, - isOverlayVisible: deps.isOverlayVisible, - tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, - forwardTabToMpv: deps.forwardTabToMpv, - getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay, - onVisibleWindowBlurred: deps.onVisibleWindowBlurred, - onVisibleWindowFocused: deps.onVisibleWindowFocused, - onWindowDidFinishLoad: deps.onWindowDidFinishLoad, - onWindowContentReady: deps.onWindowContentReady, - onWindowClosed: deps.onWindowClosed, - getYomitanSession: () => deps.getYomitanSession?.() ?? null, - }); -} - -export function createBuildCreateMainWindowMainDepsHandler(deps: { - getMainWindow: () => TWindow | null; - isWindowDestroyed: (window: TWindow) => boolean; - createOverlayWindow: (kind: 'visible' | 'modal') => TWindow; - setMainWindow: (window: TWindow | null) => void; -}) { - return () => ({ - getMainWindow: () => deps.getMainWindow(), - isWindowDestroyed: (window: TWindow) => deps.isWindowDestroyed(window), - createOverlayWindow: deps.createOverlayWindow, - setMainWindow: deps.setMainWindow, - }); -} - -export function createBuildCreateModalWindowMainDepsHandler(deps: { - createOverlayWindow: (kind: 'visible' | 'modal') => TWindow; - setModalWindow: (window: TWindow | null) => void; -}) { - return () => ({ - createOverlayWindow: deps.createOverlayWindow, - setModalWindow: deps.setModalWindow, - }); -} diff --git a/src/main/runtime/overlay-window-layout-main-deps.ts b/src/main/runtime/overlay-window-layout-main-deps.ts deleted file mode 100644 index 0d2d4893..00000000 --- a/src/main/runtime/overlay-window-layout-main-deps.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - createEnforceOverlayLayerOrderHandler, - createEnsureOverlayWindowLevelHandler, - createUpdateVisibleOverlayBoundsHandler, -} from './overlay-window-layout'; - -type UpdateVisibleOverlayBoundsMainDeps = Parameters< - typeof createUpdateVisibleOverlayBoundsHandler ->[0]; -type EnsureOverlayWindowLevelMainDeps = Parameters[0]; -type EnforceOverlayLayerOrderMainDeps = Parameters[0]; - -export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler( - deps: UpdateVisibleOverlayBoundsMainDeps, -) { - return (): UpdateVisibleOverlayBoundsMainDeps => ({ - getCurrentOverlayWindowBounds: () => deps.getCurrentOverlayWindowBounds?.() ?? null, - shouldRefreshUnchangedGeometry: (geometry) => - deps.shouldRefreshUnchangedGeometry?.(geometry) ?? false, - setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry), - afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry), - }); -} - -export function createBuildEnsureOverlayWindowLevelMainDepsHandler( - deps: EnsureOverlayWindowLevelMainDeps, -) { - return (): EnsureOverlayWindowLevelMainDeps => ({ - shouldSuppressOverlayWindowLevel: (window: unknown) => - deps.shouldSuppressOverlayWindowLevel?.(window) ?? false, - ensureOverlayWindowLevelCore: (window: unknown) => deps.ensureOverlayWindowLevelCore(window), - afterEnsureOverlayWindowLevel: (window: unknown) => - deps.afterEnsureOverlayWindowLevel?.(window), - }); -} - -export function createBuildEnforceOverlayLayerOrderMainDepsHandler( - deps: EnforceOverlayLayerOrderMainDeps, -) { - return (): EnforceOverlayLayerOrderMainDeps => ({ - enforceOverlayLayerOrderCore: (params) => deps.enforceOverlayLayerOrderCore(params), - getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), - getMainWindow: () => deps.getMainWindow(), - ensureOverlayWindowLevel: (window: unknown) => deps.ensureOverlayWindowLevel(window), - }); -} diff --git a/src/main/runtime/overlay-window-runtime-handlers.ts b/src/main/runtime/overlay-window-runtime-handlers.ts index dadfd29d..4c8c9bcb 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.ts @@ -3,40 +3,31 @@ import { createCreateModalWindowHandler, createCreateOverlayWindowHandler, } from './overlay-window-factory'; -import { - createBuildCreateMainWindowMainDepsHandler, - createBuildCreateModalWindowMainDepsHandler, - createBuildCreateOverlayWindowMainDepsHandler, -} from './overlay-window-factory-main-deps'; -type CreateOverlayWindowMainDeps = Parameters< - typeof createBuildCreateOverlayWindowMainDepsHandler +type CreateOverlayWindowDeps = Parameters< + typeof createCreateOverlayWindowHandler >[0]; export function createOverlayWindowRuntimeHandlers(deps: { - createOverlayWindowDeps: CreateOverlayWindowMainDeps; + createOverlayWindowDeps: CreateOverlayWindowDeps; getMainWindow: () => TWindow | null; isWindowDestroyed: (window: TWindow) => boolean; setMainWindow: (window: TWindow | null) => void; setModalWindow: (window: TWindow | null) => void; }) { const createOverlayWindow = createCreateOverlayWindowHandler( - createBuildCreateOverlayWindowMainDepsHandler(deps.createOverlayWindowDeps)(), - ); - const createMainWindow = createCreateMainWindowHandler( - createBuildCreateMainWindowMainDepsHandler({ - getMainWindow: () => deps.getMainWindow(), - isWindowDestroyed: (window) => deps.isWindowDestroyed(window), - createOverlayWindow: (kind) => createOverlayWindow(kind), - setMainWindow: (window) => deps.setMainWindow(window), - })(), - ); - const createModalWindow = createCreateModalWindowHandler( - createBuildCreateModalWindowMainDepsHandler({ - createOverlayWindow: (kind) => createOverlayWindow(kind), - setModalWindow: (window) => deps.setModalWindow(window), - })(), + deps.createOverlayWindowDeps, ); + const createMainWindow = createCreateMainWindowHandler({ + getMainWindow: () => deps.getMainWindow(), + isWindowDestroyed: (window) => deps.isWindowDestroyed(window), + createOverlayWindow: (kind) => createOverlayWindow(kind), + setMainWindow: (window) => deps.setMainWindow(window), + }); + const createModalWindow = createCreateModalWindowHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setModalWindow: (window) => deps.setModalWindow(window), + }); return { createOverlayWindow, diff --git a/src/main/runtime/startup-bootstrap-deps-builder.ts b/src/main/runtime/startup-bootstrap-deps-builder.ts deleted file mode 100644 index 5cd03649..00000000 --- a/src/main/runtime/startup-bootstrap-deps-builder.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CliArgs } from '../../cli/args'; -import type { ResolvedConfig } from '../../types'; -import type { LogLevelSource } from '../../logger'; -import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; - -export function createBuildStartupBootstrapRuntimeFactoryDepsHandler( - deps: StartupBootstrapRuntimeFactoryDeps, -) { - return (): StartupBootstrapRuntimeFactoryDeps => ({ - argv: deps.argv, - parseArgs: deps.parseArgs, - setLogLevel: deps.setLogLevel, - forceX11Backend: deps.forceX11Backend, - enforceUnsupportedWaylandMode: deps.enforceUnsupportedWaylandMode, - shouldStartApp: deps.shouldStartApp, - getDefaultSocketPath: deps.getDefaultSocketPath, - defaultTexthookerPort: deps.defaultTexthookerPort, - configDir: deps.configDir, - defaultConfig: deps.defaultConfig, - generateConfigTemplate: deps.generateConfigTemplate, - generateDefaultConfigFile: deps.generateDefaultConfigFile, - onConfigGenerated: deps.onConfigGenerated, - onGenerateConfigError: deps.onGenerateConfigError, - startAppLifecycle: deps.startAppLifecycle, - }); -} - -export type { - CliArgs as StartupBuilderCliArgs, - ResolvedConfig as StartupBuilderResolvedConfig, - LogLevelSource as StartupBuilderLogLevelSource, -}; diff --git a/src/main/runtime/startup-bootstrap-main-deps.test.ts b/src/main/runtime/startup-bootstrap-main-deps.test.ts deleted file mode 100644 index 054a45bb..00000000 --- a/src/main/runtime/startup-bootstrap-main-deps.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps'; - -function buildDeps() { - const state = { exitCodes: [] as number[], quitCount: 0, errors: [] as string[] }; - const deps = createBuildStartupBootstrapMainDepsHandler({ - argv: ['node', 'main.js'], - parseArgs: () => ({}) as never, - setLogLevel: () => {}, - forceX11Backend: () => {}, - enforceUnsupportedWaylandMode: () => {}, - shouldStartApp: () => true, - getDefaultSocketPath: () => '/tmp/mpv.sock', - defaultTexthookerPort: 5174, - configDir: '/tmp/config', - defaultConfig: {} as never, - generateConfigTemplate: () => 'template', - generateDefaultConfigFile: async () => 0, - setExitCode: (code) => state.exitCodes.push(code), - quitApp: () => { - state.quitCount += 1; - }, - logGenerateConfigError: (message) => state.errors.push(message), - startAppLifecycle: () => {}, - })(); - return { deps, state }; -} - -test('onConfigGenerated exits with the generator exit code', () => { - const { deps, state } = buildDeps(); - deps.onConfigGenerated(7); - assert.deepEqual(state.exitCodes, [7]); - assert.equal(state.quitCount, 1); - assert.deepEqual(state.errors, []); -}); - -test('onGenerateConfigError logs the failure and exits with code 1', () => { - const { deps, state } = buildDeps(); - deps.onGenerateConfigError(new Error('boom')); - assert.deepEqual(state.errors, ['Failed to generate config: boom']); - assert.deepEqual(state.exitCodes, [1]); - assert.equal(state.quitCount, 1); -}); diff --git a/src/main/runtime/startup-bootstrap-main-deps.ts b/src/main/runtime/startup-bootstrap-main-deps.ts deleted file mode 100644 index 03b7d052..00000000 --- a/src/main/runtime/startup-bootstrap-main-deps.ts +++ /dev/null @@ -1,62 +0,0 @@ -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-runtime-handlers.test.ts b/src/main/runtime/startup-runtime-handlers.test.ts new file mode 100644 index 00000000..30b02eaf --- /dev/null +++ b/src/main/runtime/startup-runtime-handlers.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; +import { createStartupRuntimeHandlers } from './startup-runtime-handlers'; + +function buildBootstrapDeps() { + const state = { exitCodes: [] as number[], quitCount: 0, errors: [] as string[] }; + const captured: StartupBootstrapRuntimeFactoryDeps[] = []; + const { runAndApplyStartupState } = createStartupRuntimeHandlers({ + appLifecycleRuntimeRunnerMainDeps: {} as never, + createAppLifecycleRuntimeRunner: () => () => {}, + buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ + argv: ['node', 'main.js'], + parseArgs: () => ({}) as never, + setLogLevel: () => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: (code) => state.exitCodes.push(code), + quitApp: () => { + state.quitCount += 1; + }, + logGenerateConfigError: (message) => state.errors.push(message), + startAppLifecycle, + }), + createStartupBootstrapRuntimeDeps: (deps) => deps, + runStartupBootstrapRuntime: (deps) => { + captured.push(deps); + }, + applyStartupState: () => {}, + }); + runAndApplyStartupState(); + const [deps] = captured; + assert.ok(deps); + return { deps, state }; +} + +test('onConfigGenerated exits with the generator exit code', () => { + const { deps, state } = buildBootstrapDeps(); + deps.onConfigGenerated(7); + assert.deepEqual(state.exitCodes, [7]); + assert.equal(state.quitCount, 1); + assert.deepEqual(state.errors, []); +}); + +test('onGenerateConfigError logs the failure and exits with code 1', () => { + const { deps, state } = buildBootstrapDeps(); + deps.onGenerateConfigError(new Error('boom')); + assert.deepEqual(state.errors, ['Failed to generate config: boom']); + assert.deepEqual(state.exitCodes, [1]); + assert.equal(state.quitCount, 1); +}); diff --git a/src/main/runtime/startup-runtime-handlers.ts b/src/main/runtime/startup-runtime-handlers.ts index 4c544160..eda61080 100644 --- a/src/main/runtime/startup-runtime-handlers.ts +++ b/src/main/runtime/startup-runtime-handlers.ts @@ -1,11 +1,15 @@ -import { createBuildStartupBootstrapRuntimeFactoryDepsHandler } from './startup-bootstrap-deps-builder'; import type { AppLifecycleRuntimeRunnerParams } from '../startup-lifecycle'; -import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps'; +import type { StartupBootstrapRuntimeFactoryDeps } from '../startup'; -type StartupBootstrapMainDeps = Parameters[0]; -type StartupBootstrapRuntimeFactoryDeps = Parameters< - typeof createBuildStartupBootstrapRuntimeFactoryDepsHandler ->[0]; +// Bootstrap deps as main.ts supplies them; the config-generation exit callbacks are built here. +type StartupBootstrapMainDeps = Omit< + StartupBootstrapRuntimeFactoryDeps, + 'onConfigGenerated' | 'onGenerateConfigError' +> & { + setExitCode: (code: number) => void; + quitApp: () => void; + logGenerateConfigError: (message: string) => void; +}; export function createStartupRuntimeHandlers< TCliArgs, @@ -29,11 +33,20 @@ export function createStartupRuntimeHandlers< deps.appLifecycleRuntimeRunnerMainDeps, ); - const startupBootstrapMainDeps = createBuildStartupBootstrapMainDepsHandler( - deps.buildStartupBootstrapMainDeps(appLifecycleRuntimeRunner), - )(); - const startupBootstrapRuntimeFactoryDeps = - createBuildStartupBootstrapRuntimeFactoryDepsHandler(startupBootstrapMainDeps)(); + const { setExitCode, quitApp, logGenerateConfigError, ...bootstrapDeps } = + deps.buildStartupBootstrapMainDeps(appLifecycleRuntimeRunner); + const startupBootstrapRuntimeFactoryDeps: StartupBootstrapRuntimeFactoryDeps = { + ...bootstrapDeps, + onConfigGenerated: (exitCode) => { + setExitCode(exitCode); + quitApp(); + }, + onGenerateConfigError: (error) => { + logGenerateConfigError(`Failed to generate config: ${error.message}`); + setExitCode(1); + quitApp(); + }, + }; const runAndApplyStartupState = (): TStartupState => { const startupState = deps.runStartupBootstrapRuntime( diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts deleted file mode 100644 index a2a3b412..00000000 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer'; - -type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { - getJlptEnabled: NonNullable; - getCharacterDictionaryEnabled?: () => boolean; - getNameMatchEnabled?: NonNullable; - getNameMatchImagesEnabled?: NonNullable; - getCharacterNameImage?: NonNullable; - getCurrentCharacterDictionaryMediaId?: NonNullable< - TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId'] - >; - getCharacterNameCandidates?: NonNullable< - TokenizerDepsRuntimeOptions['getCharacterNameCandidates'] - >; - getFrequencyDictionaryEnabled: NonNullable< - TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] - >; - getFrequencyDictionaryMatchMode: NonNullable< - TokenizerDepsRuntimeOptions['getFrequencyDictionaryMatchMode'] - >; - getFrequencyRank: NonNullable; - getMinSentenceWordsForNPlusOne: NonNullable< - TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne'] - >; - getYomitanGroupDebugEnabled: NonNullable< - TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled'] - >; - recordLookup: (hit: boolean) => void; -}; - -export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { - return (): TokenizerDepsRuntimeOptions => ({ - getYomitanExt: () => deps.getYomitanExt(), - getYomitanSession: () => deps.getYomitanSession?.() ?? null, - getYomitanParserWindow: () => deps.getYomitanParserWindow(), - setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window), - getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(), - setYomitanParserReadyPromise: (promise: Promise | null) => - deps.setYomitanParserReadyPromise(promise), - getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(), - setYomitanParserInitPromise: (promise: Promise | null) => - deps.setYomitanParserInitPromise(promise), - isKnownWord: (text, reading, options) => { - const hit = deps.isKnownWord(text, reading, options); - deps.recordLookup(hit); - return hit; - }, - ...(deps.getKnownWordTier - ? { - getKnownWordTier: (text, reading, options) => - deps.getKnownWordTier!(text, reading, options), - } - : {}), - getKnownWordMatchMode: () => deps.getKnownWordMatchMode(), - ...(deps.getKnownWordsEnabled - ? { - getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(), - } - : {}), - ...(deps.getNPlusOneEnabled - ? { - getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(), - } - : {}), - getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(), - getJlptLevel: (text: string) => deps.getJlptLevel(text), - getJlptEnabled: () => deps.getJlptEnabled(), - ...(deps.getNameMatchEnabled - ? { - getNameMatchEnabled: () => - deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(), - } - : {}), - ...(deps.getNameMatchImagesEnabled - ? { - getNameMatchImagesEnabled: () => - deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(), - } - : {}), - ...(deps.getCharacterNameImage - ? { - getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term), - } - : {}), - ...(deps.getCurrentCharacterDictionaryMediaId - ? { - getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(), - } - : {}), - ...(deps.getCharacterNameCandidates - ? { - getCharacterNameCandidates: () => deps.getCharacterNameCandidates!(), - } - : {}), - getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), - getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(), - getFrequencyRank: (text: string) => deps.getFrequencyRank(text), - getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(), - getMecabTokenizer: () => deps.getMecabTokenizer(), - onTokenizationReady: (text: string) => deps.onTokenizationReady?.(text), - }); -} - -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; - showMpvOsd?: (message: string) => void; - showLoadingOsd?: (message: string) => void; - showLoadedOsd?: (message: string) => void; - shouldShowOsdNotification?: () => boolean; - setInterval?: (callback: () => void, delayMs: number) => unknown; - clearInterval?: (timer: unknown) => void; -}) { - let prewarmed = false; - let prewarmPromise: Promise | null = null; - let loadingOsdDepth = 0; - let loadingOsdFrame = 0; - let loadingOsdTimer: unknown = null; - const showMpvOsd = deps.showMpvOsd; - const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd; - const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd; - const setIntervalHandler = - deps.setInterval ?? - ((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs)); - const clearIntervalHandler = - deps.clearInterval ?? - ((timer: unknown): void => clearInterval(timer as ReturnType)); - const spinnerFrames = ['|', '/', '-', '\\']; - - const beginLoadingOsd = (): boolean => { - if (!showLoadingOsd) { - return false; - } - loadingOsdDepth += 1; - if (loadingOsdDepth > 1) { - return true; - } - - loadingOsdFrame = 0; - showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`); - loadingOsdFrame += 1; - loadingOsdTimer = setIntervalHandler(() => { - if (!showLoadingOsd) { - return; - } - showLoadingOsd( - `Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`, - ); - loadingOsdFrame += 1; - }, 180); - return true; - }; - - const endLoadingOsd = (): void => { - if (!showLoadedOsd) { - return; - } - - loadingOsdDepth = Math.max(0, loadingOsdDepth - 1); - if (loadingOsdDepth > 0) { - return; - } - - if (loadingOsdTimer) { - clearIntervalHandler(loadingOsdTimer); - loadingOsdTimer = null; - } - showLoadedOsd('Subtitle annotations loaded'); - }; - - return async (options?: { showLoadingOsd?: boolean }): Promise => { - if (prewarmed) { - return; - } - const shouldTrackLoadingOsd = options?.showLoadingOsd === true; - const loadingOsdStarted = shouldTrackLoadingOsd ? beginLoadingOsd() : false; - - if (!prewarmPromise) { - prewarmPromise = (async () => { - try { - await Promise.all([ - deps.ensureJlptDictionaryLookup(), - deps.ensureFrequencyDictionaryLookup(), - ]); - prewarmed = true; - } finally { - prewarmPromise = null; - } - })(); - } - try { - await prewarmPromise; - } finally { - if (loadingOsdStarted) { - endLoadingOsd(); - } - } - }; -} diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-warmup.test.ts similarity index 67% rename from src/main/runtime/subtitle-tokenization-main-deps.test.ts rename to src/main/runtime/subtitle-tokenization-warmup.test.ts index d85fd09c..6c830045 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-warmup.test.ts @@ -1,10 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - createBuildTokenizerDepsMainHandler, - createCreateMecabTokenizerAndCheckMainHandler, - createPrewarmSubtitleDictionariesMainHandler, -} from './subtitle-tokenization-main-deps'; + createMecabTokenizerAndCheckHandler, + createPrewarmSubtitleDictionariesHandler, +} from './subtitle-tokenization-warmup'; function createDeferred(): { promise: Promise; @@ -17,59 +16,11 @@ function createDeferred(): { return { promise, resolve }; } -type TokenizerMainDeps = Parameters[0]; - -function buildTokenizerDeps(overrides: Partial = {}) { - return createBuildTokenizerDepsMainHandler({ - getYomitanExt: () => null, - getYomitanParserWindow: () => null, - setYomitanParserWindow: () => undefined, - getYomitanParserReadyPromise: () => null, - setYomitanParserReadyPromise: () => undefined, - getYomitanParserInitPromise: () => null, - setYomitanParserInitPromise: () => undefined, - isKnownWord: () => false, - recordLookup: () => undefined, - getKnownWordMatchMode: () => 'surface', - getNPlusOneEnabled: () => true, - getMinSentenceWordsForNPlusOne: () => 3, - getJlptLevel: () => 'N2', - getJlptEnabled: () => true, - getNameMatchEnabled: () => true, - getNameMatchImagesEnabled: () => true, - getFrequencyDictionaryEnabled: () => true, - getFrequencyDictionaryMatchMode: () => 'surface', - getFrequencyRank: () => 5, - getYomitanGroupDebugEnabled: () => false, - getMecabTokenizer: () => null, - ...overrides, - })(); -} - -test('tokenizer deps builder records every known-word lookup result', () => { - const lookups: boolean[] = []; - const deps = buildTokenizerDeps({ - isKnownWord: (text) => text === 'known', - recordLookup: (hit) => lookups.push(hit), - }); - - assert.equal(deps.isKnownWord('known'), true); - assert.equal(deps.isKnownWord('unknown'), false); - assert.deepEqual(lookups, [true, false]); -}); - -test('tokenizer deps builder disables name matching when character dictionary runtime is disabled', () => { - const deps = buildTokenizerDeps({ getCharacterDictionaryEnabled: () => false }); - - assert.equal(deps.getNameMatchEnabled?.(), false); - assert.equal(deps.getNameMatchImagesEnabled?.(), false); -}); - 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({ + const run = createMecabTokenizerAndCheckHandler({ getMecabTokenizer: () => tokenizer, setMecabTokenizer: (next) => { tokenizer = next; @@ -91,7 +42,7 @@ test('mecab tokenizer check creates tokenizer once and runs availability check', test('dictionary prewarm runs both dictionary loaders', async () => { const calls: string[] = []; - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + const prewarm = createPrewarmSubtitleDictionariesHandler({ ensureJlptDictionaryLookup: async () => { calls.push('jlpt'); }, @@ -111,7 +62,7 @@ test('dictionary prewarm shows OSD spinner while loading and completion when loa const jlptDeferred = createDeferred(); const freqDeferred = createDeferred(); - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + const prewarm = createPrewarmSubtitleDictionariesHandler({ ensureJlptDictionaryLookup: async () => jlptDeferred.promise, ensureFrequencyDictionaryLookup: async () => freqDeferred.promise, shouldShowOsdNotification: () => true, @@ -157,7 +108,7 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a const jlptDeferred = createDeferred(); const freqDeferred = createDeferred(); - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + const prewarm = createPrewarmSubtitleDictionariesHandler({ ensureJlptDictionaryLookup: async () => jlptDeferred.promise, ensureFrequencyDictionaryLookup: async () => freqDeferred.promise, shouldShowOsdNotification: () => true, @@ -183,7 +134,7 @@ test('dictionary prewarm can show OSD while awaiting background-started load', a test('dictionary prewarm shows OSD when loading indicator is requested even if notification predicate is disabled', async () => { const osdMessages: string[] = []; - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + const prewarm = createPrewarmSubtitleDictionariesHandler({ ensureJlptDictionaryLookup: async () => undefined, ensureFrequencyDictionaryLookup: async () => undefined, shouldShowOsdNotification: () => false, @@ -203,7 +154,7 @@ test('dictionary prewarm clears loading OSD timer even if notifications are disa const freqDeferred = createDeferred(); let shouldShowNotification = true; - const prewarm = createPrewarmSubtitleDictionariesMainHandler({ + const prewarm = createPrewarmSubtitleDictionariesHandler({ ensureJlptDictionaryLookup: async () => jlptDeferred.promise, ensureFrequencyDictionaryLookup: async () => freqDeferred.promise, shouldShowOsdNotification: () => shouldShowNotification, diff --git a/src/main/runtime/subtitle-tokenization-warmup.ts b/src/main/runtime/subtitle-tokenization-warmup.ts new file mode 100644 index 00000000..3482fe1c --- /dev/null +++ b/src/main/runtime/subtitle-tokenization-warmup.ts @@ -0,0 +1,115 @@ +// Creates the MeCab tokenizer on first use, then runs its availability check. +export function createMecabTokenizerAndCheckHandler(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); + }; +} + +// Loads the JLPT and frequency dictionaries once (concurrent callers share the in-flight load), +// optionally showing a spinner OSD while loading. +export function createPrewarmSubtitleDictionariesHandler(deps: { + ensureJlptDictionaryLookup: () => Promise; + ensureFrequencyDictionaryLookup: () => Promise; + showMpvOsd?: (message: string) => void; + showLoadingOsd?: (message: string) => void; + showLoadedOsd?: (message: string) => void; + shouldShowOsdNotification?: () => boolean; + setInterval?: (callback: () => void, delayMs: number) => unknown; + clearInterval?: (timer: unknown) => void; +}) { + let prewarmed = false; + let prewarmPromise: Promise | null = null; + let loadingOsdDepth = 0; + let loadingOsdFrame = 0; + let loadingOsdTimer: unknown = null; + const showMpvOsd = deps.showMpvOsd; + const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd; + const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd; + const setIntervalHandler = + deps.setInterval ?? + ((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs)); + const clearIntervalHandler = + deps.clearInterval ?? + ((timer: unknown): void => clearInterval(timer as ReturnType)); + const spinnerFrames = ['|', '/', '-', '\\']; + + const beginLoadingOsd = (): boolean => { + if (!showLoadingOsd) { + return false; + } + loadingOsdDepth += 1; + if (loadingOsdDepth > 1) { + return true; + } + + loadingOsdFrame = 0; + showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`); + loadingOsdFrame += 1; + loadingOsdTimer = setIntervalHandler(() => { + if (!showLoadingOsd) { + return; + } + showLoadingOsd( + `Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`, + ); + loadingOsdFrame += 1; + }, 180); + return true; + }; + + const endLoadingOsd = (): void => { + if (!showLoadedOsd) { + return; + } + + loadingOsdDepth = Math.max(0, loadingOsdDepth - 1); + if (loadingOsdDepth > 0) { + return; + } + + if (loadingOsdTimer) { + clearIntervalHandler(loadingOsdTimer); + loadingOsdTimer = null; + } + showLoadedOsd('Subtitle annotations loaded'); + }; + + return async (options?: { showLoadingOsd?: boolean }): Promise => { + if (prewarmed) { + return; + } + const shouldTrackLoadingOsd = options?.showLoadingOsd === true; + const loadingOsdStarted = shouldTrackLoadingOsd ? beginLoadingOsd() : false; + + if (!prewarmPromise) { + prewarmPromise = (async () => { + try { + await Promise.all([ + deps.ensureJlptDictionaryLookup(), + deps.ensureFrequencyDictionaryLookup(), + ]); + prewarmed = true; + } finally { + prewarmPromise = null; + } + })(); + } + try { + await prewarmPromise; + } finally { + if (loadingOsdStarted) { + endLoadingOsd(); + } + } + }; +} diff --git a/src/main/runtime/tray-lifecycle.test.ts b/src/main/runtime/tray-lifecycle.test.ts index 5902f788..796c8fb1 100644 --- a/src/main/runtime/tray-lifecycle.test.ts +++ b/src/main/runtime/tray-lifecycle.test.ts @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle'; +import { + createDestroyTrayHandler, + createEnsureOverlayVisibleFromTrayClickHandler, + createEnsureTrayHandler, +} from './tray-lifecycle'; type EnsureTrayDeps = Parameters[0]; type TrayIcon = ReturnType; @@ -154,3 +158,18 @@ test('destroy tray handler destroys active tray and clears ref', () => { assert.equal(activeTray.destroyed, true); assert.equal(tray, null); }); + +for (const initialized of [false, true]) { + test(`tray click shows overlay${initialized ? '' : ' after booting runtime'}`, () => { + const calls: string[] = []; + createEnsureOverlayVisibleFromTrayClickHandler({ + isOverlayRuntimeInitialized: () => initialized, + initializeOverlayRuntime: () => calls.push('init-overlay'), + setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), + })(); + assert.deepEqual( + calls, + initialized ? ['set-visible:true'] : ['init-overlay', 'set-visible:true'], + ); + }); +} diff --git a/src/main/runtime/tray-lifecycle.ts b/src/main/runtime/tray-lifecycle.ts index 82fc2300..ea2343dc 100644 --- a/src/main/runtime/tray-lifecycle.ts +++ b/src/main/runtime/tray-lifecycle.ts @@ -82,3 +82,17 @@ export function createDestroyTrayHandler(deps: { deps.setTray(null); }; } + +// Tray icon click: boot the overlay runtime on first use, then show the visible overlay. +export function createEnsureOverlayVisibleFromTrayClickHandler(deps: { + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntime: () => void; + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return (): void => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.setVisibleOverlayVisible(true); + }; +} diff --git a/src/main/runtime/tray-runtime-handlers.ts b/src/main/runtime/tray-runtime-handlers.ts index b4ac5dfd..08d93ed6 100644 --- a/src/main/runtime/tray-runtime-handlers.ts +++ b/src/main/runtime/tray-runtime-handlers.ts @@ -1,8 +1,8 @@ -import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle'; import { - createBuildDestroyTrayMainDepsHandler, - createBuildEnsureTrayMainDepsHandler, -} from './app-runtime-main-deps'; + createDestroyTrayHandler, + createEnsureOverlayVisibleFromTrayClickHandler, + createEnsureTrayHandler, +} from './tray-lifecycle'; import { createBuildTrayMenuTemplateHandler, createResolveTrayIconPathHandler, @@ -12,34 +12,41 @@ type ResolveTrayIconPathMainDeps = Parameters = Parameters< typeof createBuildTrayMenuTemplateHandler >[0]; -type EnsureTrayMainDeps = Parameters< - typeof createBuildEnsureTrayMainDepsHandler ->[0]; -type TrayLike = NonNullable[0]['getTray']>>; -type TrayIconLike = Parameters[0]['createTray']>[0]; -type DestroyTrayMainDeps = Parameters>[0]; +type EnsureTrayDeps = Parameters[0]; +type DestroyTrayDeps = Parameters[0]; export function createTrayRuntimeHandlers(deps: { resolveTrayIconPathDeps: ResolveTrayIconPathMainDeps; buildTrayMenuTemplateDeps: BuildTrayMenuTemplateMainDeps; - ensureTrayDeps: Omit, 'buildTrayMenu' | 'resolveTrayIconPath'>; - destroyTrayDeps: DestroyTrayMainDeps; + ensureTrayDeps: Omit< + EnsureTrayDeps, + 'buildTrayMenu' | 'resolveTrayIconPath' | 'ensureOverlayVisibleFromTrayClick' + > & + Parameters[0]; + destroyTrayDeps: DestroyTrayDeps; buildMenuFromTemplate: (template: TMenuItem[]) => TMenu; }) { const resolveTrayIconPath = createResolveTrayIconPathHandler(deps.resolveTrayIconPathDeps); const buildTrayMenuTemplate = createBuildTrayMenuTemplateHandler(deps.buildTrayMenuTemplateDeps); const buildTrayMenu = () => deps.buildMenuFromTemplate(buildTrayMenuTemplate()); - const ensureTray = createEnsureTrayHandler( - createBuildEnsureTrayMainDepsHandler({ - ...deps.ensureTrayDeps, - buildTrayMenu: () => buildTrayMenu(), - resolveTrayIconPath: () => resolveTrayIconPath(), - })(), - ); - const destroyTray = createDestroyTrayHandler( - createBuildDestroyTrayMainDepsHandler(deps.destroyTrayDeps)(), - ); + const { + initializeOverlayRuntime, + isOverlayRuntimeInitialized, + setVisibleOverlayVisible, + ...ensureTrayDeps + } = deps.ensureTrayDeps; + const ensureTray = createEnsureTrayHandler({ + ...ensureTrayDeps, + buildTrayMenu: () => buildTrayMenu(), + resolveTrayIconPath: () => resolveTrayIconPath(), + ensureOverlayVisibleFromTrayClick: createEnsureOverlayVisibleFromTrayClickHandler({ + initializeOverlayRuntime, + isOverlayRuntimeInitialized, + setVisibleOverlayVisible, + }), + }); + const destroyTray = createDestroyTrayHandler(deps.destroyTrayDeps); return { resolveTrayIconPath, diff --git a/src/main/runtime/yomitan-settings-runtime.ts b/src/main/runtime/yomitan-settings-runtime.ts index 99b610b9..b2fad1d5 100644 --- a/src/main/runtime/yomitan-settings-runtime.ts +++ b/src/main/runtime/yomitan-settings-runtime.ts @@ -1,15 +1,9 @@ -import { createBuildOpenYomitanSettingsMainDepsHandler } from './app-runtime-main-deps'; import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener'; -type OpenYomitanSettingsMainDeps = Parameters< - typeof createBuildOpenYomitanSettingsMainDepsHandler ->[0]; - -export function createYomitanSettingsRuntime(deps: OpenYomitanSettingsMainDeps) { - const openYomitanSettingsMainDeps = createBuildOpenYomitanSettingsMainDepsHandler(deps)(); - const openYomitanSettings = createOpenYomitanSettingsHandler(openYomitanSettingsMainDeps); - +export function createYomitanSettingsRuntime( + deps: Parameters[0], +) { return { - openYomitanSettings, + openYomitanSettings: createOpenYomitanSettingsHandler(deps), }; }