diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 1f7d628..c7ab649 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -6,7 +6,7 @@ Read first. Keep concise. | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | | `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T02:56:34Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T03:27:35Z` | | `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | | `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | | `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | diff --git a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md index e947882..4423254 100644 --- a/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md +++ b/docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md @@ -9,6 +9,17 @@ ## Current Work (newest first) +- [2026-02-20T03:27:35Z] progress: extracted CLI context deps builder into `src/main/runtime/cli-command-context-deps.ts` (`createBuildCliCommandContextDepsHandler`) and rewired `handleCliCommand` to build deps through the helper. +- [2026-02-20T03:27:35Z] progress: extracted overlay runtime options builder into `src/main/runtime/overlay-runtime-options.ts` (`createBuildInitializeOverlayRuntimeOptionsHandler`) and rewired `initializeOverlayRuntime` `buildOptions`; extracted Yomitan extension load wrappers into `src/main/runtime/yomitan-extension-loader.ts` (`createLoadYomitanExtensionHandler`, `createEnsureYomitanExtensionLoadedHandler`) and rewired `loadYomitanExtension` + `ensureYomitanExtensionLoaded`. +- [2026-02-20T03:27:35Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-context-deps.test.js dist/main/runtime/overlay-runtime-options.test.js dist/main/runtime/yomitan-extension-loader.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/ipc-bridge-actions.test.js dist/main/runtime/overlay-main-actions.test.js dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (116/116). +- [2026-02-20T03:19:44Z] progress: extracted tray wrappers into `src/main/runtime/tray-main-actions.ts` (`createResolveTrayIconPathHandler`, `createBuildTrayMenuTemplateHandler`) and rewired `resolveTrayIconPath` + `buildTrayMenu`. +- [2026-02-20T03:19:44Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/tray-main-actions.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/ipc-bridge-actions.test.js dist/main/runtime/overlay-main-actions.test.js dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (110/110). +- [2026-02-20T03:17:35Z] progress: extracted overlay window creation wrappers into `src/main/runtime/overlay-window-factory.ts` (`createCreateOverlayWindowHandler`, `createCreateMainWindowHandler`, `createCreateInvisibleWindowHandler`) and rewired `createOverlayWindow`, `createMainWindow`, `createInvisibleWindow`. +- [2026-02-20T03:17:35Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/ipc-bridge-actions.test.js dist/main/runtime/overlay-main-actions.test.js dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (108/108). +- [2026-02-20T03:14:23Z] progress: extracted thin wrapper cluster into `src/main/runtime/overlay-main-actions.ts` (`createSetOverlayVisibleHandler`, `createToggleOverlayHandler`, `createHandleOverlayModalClosedHandler`, `createAppendClipboardVideoToQueueHandler`) and rewired `setOverlayVisible`, `toggleOverlay`, `handleOverlayModalClosed`, `appendClipboardVideoToQueue`. +- [2026-02-20T03:14:23Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-main-actions.test.js dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (103/103). +- [2026-02-20T03:11:11Z] progress: extracted overlay visibility set/toggle wrappers into `src/main/runtime/overlay-visibility-actions.ts` (`createSetVisibleOverlayVisibleHandler`, `createSetInvisibleOverlayVisibleHandler`, `createToggleVisibleOverlayHandler`, `createToggleInvisibleOverlayHandler`) and rewired corresponding `main.ts` functions. +- [2026-02-20T03:11:11Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-visibility-actions.test.js dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (99/99). - [2026-02-20T02:56:34Z] progress: extracted subtitle copy/mine wrapper cluster into `src/main/runtime/mining-actions.ts` (`createHandleMultiCopyDigitHandler`, `createCopyCurrentSubtitleHandler`, `createHandleMineSentenceDigitHandler`) and rewired `handleMultiCopyDigit`, `copyCurrentSubtitle`, `handleMineSentenceDigit`. - [2026-02-20T02:56:34Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/mining-actions.test.js dist/main/runtime/anki-actions.test.js dist/main/runtime/overlay-shortcuts-lifecycle.test.js dist/main/runtime/numeric-shortcut-session-handlers.test.js dist/main/runtime/mpv-osd-log.test.js dist/main/runtime/global-shortcuts.test.js dist/main/runtime/yomitan-settings-opener.test.js dist/main/runtime/overlay-runtime-bootstrap.test.js dist/main/runtime/tray-lifecycle.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/startup-warmups.test.js dist/main/runtime/mpv-subtitle-render-metrics.test.js dist/main/runtime/mpv-client-runtime-service.test.js dist/main/runtime/mpv-client-event-bindings.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/anilist-setup-window.test.js dist/main/runtime/jellyfin-setup-window.test.js dist/main/runtime/jellyfin-remote-session-lifecycle.test.js dist/main/runtime/jellyfin-cli-auth.test.js dist/main/runtime/jellyfin-cli-list.test.js dist/main/runtime/jellyfin-cli-play.test.js dist/main/runtime/jellyfin-cli-remote-announce.test.js` pass (95/95). - [2026-02-20T02:55:17Z] progress: extracted Anki action wrappers into `src/main/runtime/anki-actions.ts` (`createUpdateLastCardFromClipboardHandler`, `createRefreshKnownWordCacheHandler`, `createTriggerFieldGroupingHandler`, `createMarkLastCardAsAudioCardHandler`, `createMineSentenceCardHandler`) and rewired `main.ts` handlers. @@ -130,6 +141,22 @@ - `src/main/runtime/anki-actions.test.ts` - `src/main/runtime/mining-actions.ts` - `src/main/runtime/mining-actions.test.ts` +- `src/main/runtime/overlay-visibility-actions.ts` +- `src/main/runtime/overlay-visibility-actions.test.ts` +- `src/main/runtime/overlay-main-actions.ts` +- `src/main/runtime/overlay-main-actions.test.ts` +- `src/main/runtime/ipc-bridge-actions.ts` +- `src/main/runtime/ipc-bridge-actions.test.ts` +- `src/main/runtime/overlay-window-factory.ts` +- `src/main/runtime/overlay-window-factory.test.ts` +- `src/main/runtime/tray-main-actions.ts` +- `src/main/runtime/tray-main-actions.test.ts` +- `src/main/runtime/yomitan-extension-loader.ts` +- `src/main/runtime/yomitan-extension-loader.test.ts` +- `src/main/runtime/overlay-runtime-options.ts` +- `src/main/runtime/overlay-runtime-options.test.ts` +- `src/main/runtime/cli-command-context-deps.ts` +- `src/main/runtime/cli-command-context-deps.test.ts` - `src/main/runtime/anilist-token-refresh.ts` - `src/main/runtime/anilist-token-refresh.test.ts` - `src/main/runtime/anilist-media-guess.ts` @@ -161,4 +188,4 @@ ## Next Step -- extract next `src/main.ts` overlay visibility state wrappers (`setVisibleOverlayVisible`, `setInvisibleOverlayVisible`, `toggleVisibleOverlay`, `toggleInvisibleOverlay`) into runtime helper + tests. +- extract next `src/main.ts` app-lifecycle cleanup/restore wrapper cluster in startup lifecycle wiring (`onWillQuitCleanup`, `restoreWindowsOnActivate`) into runtime helper + tests. diff --git a/src/main.ts b/src/main.ts index f167658..99815d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -208,6 +208,37 @@ import { createHandleMineSentenceDigitHandler, createHandleMultiCopyDigitHandler, } from './main/runtime/mining-actions'; +import { + createSetInvisibleOverlayVisibleHandler, + createSetVisibleOverlayVisibleHandler, + createToggleInvisibleOverlayHandler, + createToggleVisibleOverlayHandler, +} from './main/runtime/overlay-visibility-actions'; +import { + createAppendClipboardVideoToQueueHandler, + createHandleOverlayModalClosedHandler, + createSetOverlayVisibleHandler, + createToggleOverlayHandler, +} from './main/runtime/overlay-main-actions'; +import { + createHandleMpvCommandFromIpcHandler, + createRunSubsyncManualFromIpcHandler, +} from './main/runtime/ipc-bridge-actions'; +import { + createCreateInvisibleWindowHandler, + createCreateMainWindowHandler, + createCreateOverlayWindowHandler, +} from './main/runtime/overlay-window-factory'; +import { + createBuildTrayMenuTemplateHandler, + createResolveTrayIconPathHandler, +} from './main/runtime/tray-main-actions'; +import { + createEnsureYomitanExtensionLoadedHandler, + createLoadYomitanExtensionHandler, +} from './main/runtime/yomitan-extension-loader'; +import { createBuildInitializeOverlayRuntimeOptionsHandler } from './main/runtime/overlay-runtime-options'; +import { createBuildCliCommandContextDepsHandler } from './main/runtime/cli-command-context-deps'; import { buildRestartRequiredConfigMessage, createConfigHotReloadAppliedHandler, @@ -1926,57 +1957,7 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): logInfo: (message) => logger.info(message), })(args); - const cliContext = createCliCommandContext({ - getSocketPath: () => appState.mpvSocketPath, - setSocketPath: (socketPath: string) => { - appState.mpvSocketPath = socketPath; - }, - getMpvClient: () => appState.mpvClient, - showOsd: (text: string) => showMpvOsd(text), - texthookerService, - getTexthookerPort: () => appState.texthookerPort, - setTexthookerPort: (port: number) => { - appState.texthookerPort = port; - }, - shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, - openExternal: (url: string) => shell.openExternal(url), - logBrowserOpenError: (url: string, error: unknown) => - logger.error(`Failed to open browser for texthooker URL: ${url}`, error), - isOverlayInitialized: () => appState.overlayRuntimeInitialized, - initializeOverlay: () => initializeOverlayRuntime(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), - toggleInvisibleOverlay: () => toggleInvisibleOverlay(), - setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible), - setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible), - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - refreshKnownWordCache: () => refreshKnownWordCache(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), - clearAnilistToken: () => anilistStateRuntime.clearTokenState(), - openAnilistSetup: () => openAnilistSetupWindow(), - openJellyfinSetup: () => openJellyfinSetupWindow(), - getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), - retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), - runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - stopApp: () => app.quit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), - getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, - schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - logInfo: (message: string) => logger.info(message), - logWarn: (message: string) => logger.warn(message), - logError: (message: string, err: unknown) => logger.error(message, err), - }); + const cliContext = createCliCommandContext(buildCliCommandContextDepsHandler()); handleCliCommandRuntimeServiceWithContext(args, source, cliContext); } @@ -2213,112 +2194,30 @@ const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler({ }); async function loadYomitanExtension(): Promise { - return loadYomitanExtensionCore({ - userDataPath: USER_DATA_PATH, - getYomitanParserWindow: () => appState.yomitanParserWindow, - setYomitanParserWindow: (window) => { - appState.yomitanParserWindow = window; - }, - setYomitanParserReadyPromise: (promise) => { - appState.yomitanParserReadyPromise = promise; - }, - setYomitanParserInitPromise: (promise) => { - appState.yomitanParserInitPromise = promise; - }, - setYomitanExtension: (extension) => { - appState.yomitanExt = extension; - }, - }); + return loadYomitanExtensionHandler(); } async function ensureYomitanExtensionLoaded(): Promise { - if (appState.yomitanExt) { - return appState.yomitanExt; - } - if (yomitanLoadInFlight) { - return yomitanLoadInFlight; - } - - yomitanLoadInFlight = loadYomitanExtension().finally(() => { - yomitanLoadInFlight = null; - }); - return yomitanLoadInFlight; + return ensureYomitanExtensionLoadedHandler(); } function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow { - return createOverlayWindowCore(kind, { - isDev, - overlayDebugVisualizationEnabled: appState.overlayDebugVisualizationEnabled, - ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), - onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), - setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), - isOverlayVisible: (windowKind) => - windowKind === 'visible' - ? overlayManager.getVisibleOverlayVisible() - : overlayManager.getInvisibleOverlayVisible(), - tryHandleOverlayShortcutLocalFallback: (input) => - overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), - onWindowClosed: (windowKind) => { - if (windowKind === 'visible') { - overlayManager.setMainWindow(null); - } else { - overlayManager.setInvisibleWindow(null); - } - }, - }); + return createOverlayWindowHandler(kind); } function createMainWindow(): BrowserWindow { - const window = createOverlayWindow('visible'); - overlayManager.setMainWindow(window); - return window; + return createMainWindowHandler(); } function createInvisibleWindow(): BrowserWindow { - const window = createOverlayWindow('invisible'); - overlayManager.setInvisibleWindow(window); - return window; + return createInvisibleWindowHandler(); } function resolveTrayIconPath(): string | null { - return resolveTrayIconPathRuntime({ - platform: process.platform, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath(), - dirname: __dirname, - joinPath: (...parts) => path.join(...parts), - fileExists: (candidate) => fs.existsSync(candidate), - }); + return resolveTrayIconPathHandler(); } function buildTrayMenu(): Menu { - return Menu.buildFromTemplate( - buildTrayMenuTemplateRuntime({ - openOverlay: () => { - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - } - setVisibleOverlayVisible(true); - }, - openYomitanSettings: () => { - openYomitanSettings(); - }, - openRuntimeOptions: () => { - if (!appState.overlayRuntimeInitialized) { - initializeOverlayRuntime(); - } - openRuntimeOptionsPalette(); - }, - openJellyfinSetup: () => { - openJellyfinSetupWindow(); - }, - openAnilistSetup: () => { - openAnilistSetupWindow(); - }, - quitApp: () => { - app.quit(); - }, - }), - ); + return Menu.buildFromTemplate(buildTrayMenuTemplateHandler()); } function ensureTray(): void { @@ -2357,50 +2256,7 @@ function initializeOverlayRuntime(): void { createInitializeOverlayRuntimeHandler({ isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options), - buildOptions: () => ({ - backendOverride: appState.backendOverride, - getInitialInvisibleOverlayVisibility: () => - configDerivedRuntime.getInitialInvisibleOverlayVisibility(), - createMainWindow: () => { - createMainWindow(); - }, - createInvisibleWindow: () => { - createInvisibleWindow(); - }, - registerGlobalShortcuts: () => { - registerGlobalShortcuts(); - }, - updateVisibleOverlayBounds: (geometry) => { - updateVisibleOverlayBounds(geometry); - }, - updateInvisibleOverlayBounds: (geometry) => { - updateInvisibleOverlayBounds(geometry); - }, - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), - updateVisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - }, - updateInvisibleOverlayVisibility: () => { - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); - }, - getOverlayWindows: () => getOverlayWindows(), - syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), - setWindowTracker: (tracker) => { - appState.windowTracker = tracker; - }, - getResolvedConfig: () => getResolvedConfig(), - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, - getMpvClient: () => appState.mpvClient, - getMpvSocketPath: () => appState.mpvSocketPath, - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - setAnkiIntegration: (integration) => { - appState.ankiIntegration = integration as AnkiIntegration | null; - }, - showDesktopNotification, - createFieldGroupingCallback: () => createFieldGroupingCallback(), - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - }), + buildOptions: () => buildInitializeOverlayRuntimeOptionsHandler(), setInvisibleOverlayVisible: (visible) => { overlayManager.setInvisibleOverlayVisible(visible); }, @@ -2627,6 +2483,252 @@ const handleMineSentenceDigitHandler = createHandleMineSentenceDigitHandler({ }, handleMineSentenceDigitCore, }); +const setVisibleOverlayVisibleHandler = createSetVisibleOverlayVisibleHandler({ + setVisibleOverlayVisibleCore, + setVisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setVisibleOverlayVisible(nextVisible); + }, + updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + updateInvisibleOverlayVisibility: () => overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + setMpvSubVisibility: (mpvSubVisible) => { + setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible); + }, +}); +const setInvisibleOverlayVisibleHandler = createSetInvisibleOverlayVisibleHandler({ + setInvisibleOverlayVisibleCore, + setInvisibleOverlayVisibleState: (nextVisible) => { + overlayManager.setInvisibleOverlayVisible(nextVisible); + }, + updateInvisibleOverlayVisibility: () => overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), + syncInvisibleOverlayMousePassthrough: () => + overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), +}); +const toggleVisibleOverlayHandler = createToggleVisibleOverlayHandler({ + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), +}); +const toggleInvisibleOverlayHandler = createToggleInvisibleOverlayHandler({ + getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + setInvisibleOverlayVisible: (visible) => setInvisibleOverlayVisible(visible), +}); +const setOverlayVisibleHandler = createSetOverlayVisibleHandler({ + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), +}); +const toggleOverlayHandler = createToggleOverlayHandler({ + toggleVisibleOverlay: () => toggleVisibleOverlay(), +}); +const handleOverlayModalClosedHandler = createHandleOverlayModalClosedHandler({ + handleOverlayModalClosedRuntime: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), +}); +const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHandler({ + appendClipboardVideoToQueueRuntime, + getMpvClient: () => appState.mpvClient, + readClipboardText: () => clipboard.readText(), + showMpvOsd: (text) => showMpvOsd(text), + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, +}); +const handleMpvCommandFromIpcHandler = createHandleMpvCommandFromIpcHandler({ + handleMpvCommandFromIpcRuntime, + buildMpvCommandDeps: () => ({ + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + cycleRuntimeOption: (id, direction) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return applyRuntimeOptionResultRuntime( + appState.runtimeOptionsManager.cycleOption(id, direction), + (text) => showMpvOsd(text), + ); + }, + showMpvOsd: (text: string) => showMpvOsd(text), + replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), + playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), + sendMpvCommand: (rawCommand: (string | number)[]) => + sendMpvCommandRuntime(appState.mpvClient, rawCommand), + isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), + hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, + }), +}); +const runSubsyncManualFromIpcHandler = createRunSubsyncManualFromIpcHandler({ + runManualFromIpc: (request: SubsyncManualRunRequest) => subsyncRuntime.runManualFromIpc(request), +}); +const buildCliCommandContextDepsHandler = createBuildCliCommandContextDepsHandler({ + getSocketPath: () => appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + appState.mpvSocketPath = socketPath; + }, + getMpvClient: () => appState.mpvClient, + showOsd: (text: string) => showMpvOsd(text), + texthookerService, + getTexthookerPort: () => appState.texthookerPort, + setTexthookerPort: (port: number) => { + appState.texthookerPort = port; + }, + shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, + openExternal: (url: string) => shell.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + logger.error(`Failed to open browser for texthooker URL: ${url}`, error), + isOverlayInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlay: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + refreshKnownWordCache: () => refreshKnownWordCache(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), + clearAnilistToken: () => anilistStateRuntime.clearTokenState(), + openAnilistSetup: () => openAnilistSetupWindow(), + openJellyfinSetup: () => openJellyfinSetupWindow(), + getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, + schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), + logInfo: (message: string) => logger.info(message), + logWarn: (message: string) => logger.warn(message), + logError: (message: string, err: unknown) => logger.error(message, err), +}); +const createOverlayWindowHandler = createCreateOverlayWindowHandler({ + createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), + isDev, + getOverlayDebugVisualizationEnabled: () => appState.overlayDebugVisualizationEnabled, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), + setOverlayDebugVisualizationEnabled: (enabled) => setOverlayDebugVisualizationEnabled(enabled), + isOverlayVisible: (windowKind) => + windowKind === 'visible' + ? overlayManager.getVisibleOverlayVisible() + : overlayManager.getInvisibleOverlayVisible(), + tryHandleOverlayShortcutLocalFallback: (input) => + overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + onWindowClosed: (windowKind) => { + if (windowKind === 'visible') { + overlayManager.setMainWindow(null); + } else { + overlayManager.setInvisibleWindow(null); + } + }, +}); +const createMainWindowHandler = createCreateMainWindowHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setMainWindow: (window) => overlayManager.setMainWindow(window), +}); +const createInvisibleWindowHandler = createCreateInvisibleWindowHandler({ + createOverlayWindow: (kind) => createOverlayWindow(kind), + setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window), +}); +const resolveTrayIconPathHandler = createResolveTrayIconPathHandler({ + resolveTrayIconPathRuntime, + platform: process.platform, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + dirname: __dirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), +}); +const buildTrayMenuTemplateHandler = createBuildTrayMenuTemplateHandler({ + buildTrayMenuTemplateRuntime, + initializeOverlayRuntime: () => initializeOverlayRuntime(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible), + openYomitanSettings: () => openYomitanSettings(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJellyfinSetupWindow: () => openJellyfinSetupWindow(), + openAnilistSetupWindow: () => openAnilistSetupWindow(), + quitApp: () => app.quit(), +}); +const loadYomitanExtensionHandler = createLoadYomitanExtensionHandler({ + loadYomitanExtensionCore, + userDataPath: USER_DATA_PATH, + getYomitanParserWindow: () => appState.yomitanParserWindow, + setYomitanParserWindow: (window) => { + appState.yomitanParserWindow = window as BrowserWindow | null; + }, + setYomitanParserReadyPromise: (promise) => { + appState.yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + appState.yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extension) => { + appState.yomitanExt = extension; + }, +}); +const ensureYomitanExtensionLoadedHandler = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => appState.yomitanExt, + getLoadInFlight: () => yomitanLoadInFlight, + setLoadInFlight: (promise) => { + yomitanLoadInFlight = promise; + }, + loadYomitanExtension: () => loadYomitanExtension(), +}); +const buildInitializeOverlayRuntimeOptionsHandler = createBuildInitializeOverlayRuntimeOptionsHandler({ + getBackendOverride: () => appState.backendOverride, + getInitialInvisibleOverlayVisibility: () => + configDerivedRuntime.getInitialInvisibleOverlayVisibility(), + createMainWindow: () => { + createMainWindow(); + }, + createInvisibleWindow: () => { + createInvisibleWindow(); + }, + registerGlobalShortcuts: () => { + registerGlobalShortcuts(); + }, + updateVisibleOverlayBounds: (geometry) => { + updateVisibleOverlayBounds(geometry); + }, + updateInvisibleOverlayBounds: (geometry) => { + updateInvisibleOverlayBounds(geometry); + }, + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + isInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(), + updateVisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + updateInvisibleOverlayVisibility: () => { + overlayVisibilityRuntime.updateInvisibleOverlayVisibility(); + }, + getOverlayWindows: () => getOverlayWindows(), + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), + setWindowTracker: (tracker) => { + appState.windowTracker = tracker as never; + }, + getResolvedConfig: () => getResolvedConfig(), + getSubtitleTimingTracker: () => appState.subtitleTimingTracker, + getMpvClient: () => appState.mpvClient, + getMpvSocketPath: () => appState.mpvSocketPath, + getRuntimeOptionsManager: () => appState.runtimeOptionsManager, + setAnkiIntegration: (integration) => { + appState.ankiIntegration = integration as AnkiIntegration | null; + }, + showDesktopNotification, + createFieldGroupingCallback: () => createFieldGroupingCallback() as never, + getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), +}); async function updateLastCardFromClipboard(): Promise { await updateLastCardFromClipboardHandler(); @@ -2676,90 +2778,39 @@ function refreshOverlayShortcuts(): void { } function setVisibleOverlayVisible(visible: boolean): void { - setVisibleOverlayVisibleCore({ - visible, - setVisibleOverlayVisibleState: (nextVisible) => { - overlayManager.setVisibleOverlayVisible(nextVisible); - }, - updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), - updateInvisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), - shouldBindVisibleOverlayToMpvSubVisibility: () => - configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), - isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), - setMpvSubVisibility: (mpvSubVisible) => { - setMpvSubVisibilityRuntime(appState.mpvClient, mpvSubVisible); - }, - }); + setVisibleOverlayVisibleHandler(visible); } function setInvisibleOverlayVisible(visible: boolean): void { - setInvisibleOverlayVisibleCore({ - visible, - setInvisibleOverlayVisibleState: (nextVisible) => { - overlayManager.setInvisibleOverlayVisible(nextVisible); - }, - updateInvisibleOverlayVisibility: () => - overlayVisibilityRuntime.updateInvisibleOverlayVisibility(), - syncInvisibleOverlayMousePassthrough: () => - overlayVisibilityRuntime.syncInvisibleOverlayMousePassthrough(), - }); + setInvisibleOverlayVisibleHandler(visible); } function toggleVisibleOverlay(): void { - setVisibleOverlayVisible(!overlayManager.getVisibleOverlayVisible()); + toggleVisibleOverlayHandler(); } function toggleInvisibleOverlay(): void { - setInvisibleOverlayVisible(!overlayManager.getInvisibleOverlayVisible()); + toggleInvisibleOverlayHandler(); } function setOverlayVisible(visible: boolean): void { - setVisibleOverlayVisible(visible); + setOverlayVisibleHandler(visible); } function toggleOverlay(): void { - toggleVisibleOverlay(); + toggleOverlayHandler(); } function handleOverlayModalClosed(modal: OverlayHostedModal): void { - overlayModalRuntime.handleOverlayModalClosed(modal); + handleOverlayModalClosedHandler(modal); } function handleMpvCommandFromIpc(command: (string | number)[]): void { - handleMpvCommandFromIpcRuntime(command, { - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - cycleRuntimeOption: (id, direction) => { - if (!appState.runtimeOptionsManager) { - return { ok: false, error: 'Runtime options manager unavailable' }; - } - return applyRuntimeOptionResultRuntime( - appState.runtimeOptionsManager.cycleOption(id, direction), - (text) => showMpvOsd(text), - ); - }, - showMpvOsd: (text: string) => showMpvOsd(text), - replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), - playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), - sendMpvCommand: (rawCommand: (string | number)[]) => - sendMpvCommandRuntime(appState.mpvClient, rawCommand), - isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), - hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, - }); + handleMpvCommandFromIpcHandler(command); } async function runSubsyncManualFromIpc(request: SubsyncManualRunRequest): Promise { - return subsyncRuntime.runManualFromIpc(request); + return runSubsyncManualFromIpcHandler(request); } function appendClipboardVideoToQueue(): { ok: boolean; message: string } { - return appendClipboardVideoToQueueRuntime({ - getMpvClient: () => appState.mpvClient, - readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), - sendMpvCommand: (command) => { - sendMpvCommandRuntime(appState.mpvClient, command); - }, - }); + return appendClipboardVideoToQueueHandler(); } registerIpcRuntimeServices({ diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts new file mode 100644 index 0000000..53540b6 --- /dev/null +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps'; + +test('build cli command context deps maps handlers and values', () => { + const calls: string[] = []; + const buildDeps = createBuildCliCommandContextDepsHandler({ + getSocketPath: () => '/tmp/mpv.sock', + setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`), + getMpvClient: () => null, + showOsd: (text) => calls.push(`osd:${text}`), + texthookerService: { start: () => null, status: () => ({ running: false }) } as never, + getTexthookerPort: () => 5174, + setTexthookerPort: (port) => calls.push(`port:${port}`), + shouldOpenBrowser: () => true, + openExternal: async (url) => calls.push(`open:${url}`), + logBrowserOpenError: (url) => calls.push(`open-error:${url}`), + isOverlayInitialized: () => true, + initializeOverlay: () => calls.push('init'), + toggleVisibleOverlay: () => calls.push('toggle-visible'), + toggleInvisibleOverlay: () => calls.push('toggle-invisible'), + setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), + setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`), + copyCurrentSubtitle: () => calls.push('copy'), + startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`), + mineSentenceCard: async () => { + calls.push('mine'); + }, + startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`), + updateLastCardFromClipboard: async () => { + calls.push('update'); + }, + refreshKnownWordCache: async () => { + calls.push('refresh'); + }, + triggerFieldGrouping: async () => { + calls.push('group'); + }, + triggerSubsyncFromConfig: async () => { + calls.push('subsync'); + }, + markLastCardAsAudioCard: async () => { + calls.push('mark'); + }, + getAnilistStatus: () => ({}) as never, + clearAnilistToken: () => calls.push('clear-token'), + openAnilistSetup: () => calls.push('anilist'), + openJellyfinSetup: () => calls.push('jellyfin'), + getAnilistQueueStatus: () => ({}) as never, + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + runJellyfinCommand: async () => { + calls.push('run-jellyfin'); + }, + openYomitanSettings: () => calls.push('yomitan'), + cycleSecondarySubMode: () => calls.push('cycle-secondary'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + printHelp: () => calls.push('help'), + stopApp: () => calls.push('stop'), + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 5000, + schedule: (fn) => { + fn(); + return setTimeout(() => {}, 0); + }, + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + logError: (message) => calls.push(`error:${message}`), + }); + + const deps = buildDeps(); + assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); + assert.equal(deps.getTexthookerPort(), 5174); + assert.equal(deps.shouldOpenBrowser(), true); + assert.equal(deps.isOverlayInitialized(), true); + assert.equal(deps.hasMainWindow(), true); + assert.equal(deps.getMultiCopyTimeoutMs(), 5000); + + deps.setSocketPath('/tmp/next.sock'); + deps.showOsd('hello'); + deps.setTexthookerPort(5175); + deps.printHelp(); + assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']); +}); diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts new file mode 100644 index 0000000..f268210 --- /dev/null +++ b/src/main/runtime/cli-command-context-deps.ts @@ -0,0 +1,94 @@ +import type { CliArgs } from '../../cli/args'; +import type { CliCommandContextFactoryDeps } from './cli-command-context'; + +export function createBuildCliCommandContextDepsHandler(deps: { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getMpvClient: CliCommandContextFactoryDeps['getMpvClient']; + showOsd: (text: string) => void; + texthookerService: CliCommandContextFactoryDeps['texthookerService']; + getTexthookerPort: () => number; + setTexthookerPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openExternal: (url: string) => Promise; + logBrowserOpenError: (url: string, error: unknown) => void; + isOverlayInitialized: () => boolean; + initializeOverlay: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlay: (visible: boolean) => void; + setInvisibleOverlay: (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; + getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; + clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; + openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; + openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup']; + getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; + retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; + runJellyfinCommand: (args: CliArgs) => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + logInfo: (message: string) => void; + logWarn: (message: string) => void; + logError: (message: string, err: unknown) => void; +}) { + return (): CliCommandContextFactoryDeps => ({ + getSocketPath: deps.getSocketPath, + setSocketPath: deps.setSocketPath, + getMpvClient: deps.getMpvClient, + showOsd: deps.showOsd, + texthookerService: deps.texthookerService, + getTexthookerPort: deps.getTexthookerPort, + setTexthookerPort: deps.setTexthookerPort, + shouldOpenBrowser: deps.shouldOpenBrowser, + openExternal: deps.openExternal, + logBrowserOpenError: deps.logBrowserOpenError, + isOverlayInitialized: deps.isOverlayInitialized, + initializeOverlay: deps.initializeOverlay, + toggleVisibleOverlay: deps.toggleVisibleOverlay, + toggleInvisibleOverlay: deps.toggleInvisibleOverlay, + setVisibleOverlay: deps.setVisibleOverlay, + setInvisibleOverlay: deps.setInvisibleOverlay, + 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, + getAnilistStatus: deps.getAnilistStatus, + clearAnilistToken: deps.clearAnilistToken, + openAnilistSetup: deps.openAnilistSetup, + openJellyfinSetup: deps.openJellyfinSetup, + getAnilistQueueStatus: deps.getAnilistQueueStatus, + retryAnilistQueueNow: deps.retryAnilistQueueNow, + runJellyfinCommand: deps.runJellyfinCommand, + openYomitanSettings: deps.openYomitanSettings, + cycleSecondarySubMode: deps.cycleSecondarySubMode, + openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + printHelp: deps.printHelp, + stopApp: deps.stopApp, + hasMainWindow: deps.hasMainWindow, + getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, + schedule: deps.schedule, + logInfo: deps.logInfo, + logWarn: deps.logWarn, + logError: deps.logError, + }); +} diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts new file mode 100644 index 0000000..4c2b82f --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHandleMpvCommandFromIpcHandler, + createRunSubsyncManualFromIpcHandler, +} from './ipc-bridge-actions'; + +test('handle mpv command handler forwards command and built deps', () => { + const calls: string[] = []; + const deps = { + triggerSubsyncFromConfig: () => {}, + openRuntimeOptionsPalette: () => {}, + cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), + showMpvOsd: () => {}, + replayCurrentSubtitle: () => {}, + playNextSubtitle: () => {}, + sendMpvCommand: () => {}, + isMpvConnected: () => true, + hasRuntimeOptionsManager: () => true, + }; + const handle = createHandleMpvCommandFromIpcHandler({ + handleMpvCommandFromIpcRuntime: (command, nextDeps) => { + calls.push(`command:${command.join(':')}`); + assert.equal(nextDeps, deps); + }, + buildMpvCommandDeps: () => deps, + }); + + handle(['show-text', 'hello']); + assert.deepEqual(calls, ['command:show-text:hello']); +}); + +test('run subsync manual handler forwards request and result', async () => { + const calls: string[] = []; + const run = createRunSubsyncManualFromIpcHandler({ + runManualFromIpc: async (request: { id: string }) => { + calls.push(`request:${request.id}`); + return { ok: true as const }; + }, + }); + + const result = await run({ id: 'job-1' }); + assert.deepEqual(result, { ok: true }); + assert.deepEqual(calls, ['request:job-1']); +}); diff --git a/src/main/runtime/ipc-bridge-actions.ts b/src/main/runtime/ipc-bridge-actions.ts new file mode 100644 index 0000000..9fcb82c --- /dev/null +++ b/src/main/runtime/ipc-bridge-actions.ts @@ -0,0 +1,21 @@ +import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command'; + +export function createHandleMpvCommandFromIpcHandler(deps: { + handleMpvCommandFromIpcRuntime: ( + command: (string | number)[], + options: MpvCommandFromIpcRuntimeDeps, + ) => void; + buildMpvCommandDeps: () => MpvCommandFromIpcRuntimeDeps; +}) { + return (command: (string | number)[]): void => { + deps.handleMpvCommandFromIpcRuntime(command, deps.buildMpvCommandDeps()); + }; +} + +export function createRunSubsyncManualFromIpcHandler(deps: { + runManualFromIpc: (request: TRequest) => Promise; +}) { + return async (request: TRequest): Promise => { + return deps.runManualFromIpc(request); + }; +} diff --git a/src/main/runtime/overlay-main-actions.test.ts b/src/main/runtime/overlay-main-actions.test.ts new file mode 100644 index 0000000..0792d5c --- /dev/null +++ b/src/main/runtime/overlay-main-actions.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createAppendClipboardVideoToQueueHandler, + createHandleOverlayModalClosedHandler, + createSetOverlayVisibleHandler, + createToggleOverlayHandler, +} from './overlay-main-actions'; + +test('set overlay visible handler delegates to visible overlay setter', () => { + const calls: string[] = []; + const setOverlayVisible = createSetOverlayVisibleHandler({ + setVisibleOverlayVisible: (visible) => calls.push(`set:${visible}`), + }); + + setOverlayVisible(true); + assert.deepEqual(calls, ['set:true']); +}); + +test('toggle overlay handler delegates to visible toggle', () => { + const calls: string[] = []; + const toggleOverlay = createToggleOverlayHandler({ + toggleVisibleOverlay: () => calls.push('toggle'), + }); + + toggleOverlay(); + assert.deepEqual(calls, ['toggle']); +}); + +test('overlay modal closed handler delegates to runtime handler', () => { + const calls: string[] = []; + const handleClosed = createHandleOverlayModalClosedHandler({ + handleOverlayModalClosedRuntime: (modal) => calls.push(`closed:${modal}`), + }); + + handleClosed('runtime-options'); + assert.deepEqual(calls, ['closed:runtime-options']); +}); + +test('append clipboard queue handler forwards runtime deps and result', () => { + const calls: string[] = []; + const mpvClient = { connected: true }; + const appendClipboardVideoToQueue = createAppendClipboardVideoToQueueHandler({ + appendClipboardVideoToQueueRuntime: (options) => { + assert.equal(options.getMpvClient(), mpvClient); + assert.equal(options.readClipboardText(), '/tmp/video.mkv'); + options.showMpvOsd('queued'); + options.sendMpvCommand(['loadfile', '/tmp/video.mkv', 'append']); + return { ok: true, message: 'ok' }; + }, + getMpvClient: () => mpvClient, + readClipboardText: () => '/tmp/video.mkv', + showMpvOsd: (text) => calls.push(`osd:${text}`), + sendMpvCommand: (command) => calls.push(`mpv:${command.join(':')}`), + }); + + const result = appendClipboardVideoToQueue(); + assert.deepEqual(result, { ok: true, message: 'ok' }); + assert.deepEqual(calls, ['osd:queued', 'mpv:loadfile:/tmp/video.mkv:append']); +}); diff --git a/src/main/runtime/overlay-main-actions.ts b/src/main/runtime/overlay-main-actions.ts new file mode 100644 index 0000000..fc0b2a2 --- /dev/null +++ b/src/main/runtime/overlay-main-actions.ts @@ -0,0 +1,47 @@ +import type { OverlayHostedModal } from '../overlay-runtime'; +import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue'; + +export function createSetOverlayVisibleHandler(deps: { + setVisibleOverlayVisible: (visible: boolean) => void; +}) { + return (visible: boolean): void => { + deps.setVisibleOverlayVisible(visible); + }; +} + +export function createToggleOverlayHandler(deps: { + toggleVisibleOverlay: () => void; +}) { + return (): void => { + deps.toggleVisibleOverlay(); + }; +} + +export function createHandleOverlayModalClosedHandler(deps: { + handleOverlayModalClosedRuntime: (modal: OverlayHostedModal) => void; +}) { + return (modal: OverlayHostedModal): void => { + deps.handleOverlayModalClosedRuntime(modal); + }; +} + +export function createAppendClipboardVideoToQueueHandler(deps: { + appendClipboardVideoToQueueRuntime: ( + options: AppendClipboardVideoToQueueRuntimeDeps, + ) => { ok: boolean; message: string }; + getMpvClient: () => AppendClipboardVideoToQueueRuntimeDeps['getMpvClient'] extends () => infer T + ? T + : never; + readClipboardText: () => string; + showMpvOsd: (text: string) => void; + sendMpvCommand: (command: (string | number)[]) => void; +}) { + return (): { ok: boolean; message: string } => { + return deps.appendClipboardVideoToQueueRuntime({ + getMpvClient: () => deps.getMpvClient(), + readClipboardText: deps.readClipboardText, + showMpvOsd: deps.showMpvOsd, + sendMpvCommand: deps.sendMpvCommand, + }); + }; +} diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts new file mode 100644 index 0000000..9f8d853 --- /dev/null +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; + +test('build initialize overlay runtime options maps dependencies', () => { + const calls: string[] = []; + const buildOptions = createBuildInitializeOverlayRuntimeOptionsHandler({ + getBackendOverride: () => 'x11', + getInitialInvisibleOverlayVisibility: () => true, + createMainWindow: () => calls.push('create-main'), + createInvisibleWindow: () => calls.push('create-invisible'), + registerGlobalShortcuts: () => calls.push('register-shortcuts'), + updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'), + updateInvisibleOverlayBounds: () => calls.push('update-invisible-bounds'), + isVisibleOverlayVisible: () => true, + isInvisibleOverlayVisible: () => false, + updateVisibleOverlayVisibility: () => calls.push('update-visible'), + updateInvisibleOverlayVisibility: () => calls.push('update-invisible'), + getOverlayWindows: () => [], + syncOverlayShortcuts: () => calls.push('sync-shortcuts'), + setWindowTracker: () => calls.push('set-tracker'), + getResolvedConfig: () => ({}), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getMpvSocketPath: () => '/tmp/mpv.sock', + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => calls.push('set-anki'), + showDesktopNotification: () => calls.push('notify'), + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const options = buildOptions(); + assert.equal(options.backendOverride, 'x11'); + assert.equal(options.getInitialInvisibleOverlayVisibility(), true); + assert.equal(options.isVisibleOverlayVisible(), true); + assert.equal(options.isInvisibleOverlayVisible(), false); + assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock'); + assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json'); + options.createMainWindow(); + options.createInvisibleWindow(); + options.registerGlobalShortcuts(); + options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + options.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); + options.updateVisibleOverlayVisibility(); + options.updateInvisibleOverlayVisibility(); + options.syncOverlayShortcuts(); + options.setWindowTracker(null); + options.setAnkiIntegration(null); + options.showDesktopNotification('title', {}); + + assert.deepEqual(calls, [ + 'create-main', + 'create-invisible', + 'register-shortcuts', + 'update-visible-bounds', + 'update-invisible-bounds', + 'update-visible', + 'update-invisible', + 'sync-shortcuts', + 'set-tracker', + 'set-anki', + 'notify', + ]); +}); diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts new file mode 100644 index 0000000..c953294 --- /dev/null +++ b/src/main/runtime/overlay-runtime-options.ts @@ -0,0 +1,93 @@ +import type { + AnkiConnectConfig, + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + WindowGeometry, +} from '../../types'; +import type { BrowserWindow } from 'electron'; + +type OverlayRuntimeOptions = { + backendOverride: string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: unknown | null) => void; + 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; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; +}; + +export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { + getBackendOverride: () => string | null; + getInitialInvisibleOverlayVisibility: () => boolean; + createMainWindow: () => void; + createInvisibleWindow: () => void; + registerGlobalShortcuts: () => void; + updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; + updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void; + isVisibleOverlayVisible: () => boolean; + isInvisibleOverlayVisible: () => boolean; + updateVisibleOverlayVisibility: () => void; + updateInvisibleOverlayVisibility: () => void; + getOverlayWindows: () => BrowserWindow[]; + syncOverlayShortcuts: () => void; + setWindowTracker: (tracker: unknown | null) => void; + 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; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + getKnownWordCacheStatePath: () => string; +}) { + return (): OverlayRuntimeOptions => ({ + backendOverride: deps.getBackendOverride(), + getInitialInvisibleOverlayVisibility: deps.getInitialInvisibleOverlayVisibility, + createMainWindow: deps.createMainWindow, + createInvisibleWindow: deps.createInvisibleWindow, + registerGlobalShortcuts: deps.registerGlobalShortcuts, + updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, + updateInvisibleOverlayBounds: deps.updateInvisibleOverlayBounds, + isVisibleOverlayVisible: deps.isVisibleOverlayVisible, + isInvisibleOverlayVisible: deps.isInvisibleOverlayVisible, + updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility, + getOverlayWindows: deps.getOverlayWindows, + syncOverlayShortcuts: deps.syncOverlayShortcuts, + setWindowTracker: deps.setWindowTracker, + getResolvedConfig: deps.getResolvedConfig, + getSubtitleTimingTracker: deps.getSubtitleTimingTracker, + getMpvClient: deps.getMpvClient, + getMpvSocketPath: deps.getMpvSocketPath, + getRuntimeOptionsManager: deps.getRuntimeOptionsManager, + setAnkiIntegration: deps.setAnkiIntegration, + showDesktopNotification: deps.showDesktopNotification, + createFieldGroupingCallback: deps.createFieldGroupingCallback, + getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, + }); +} diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts new file mode 100644 index 0000000..2fe7d3d --- /dev/null +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createCreateInvisibleWindowHandler, + createCreateMainWindowHandler, + createCreateOverlayWindowHandler, +} from './overlay-window-factory'; + +test('create overlay window handler forwards options and kind', () => { + const calls: string[] = []; + const window = { id: 1 }; + const createOverlayWindow = createCreateOverlayWindowHandler({ + createOverlayWindowCore: (kind, options) => { + calls.push(`kind:${kind}`); + assert.equal(options.isDev, true); + assert.equal(options.overlayDebugVisualizationEnabled, false); + assert.equal(options.isOverlayVisible('visible'), true); + assert.equal(options.isOverlayVisible('invisible'), false); + options.onRuntimeOptionsChanged(); + options.setOverlayDebugVisualizationEnabled(true); + options.onWindowClosed(kind); + return window; + }, + isDev: true, + getOverlayDebugVisualizationEnabled: () => false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => calls.push('runtime-options'), + setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), + isOverlayVisible: (kind) => kind === 'visible', + tryHandleOverlayShortcutLocalFallback: () => false, + onWindowClosed: (kind) => calls.push(`closed:${kind}`), + }); + + assert.equal(createOverlayWindow('visible'), window); + assert.deepEqual(calls, ['kind:visible', 'runtime-options', 'debug:true', 'closed:visible']); +}); + +test('create main window handler stores visible window', () => { + const calls: string[] = []; + const visibleWindow = { id: 'visible' }; + const createMainWindow = createCreateMainWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return visibleWindow; + }, + setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createMainWindow(), visibleWindow); + assert.deepEqual(calls, ['create:visible', 'set:visible']); +}); + +test('create invisible window handler stores invisible window', () => { + const calls: string[] = []; + const invisibleWindow = { id: 'invisible' }; + const createInvisibleWindow = createCreateInvisibleWindowHandler({ + createOverlayWindow: (kind) => { + calls.push(`create:${kind}`); + return invisibleWindow; + }, + setInvisibleWindow: (window) => calls.push(`set:${(window as { id: string }).id}`), + }); + + assert.equal(createInvisibleWindow(), invisibleWindow); + assert.deepEqual(calls, ['create:invisible', 'set:invisible']); +}); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts new file mode 100644 index 0000000..20a1aa3 --- /dev/null +++ b/src/main/runtime/overlay-window-factory.ts @@ -0,0 +1,60 @@ +type OverlayWindowKind = 'visible' | 'invisible'; + +export function createCreateOverlayWindowHandler(deps: { + createOverlayWindowCore: ( + kind: OverlayWindowKind, + options: { + isDev: boolean; + overlayDebugVisualizationEnabled: boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: OverlayWindowKind) => void; + }, + ) => TWindow; + isDev: boolean; + getOverlayDebugVisualizationEnabled: () => boolean; + ensureOverlayWindowLevel: (window: TWindow) => void; + onRuntimeOptionsChanged: () => void; + setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; + isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + onWindowClosed: (windowKind: OverlayWindowKind) => void; +}) { + return (kind: OverlayWindowKind): TWindow => { + return deps.createOverlayWindowCore(kind, { + isDev: deps.isDev, + overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(), + ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel, + onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged, + setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, + isOverlayVisible: deps.isOverlayVisible, + tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + onWindowClosed: deps.onWindowClosed, + }); + }; +} + +export function createCreateMainWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setMainWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('visible'); + deps.setMainWindow(window); + return window; + }; +} + +export function createCreateInvisibleWindowHandler(deps: { + createOverlayWindow: (kind: OverlayWindowKind) => TWindow; + setInvisibleWindow: (window: TWindow | null) => void; +}) { + return (): TWindow => { + const window = deps.createOverlayWindow('invisible'); + deps.setInvisibleWindow(window); + return window; + }; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts new file mode 100644 index 0000000..2140061 --- /dev/null +++ b/src/main/runtime/tray-main-actions.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createBuildTrayMenuTemplateHandler, + createResolveTrayIconPathHandler, +} from './tray-main-actions'; + +test('resolve tray icon path handler forwards runtime dependencies', () => { + const calls: string[] = []; + const resolveTrayIconPath = createResolveTrayIconPathHandler({ + resolveTrayIconPathRuntime: (options) => { + calls.push(`platform:${options.platform}`); + calls.push(`resources:${options.resourcesPath}`); + calls.push(`app:${options.appPath}`); + calls.push(`dir:${options.dirname}`); + calls.push(`join:${options.joinPath('a', 'b')}`); + calls.push(`exists:${options.fileExists('/tmp/icon.png')}`); + return '/tmp/icon.png'; + }, + platform: 'darwin', + resourcesPath: '/resources', + appPath: '/app', + dirname: '/dir', + joinPath: (...parts) => parts.join('/'), + fileExists: () => true, + }); + + assert.equal(resolveTrayIconPath(), '/tmp/icon.png'); + assert.deepEqual(calls, [ + 'platform:darwin', + 'resources:/resources', + 'app:/app', + 'dir:/dir', + 'join:a/b', + 'exists:true', + ]); +}); + +test('build tray template handler wires actions and init guards', () => { + const calls: string[] = []; + let initialized = false; + const buildTemplate = createBuildTrayMenuTemplateHandler({ + buildTrayMenuTemplateRuntime: (handlers) => { + handlers.openOverlay(); + handlers.openYomitanSettings(); + handlers.openRuntimeOptions(); + handlers.openJellyfinSetup(); + handlers.openAnilistSetup(); + handlers.quitApp(); + return [{ label: 'ok' }] as never; + }, + initializeOverlayRuntime: () => { + initialized = true; + calls.push('init'); + }, + isOverlayRuntimeInitialized: () => initialized, + setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), + openYomitanSettings: () => calls.push('yomitan'), + openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openJellyfinSetupWindow: () => calls.push('jellyfin'), + openAnilistSetupWindow: () => calls.push('anilist'), + quitApp: () => calls.push('quit'), + }); + + const template = buildTemplate(); + assert.deepEqual(template, [{ label: 'ok' }]); + assert.deepEqual(calls, [ + 'init', + 'visible:true', + 'yomitan', + 'runtime-options', + 'jellyfin', + 'anilist', + 'quit', + ]); +}); diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts new file mode 100644 index 0000000..e624f21 --- /dev/null +++ b/src/main/runtime/tray-main-actions.ts @@ -0,0 +1,75 @@ +export function createResolveTrayIconPathHandler(deps: { + resolveTrayIconPathRuntime: (options: { + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + }) => string | null; + platform: string; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; +}) { + return (): string | null => { + return deps.resolveTrayIconPathRuntime({ + platform: deps.platform, + resourcesPath: deps.resourcesPath, + appPath: deps.appPath, + dirname: deps.dirname, + joinPath: deps.joinPath, + fileExists: deps.fileExists, + }); + }; +} + +export function createBuildTrayMenuTemplateHandler(deps: { + buildTrayMenuTemplateRuntime: (handlers: { + openOverlay: () => void; + openYomitanSettings: () => void; + openRuntimeOptions: () => void; + openJellyfinSetup: () => void; + openAnilistSetup: () => void; + quitApp: () => void; + }) => TMenuItem[]; + initializeOverlayRuntime: () => void; + isOverlayRuntimeInitialized: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; + openYomitanSettings: () => void; + openRuntimeOptionsPalette: () => void; + openJellyfinSetupWindow: () => void; + openAnilistSetupWindow: () => void; + quitApp: () => void; +}) { + return (): TMenuItem[] => { + return deps.buildTrayMenuTemplateRuntime({ + openOverlay: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.setVisibleOverlayVisible(true); + }, + openYomitanSettings: () => { + deps.openYomitanSettings(); + }, + openRuntimeOptions: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.openRuntimeOptionsPalette(); + }, + openJellyfinSetup: () => { + deps.openJellyfinSetupWindow(); + }, + openAnilistSetup: () => { + deps.openAnilistSetupWindow(); + }, + quitApp: () => { + deps.quitApp(); + }, + }); + }; +} diff --git a/src/main/runtime/yomitan-extension-loader.test.ts b/src/main/runtime/yomitan-extension-loader.test.ts new file mode 100644 index 0000000..5de56fa --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEnsureYomitanExtensionLoadedHandler, + createLoadYomitanExtensionHandler, +} from './yomitan-extension-loader'; + +test('load yomitan extension handler forwards parser state dependencies', async () => { + const calls: string[] = []; + const parserWindow = {} as never; + const extension = { id: 'ext' } as never; + const loadYomitanExtension = createLoadYomitanExtensionHandler({ + loadYomitanExtensionCore: async (options) => { + calls.push(`path:${options.userDataPath}`); + assert.equal(options.getYomitanParserWindow(), parserWindow); + options.setYomitanParserWindow(null); + options.setYomitanParserReadyPromise(null); + options.setYomitanParserInitPromise(null); + options.setYomitanExtension(extension); + return extension; + }, + userDataPath: '/tmp/subminer', + getYomitanParserWindow: () => parserWindow, + setYomitanParserWindow: () => calls.push('set-window'), + setYomitanParserReadyPromise: () => calls.push('set-ready'), + setYomitanParserInitPromise: () => calls.push('set-init'), + setYomitanExtension: () => calls.push('set-ext'), + }); + + assert.equal(await loadYomitanExtension(), extension); + assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']); +}); + +test('ensure yomitan loader returns existing extension when available', async () => { + const extension = { id: 'ext' } as never; + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => extension, + getLoadInFlight: () => null, + setLoadInFlight: () => { + throw new Error('unexpected'); + }, + loadYomitanExtension: async () => { + throw new Error('unexpected'); + }, + }); + + assert.equal(await ensureLoaded(), extension); +}); + +test('ensure yomitan loader reuses in-flight promise', async () => { + const extension = { id: 'ext' } as never; + const inflight = Promise.resolve(extension); + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => null, + getLoadInFlight: () => inflight, + setLoadInFlight: () => { + throw new Error('unexpected'); + }, + loadYomitanExtension: async () => { + throw new Error('unexpected'); + }, + }); + + assert.equal(await ensureLoaded(), extension); +}); + +test('ensure yomitan loader starts load and clears in-flight when done', async () => { + const calls: string[] = []; + let inFlight: Promise | null = null; + const extension = { id: 'ext' } as never; + const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({ + getYomitanExtension: () => null, + getLoadInFlight: () => inFlight, + setLoadInFlight: (promise) => { + inFlight = promise; + calls.push(promise ? 'set:promise' : 'set:null'); + }, + loadYomitanExtension: async () => { + calls.push('load'); + return extension; + }, + }); + + assert.equal(await ensureLoaded(), extension); + assert.deepEqual(calls, ['load', 'set:promise', 'set:null']); +}); diff --git a/src/main/runtime/yomitan-extension-loader.ts b/src/main/runtime/yomitan-extension-loader.ts new file mode 100644 index 0000000..e668e8c --- /dev/null +++ b/src/main/runtime/yomitan-extension-loader.ts @@ -0,0 +1,48 @@ +import type { Extension } from 'electron'; +import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-extension-loader'; + +export function createLoadYomitanExtensionHandler(deps: { + loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise; + userDataPath: YomitanExtensionLoaderDeps['userDataPath']; + getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow']; + setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow']; + setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise']; + setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise']; + setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension']; +}) { + return async (): Promise => { + return deps.loadYomitanExtensionCore({ + userDataPath: deps.userDataPath, + getYomitanParserWindow: deps.getYomitanParserWindow, + setYomitanParserWindow: deps.setYomitanParserWindow, + setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise, + setYomitanParserInitPromise: deps.setYomitanParserInitPromise, + setYomitanExtension: deps.setYomitanExtension, + }); + }; +} + +export function createEnsureYomitanExtensionLoadedHandler(deps: { + getYomitanExtension: () => Extension | null; + getLoadInFlight: () => Promise | null; + setLoadInFlight: (promise: Promise | null) => void; + loadYomitanExtension: () => Promise; +}) { + return async (): Promise => { + const existing = deps.getYomitanExtension(); + if (existing) { + return existing; + } + + const inFlight = deps.getLoadInFlight(); + if (inFlight) { + return inFlight; + } + + const promise = deps.loadYomitanExtension().finally(() => { + deps.setLoadInFlight(null); + }); + deps.setLoadInFlight(promise); + return promise; + }; +}