refactor: extract main runtime lifecycle helper builders

This commit is contained in:
2026-02-19 19:57:18 -08:00
parent c9605345bb
commit 45c326db6d
17 changed files with 1249 additions and 247 deletions

View File

@@ -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` |

View File

@@ -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.

View File

@@ -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<Extension | null> {
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<Extension | null> {
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<BrowserWindow>({
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<BrowserWindow>({
createOverlayWindow: (kind) => createOverlayWindow(kind),
setMainWindow: (window) => overlayManager.setMainWindow(window),
});
const createInvisibleWindowHandler = createCreateInvisibleWindowHandler<BrowserWindow>({
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<void> {
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<SubsyncResult> {
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({

View File

@@ -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']);
});

View File

@@ -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<unknown>;
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<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
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,
});
}

View File

@@ -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']);
});

View File

@@ -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<TRequest, TResult>(deps: {
runManualFromIpc: (request: TRequest) => Promise<TResult>;
}) {
return async (request: TRequest): Promise<TResult> => {
return deps.runManualFromIpc(request);
};
}

View File

@@ -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']);
});

View File

@@ -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,
});
};
}

View File

@@ -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',
]);
});

View File

@@ -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<KikuFieldGroupingChoice>;
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<KikuFieldGroupingChoice>;
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,
});
}

View File

@@ -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']);
});

View File

@@ -0,0 +1,60 @@
type OverlayWindowKind = 'visible' | 'invisible';
export function createCreateOverlayWindowHandler<TWindow>(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<TWindow>(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<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('invisible');
deps.setInvisibleWindow(window);
return window;
};
}

View File

@@ -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',
]);
});

View File

@@ -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<TMenuItem>(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();
},
});
};
}

View File

@@ -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<any> | 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']);
});

View File

@@ -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<Extension | null>;
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
}) {
return async (): Promise<Extension | null> => {
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<Extension | null> | null;
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
loadYomitanExtension: () => Promise<Extension | null>;
}) {
return async (): Promise<Extension | null> => {
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;
};
}