refactor: extract main runtime dependency builders

This commit is contained in:
2026-02-19 23:11:20 -08:00
parent 8c2d82e361
commit 0d7b65ec88
25 changed files with 1490 additions and 262 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-20T05:50:43Z` |
| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T06:56:20Z` |
| `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` |
| `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` |
| `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` |

View File

@@ -193,6 +193,28 @@
- `src/main/runtime/initial-args-main-deps.test.ts`
- `src/main/runtime/mpv-main-event-main-deps.ts`
- `src/main/runtime/mpv-main-event-main-deps.test.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.test.ts`
- `src/main/runtime/secondary-sub-mode-main-deps.ts`
- `src/main/runtime/secondary-sub-mode-main-deps.test.ts`
- `src/main/runtime/overlay-runtime-main-actions.ts`
- `src/main/runtime/overlay-runtime-main-actions.test.ts`
- `src/main/runtime/jellyfin-client-info.ts`
- `src/main/runtime/jellyfin-client-info.test.ts`
- `src/main/runtime/startup-config-main-deps.ts`
- `src/main/runtime/startup-config-main-deps.test.ts`
- `src/main/runtime/app-ready-main-deps.ts`
- `src/main/runtime/app-ready-main-deps.test.ts`
- `src/main/runtime/startup-lifecycle-main-deps.ts`
- `src/main/runtime/startup-lifecycle-main-deps.test.ts`
- `src/main/runtime/startup-bootstrap-main-deps.ts`
- `src/main/runtime/startup-bootstrap-main-deps.test.ts`
- `src/main/runtime/cli-command-prechecks-main-deps.ts`
- `src/main/runtime/cli-command-prechecks-main-deps.test.ts`
- `src/main/runtime/field-grouping-resolver.ts`
- `src/main/runtime/field-grouping-resolver.test.ts`
- `src/main/runtime/mpv-jellyfin-defaults.ts`
- `src/main/runtime/mpv-jellyfin-defaults.test.ts`
## Open Questions / Blockers
@@ -244,3 +266,27 @@
- [2026-02-20T05:15:40Z] progress: extracted MPV main-event deps assembly into `src/main/runtime/mpv-main-event-main-deps.ts` (`createBuildBindMpvMainEventHandlersMainDepsHandler`) and rewired `bindMpvClientEventHandlers` setup in `src/main.ts`.
- [2026-02-20T05:15:40Z] progress: `src/main.ts` currently 2653 LOC after this slice.
- [2026-02-20T05:15:40Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/app-lifecycle-main-activate.test.js dist/main/runtime/initial-args-main-deps.test.js dist/main/runtime/app-lifecycle-actions.test.js dist/main/runtime/initial-args-handler.test.js dist/main/runtime/app-runtime-main-deps.test.js dist/main/runtime/mpv-main-event-main-deps.test.js dist/main/runtime/mpv-main-event-bindings.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/mpv-client-event-bindings.test.js` pass (27/27).
- [2026-02-20T05:31:05Z] progress: extracted subtitle tokenization/mecab-warmup deps assembly into `src/main/runtime/subtitle-tokenization-main-deps.ts` (`createBuildTokenizerDepsMainHandler`, `createCreateMecabTokenizerAndCheckMainHandler`, `createPrewarmSubtitleDictionariesMainHandler`) and rewired `tokenizeSubtitle`, `createMecabTokenizerAndCheck`, and `prewarmSubtitleDictionaries` in `src/main.ts`.
- [2026-02-20T05:31:05Z] progress: extracted `cycleSecondarySubMode` deps assembly into `src/main/runtime/secondary-sub-mode-main-deps.ts` (`createBuildCycleSecondarySubModeMainDepsHandler`) and rewired `cycleSecondarySubMode` in `src/main.ts`.
- [2026-02-20T05:31:05Z] progress: while implementing subtitle-tokenization deps, fixed strict typing to align parser promise shapes (`Promise<void>` ready + `Promise<boolean>` init) and Mecab availability return type.
- [2026-02-20T05:31:05Z] progress: `src/main.ts` currently 2664 LOC after this slice.
- [2026-02-20T05:31:05Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/subtitle-tokenization-main-deps.test.js dist/main/runtime/secondary-sub-mode-main-deps.test.js dist/main/runtime/startup-warmups.test.js` pass (7/7).
- [2026-02-20T05:49:13Z] progress: extracted overlay runtime state/action wrappers into `src/main/runtime/overlay-runtime-main-actions.ts` (`createGetRuntimeOptionsStateHandler`, `createRestorePreviousSecondarySubVisibilityHandler`, `createBroadcastRuntimeOptionsChangedHandler`, `createSendToActiveOverlayWindowHandler`, `createSetOverlayDebugVisualizationEnabledHandler`, `createOpenRuntimeOptionsPaletteHandler`), and rewired corresponding `main.ts` functions to thin delegations.
- [2026-02-20T05:49:13Z] progress: while adding tests, corrected strict typing to import `OverlayHostedModal` from `src/main/overlay-runtime.ts` and aligned `RuntimeOptionState` fixture shape.
- [2026-02-20T05:49:13Z] progress: `src/main.ts` currently 2696 LOC after this slice.
- [2026-02-20T05:49:13Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/overlay-runtime-main-actions.test.js dist/main/runtime/subtitle-tokenization-main-deps.test.js dist/main/runtime/secondary-sub-mode-main-deps.test.js` pass (12/12).
- [2026-02-20T05:50:43Z] progress: extracted Jellyfin config/client info wrappers into `src/main/runtime/jellyfin-client-info.ts` (`createGetResolvedJellyfinConfigHandler`, `createGetJellyfinClientInfoHandler`) and rewired `getResolvedJellyfinConfig` + `getJellyfinClientInfo` in `src/main.ts`.
- [2026-02-20T05:50:43Z] progress: fixed strict typing regressions by loosening helper input types to preserve call-site inference while keeping client-info output normalized to strings.
- [2026-02-20T05:50:43Z] progress: `src/main.ts` currently 2702 LOC after this slice.
- [2026-02-20T05:50:43Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/jellyfin-client-info.test.js dist/main/runtime/overlay-runtime-main-actions.test.js dist/main/runtime/subtitle-tokenization-main-deps.test.js` pass (14/14).
- [2026-02-20T06:52:54Z] progress: extracted startup config handler deps assembly into `src/main/runtime/startup-config-main-deps.ts` and rewired `reloadConfigHandler` + `criticalConfigErrorHandler` setup in `src/main.ts`.
- [2026-02-20T06:52:54Z] progress: extracted app-ready runner deps assembly into `src/main/runtime/app-ready-main-deps.ts` and lifted nested `createAppReadyRuntimeRunner(...)` input block to `appReadyRuntimeRunner` constant in `src/main.ts`.
- [2026-02-20T06:52:54Z] progress: extracted app-lifecycle runner deps assembly into `src/main/runtime/startup-lifecycle-main-deps.ts` and lifted lifecycle runner wiring to `appLifecycleRuntimeRunner` constant in `src/main.ts`.
- [2026-02-20T06:52:54Z] progress: extracted startup bootstrap deps assembly into `src/main/runtime/startup-bootstrap-main-deps.ts` and rewired `buildStartupBootstrapRuntimeFactoryDepsHandler` to compose through the new builder.
- [2026-02-20T06:52:54Z] progress: `src/main.ts` currently 2723 LOC after this slice.
- [2026-02-20T06:52:54Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/startup-bootstrap-main-deps.test.js dist/main/runtime/startup-bootstrap-deps-builder.test.js dist/main/runtime/startup-lifecycle-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/startup-config-main-deps.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/app-ready.test.js` pass (21/21).
- [2026-02-20T06:56:20Z] progress: extracted CLI precheck deps assembly into `src/main/runtime/cli-command-prechecks-main-deps.ts` and rewired `handleCliCommand` to use a prebuilt `handleTexthookerOnlyModeTransitionHandler`.
- [2026-02-20T06:56:20Z] progress: extracted field-grouping resolver state wrappers into `src/main/runtime/field-grouping-resolver.ts` and rewired `getFieldGroupingResolver` + `setFieldGroupingResolver` in `src/main.ts`.
- [2026-02-20T06:56:20Z] progress: extracted `applyJellyfinMpvDefaults` and `getDefaultSocketPath` wrappers into `src/main/runtime/mpv-jellyfin-defaults.ts`; rewired both `main.ts` helper functions to thin handler delegates.
- [2026-02-20T06:56:20Z] progress: `src/main.ts` currently 2742 LOC after this slice.
- [2026-02-20T06:56:20Z] test: `bun run build` pass (expected macOS helper Swift cache fallback) + `node --test dist/main/runtime/cli-command-prechecks-main-deps.test.js dist/main/runtime/cli-command-prechecks.test.js dist/main/runtime/field-grouping-resolver.test.js dist/main/runtime/mpv-jellyfin-defaults.test.js dist/main/runtime/startup-bootstrap-main-deps.test.js` pass (11/11).

View File

@@ -148,6 +148,14 @@ import { createRunJellyfinCommandHandler } from './main/runtime/jellyfin-command
import { createHandleJellyfinListCommands } from './main/runtime/jellyfin-cli-list';
import { createHandleJellyfinPlayCommand } from './main/runtime/jellyfin-cli-play';
import { createHandleJellyfinRemoteAnnounceCommand } from './main/runtime/jellyfin-cli-remote-announce';
import {
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
} from './main/runtime/jellyfin-client-info';
import {
createApplyJellyfinMpvDefaultsHandler,
createGetDefaultSocketPathHandler,
} from './main/runtime/mpv-jellyfin-defaults';
import { createPlayJellyfinItemInMpvHandler } from './main/runtime/jellyfin-playback-launch';
import { createPreloadJellyfinExternalSubtitlesHandler } from './main/runtime/jellyfin-subtitle-preload';
import {
@@ -157,12 +165,22 @@ import {
import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler';
import { createBuildHandleInitialArgsMainDepsHandler } from './main/runtime/initial-args-main-deps';
import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks';
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './main/runtime/cli-command-prechecks-main-deps';
import {
createGetFieldGroupingResolverHandler,
createSetFieldGroupingResolverHandler,
} from './main/runtime/field-grouping-resolver';
import { createCliCommandContext } from './main/runtime/cli-command-context';
import { createBindMpvMainEventHandlersHandler } from './main/runtime/mpv-main-event-bindings';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './main/runtime/mpv-main-event-main-deps';
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './main/runtime/mpv-client-runtime-service-main-deps';
import { createMpvClientRuntimeServiceFactory } from './main/runtime/mpv-client-runtime-service';
import { createUpdateMpvSubtitleRenderMetricsHandler } from './main/runtime/mpv-subtitle-render-metrics';
import {
createBuildTokenizerDepsMainHandler,
createCreateMecabTokenizerAndCheckMainHandler,
createPrewarmSubtitleDictionariesMainHandler,
} from './main/runtime/subtitle-tokenization-main-deps';
import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
@@ -192,6 +210,7 @@ import {
createBuildAppendToMpvLogMainDepsHandler,
createBuildShowMpvOsdMainDepsHandler,
} from './main/runtime/mpv-osd-log-main-deps';
import { createBuildCycleSecondarySubModeMainDepsHandler } from './main/runtime/secondary-sub-mode-main-deps';
import {
createCancelNumericShortcutSessionHandler,
createStartNumericShortcutSessionHandler,
@@ -226,6 +245,14 @@ import {
createSetOverlayVisibleHandler,
createToggleOverlayHandler,
} from './main/runtime/overlay-main-actions';
import {
createBroadcastRuntimeOptionsChangedHandler,
createGetRuntimeOptionsStateHandler,
createOpenRuntimeOptionsPaletteHandler,
createRestorePreviousSecondarySubVisibilityHandler,
createSendToActiveOverlayWindowHandler,
createSetOverlayDebugVisualizationEnabledHandler,
} from './main/runtime/overlay-runtime-main-actions';
import {
createHandleMpvCommandFromIpcHandler,
createRunSubsyncManualFromIpcHandler,
@@ -279,6 +306,13 @@ import {
createConfigHotReloadMessageHandler,
resolveSubtitleStyleForRenderer,
} from './main/runtime/config-hot-reload-handlers';
import {
createBuildCriticalConfigErrorMainDepsHandler,
createBuildReloadConfigMainDepsHandler,
} from './main/runtime/startup-config-main-deps';
import { createBuildAppReadyRuntimeMainDepsHandler } from './main/runtime/app-ready-main-deps';
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './main/runtime/startup-lifecycle-main-deps';
import { createBuildStartupBootstrapMainDepsHandler } from './main/runtime/startup-bootstrap-main-deps';
import {
enforceUnsupportedWaylandMode,
forceX11Backend,
@@ -429,14 +463,13 @@ let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false;
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler({
sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client as never, command),
jellyfinLangPref: JELLYFIN_LANG_PREF,
});
function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']);
sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']);
sendMpvCommandRuntime(client, ['set_property', 'alang', JELLYFIN_LANG_PREF]);
sendMpvCommandRuntime(client, ['set_property', 'slang', JELLYFIN_LANG_PREF]);
applyJellyfinMpvDefaultsHandler(client);
}
const CONFIG_DIR = resolveConfigDir({
@@ -493,11 +526,12 @@ const appLogger = {
},
};
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler({
platform: process.platform,
});
function getDefaultSocketPath(): string {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket';
}
return '/tmp/subminer-socket';
return getDefaultSocketPathHandler();
}
if (!fs.existsSync(USER_DATA_PATH)) {
@@ -795,23 +829,29 @@ const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({
},
});
const getFieldGroupingResolverHandler = createGetFieldGroupingResolverHandler({
getResolver: () => appState.fieldGroupingResolver,
});
function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null {
return appState.fieldGroupingResolver;
return getFieldGroupingResolverHandler();
}
const setFieldGroupingResolverHandler = createSetFieldGroupingResolverHandler({
setResolver: (resolver) => {
appState.fieldGroupingResolver = resolver;
},
nextSequence: () => {
appState.fieldGroupingResolverSequence += 1;
return appState.fieldGroupingResolverSequence;
},
getSequence: () => appState.fieldGroupingResolverSequence,
});
function setFieldGroupingResolver(
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
): void {
if (!resolver) {
appState.fieldGroupingResolver = null;
return;
}
const sequence = ++appState.fieldGroupingResolverSequence;
const wrappedResolver = (choice: KikuFieldGroupingChoice): void => {
if (sequence !== appState.fieldGroupingResolverSequence) return;
resolver(choice);
};
appState.fieldGroupingResolver = wrappedResolver;
setFieldGroupingResolverHandler(resolver);
}
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>({
@@ -880,71 +920,97 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService({
},
});
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler({
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
});
function getRuntimeOptionsState(): RuntimeOptionState[] {
if (!appState.runtimeOptionsManager) return [];
return appState.runtimeOptionsManager.listOptions();
return getRuntimeOptionsStateHandler();
}
function getOverlayWindows(): BrowserWindow[] {
return overlayManager.getOverlayWindows();
}
const restorePreviousSecondarySubVisibilityHandler = createRestorePreviousSecondarySubVisibilityHandler(
{
getMpvClient: () => appState.mpvClient,
},
);
function restorePreviousSecondarySubVisibility(): void {
if (!appState.mpvClient || !appState.mpvClient.connected) return;
appState.mpvClient.restorePreviousSecondarySubVisibility();
restorePreviousSecondarySubVisibilityHandler();
}
function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
overlayManager.broadcastToOverlayWindows(channel, ...args);
}
const broadcastRuntimeOptionsChangedHandler = createBroadcastRuntimeOptionsChangedHandler({
broadcastRuntimeOptionsChangedRuntime,
getRuntimeOptionsState: () => getRuntimeOptionsState(),
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
});
function broadcastRuntimeOptionsChanged(): void {
broadcastRuntimeOptionsChangedRuntime(
() => getRuntimeOptionsState(),
(channel, ...args) => broadcastToOverlayWindows(channel, ...args),
);
broadcastRuntimeOptionsChangedHandler();
}
const sendToActiveOverlayWindowHandler = createSendToActiveOverlayWindowHandler({
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
function sendToActiveOverlayWindow(
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): boolean {
return overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions);
return sendToActiveOverlayWindowHandler(channel, payload, runtimeOptions);
}
function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
setOverlayDebugVisualizationEnabledRuntime(
appState.overlayDebugVisualizationEnabled,
enabled,
(next) => {
const setOverlayDebugVisualizationEnabledHandler = createSetOverlayDebugVisualizationEnabledHandler(
{
setOverlayDebugVisualizationEnabledRuntime,
getCurrentEnabled: () => appState.overlayDebugVisualizationEnabled,
setCurrentEnabled: (next) => {
appState.overlayDebugVisualizationEnabled = next;
},
(channel, ...args) => broadcastToOverlayWindows(channel, ...args),
);
broadcastToOverlayWindows: (channel, ...args) => broadcastToOverlayWindows(channel, ...args),
},
);
function setOverlayDebugVisualizationEnabled(enabled: boolean): void {
setOverlayDebugVisualizationEnabledHandler(enabled);
}
const openRuntimeOptionsPaletteHandler = createOpenRuntimeOptionsPaletteHandler({
openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(),
});
function openRuntimeOptionsPalette(): void {
overlayModalRuntime.openRuntimeOptionsPalette();
openRuntimeOptionsPaletteHandler();
}
function getResolvedConfig() {
return configService.getConfig();
}
const getResolvedJellyfinConfigHandler = createGetResolvedJellyfinConfigHandler({
getResolvedConfig: () => getResolvedConfig(),
});
function getResolvedJellyfinConfig() {
return getResolvedConfig().jellyfin;
return getResolvedJellyfinConfigHandler();
}
const getJellyfinClientInfoHandler = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin,
});
function getJellyfinClientInfo(config = getResolvedJellyfinConfig()) {
const clientName = config.clientName || DEFAULT_CONFIG.jellyfin.clientName;
const clientVersion = config.clientVersion || DEFAULT_CONFIG.jellyfin.clientVersion;
const deviceId = config.deviceId || DEFAULT_CONFIG.jellyfin.deviceId;
return {
clientName,
clientVersion,
deviceId,
};
return getJellyfinClientInfoHandler(config);
}
const waitForMpvConnected = createWaitForMpvConnectedHandler({
@@ -1553,162 +1619,176 @@ const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler(
})(),
);
const buildStartupBootstrapRuntimeFactoryDepsHandler =
createBuildStartupBootstrapRuntimeFactoryDepsHandler({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => {
setLogLevel(level, source);
const reloadConfigHandler = createReloadConfigHandler(
createBuildReloadConfigMainDepsHandler({
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message),
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options),
failHandlers: {
logError: (details) => logger.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
quit: () => app.quit(),
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
})(),
);
const criticalConfigErrorHandler = createCriticalConfigErrorHandler(
createBuildCriticalConfigErrorMainDepsHandler({
getConfigPath: () => configService.getConfigPath(),
failHandlers: {
logError: (message) => logger.error(message),
showErrorBox: (title, message) => dialog.showErrorBox(title, message),
quit: () => app.quit(),
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
})(),
);
const appReadyRuntimeRunner = createAppReadyRuntimeRunner(
createBuildAppReadyRuntimeMainDepsHandler({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: reloadConfigHandler,
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
await createMecabTokenizerAndCheck();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
createImmersionTracker: createImmersionTrackerStartupHandler({
getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params),
setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null;
},
) => generateDefaultConfigFile(args, options),
onConfigGenerated: (exitCode: number) => {
process.exitCode = exitCode;
app.quit();
},
onGenerateConfigError: (error: Error) => {
logger.error(`Failed to generate config: ${error.message}`);
process.exitCode = 1;
app.quit();
},
startAppLifecycle: createAppLifecycleRuntimeRunner({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: createAppReadyRuntimeRunner({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
reloadConfig: createReloadConfigHandler({
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message),
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options),
failHandlers: {
logError: (details) => logger.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
quit: () => app.quit(),
},
}),
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
onOptionsChanged: () => {
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
},
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
await createMecabTokenizerAndCheck();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
appState.subtitleTimingTracker = tracker;
},
createImmersionTracker: createImmersionTrackerStartupHandler({
getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params),
setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null;
},
getMpvClient: () => appState.mpvClient,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarn: (message, details) => logger.warn(message, details),
}),
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
startJellyfinRemoteSession: async () => {
await startJellyfinRemoteSession();
},
prewarmSubtitleDictionaries: async () => {
await prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
startBackgroundWarmups();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode
? false
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
onCriticalConfigErrors: createCriticalConfigErrorHandler({
getConfigPath: () => configService.getConfigPath(),
failHandlers: {
logError: (message) => logger.error(message),
showErrorBox: (title, message) => dialog.showErrorBox(title, message),
quit: () => app.quit(),
},
}),
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
}),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
getMpvClient: () => appState.mpvClient,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarn: (message, details) => logger.warn(message, details),
}),
});
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
startJellyfinRemoteSession: async () => {
await startJellyfinRemoteSession();
},
prewarmSubtitleDictionaries: async () => {
await prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
startBackgroundWarmups();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode
? false
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
onCriticalConfigErrors: criticalConfigErrorHandler,
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
})(),
);
const appLifecycleRuntimeRunner = createAppLifecycleRuntimeRunner(
createBuildAppLifecycleRuntimeRunnerMainDepsHandler({
app,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) =>
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: appReadyRuntimeRunner,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
})(),
);
const buildStartupBootstrapRuntimeFactoryDepsHandler =
createBuildStartupBootstrapRuntimeFactoryDepsHandler(
createBuildStartupBootstrapMainDepsHandler({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => {
setLogLevel(level, source);
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => generateDefaultConfigFile(args, options),
setExitCode: (code) => {
process.exitCode = code;
},
quitApp: () => app.quit(),
logGenerateConfigError: (message) => logger.error(message),
startAppLifecycle: appLifecycleRuntimeRunner,
})(),
);
const startupState = runStartupBootstrapRuntime(
createStartupBootstrapRuntimeDeps(buildStartupBootstrapRuntimeFactoryDepsHandler()),
@@ -1718,16 +1798,20 @@ applyStartupState(appState, startupState);
void refreshAnilistClientSecretState({ force: true });
anilistStateRuntime.refreshRetryQueueState();
function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void {
createHandleTexthookerOnlyModeTransitionHandler({
const handleTexthookerOnlyModeTransitionHandler = createHandleTexthookerOnlyModeTransitionHandler(
createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs),
startBackgroundWarmups: () => startBackgroundWarmups(),
logInfo: (message) => logger.info(message),
})(args);
logInfo: (message: string) => logger.info(message),
})(),
);
function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): void {
handleTexthookerOnlyModeTransitionHandler(args);
const cliContext = createCliCommandContext(buildCliCommandContextDepsHandler());
handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
@@ -1833,59 +1917,62 @@ function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>
updateMpvSubtitleRenderMetricsRuntime(patch);
}
const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => appState.yomitanExt,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
recordLookup: (hit) => {
appState.immersionTracker?.recordLookup(hit);
},
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text),
getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
getFrequencyDictionaryEnabled: () => getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
});
const createMecabTokenizerAndCheckHandler = createCreateMecabTokenizerAndCheckMainHandler({
getMecabTokenizer: () => appState.mecabTokenizer,
setMecabTokenizer: (tokenizer) => {
appState.mecabTokenizer = tokenizer;
},
createMecabTokenizer: () => new MecabTokenizer(),
checkAvailability: async (tokenizer) => tokenizer.checkAvailability(),
});
const prewarmSubtitleDictionariesHandler = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
});
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
await jlptDictionaryRuntime.ensureJlptDictionaryLookup();
await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup();
return tokenizeSubtitleCore(
text,
createTokenizerDepsRuntime({
getYomitanExt: () => appState.yomitanExt,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) =>
(() => {
const hit = Boolean(appState.ankiIntegration?.isKnownWord(text));
appState.immersionTracker?.recordLookup(hit);
return hit;
})(),
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text),
getJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
getFrequencyDictionaryEnabled: () =>
getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => appState.mecabTokenizer,
}),
);
return tokenizeSubtitleCore(text, createTokenizerDepsRuntime(buildTokenizerDepsHandler()));
}
async function createMecabTokenizerAndCheck(): Promise<void> {
if (!appState.mecabTokenizer) {
appState.mecabTokenizer = new MecabTokenizer();
}
await appState.mecabTokenizer.checkAvailability();
await createMecabTokenizerAndCheckHandler();
}
async function prewarmSubtitleDictionaries(): Promise<void> {
await Promise.all([
jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
]);
await prewarmSubtitleDictionariesHandler();
}
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({
@@ -2020,20 +2107,22 @@ function getConfiguredShortcuts() {
}
function cycleSecondarySubMode(): void {
cycleSecondarySubModeCore({
getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
appState.lastSecondarySubToggleAtMs = timestampMs;
},
broadcastSecondarySubMode: (mode: SecondarySubMode) => {
broadcastToOverlayWindows('secondary-subtitle:mode', mode);
},
showMpvOsd: (text: string) => showMpvOsd(text),
});
cycleSecondarySubModeCore(
createBuildCycleSecondarySubModeMainDepsHandler({
getSecondarySubMode: () => appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
getLastSecondarySubToggleAtMs: () => appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
appState.lastSecondarySubToggleAtMs = timestampMs;
},
broadcastToOverlayWindows: (channel, mode) => {
broadcastToOverlayWindows(channel, mode);
},
showMpvOsd: (text: string) => showMpvOsd(text),
})(),
);
}
const appendToMpvLogHandler = createAppendToMpvLogHandler({

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps';
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
const calls: string[] = [];
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
resolveKeybindings: () => calls.push('resolve-keybindings'),
createMpvClient: () => calls.push('create-mpv-client'),
reloadConfig: () => calls.push('reload-config'),
getResolvedConfig: () => ({ websocket: {} }),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push('log-config-warning'),
initRuntimeOptionsManager: () => calls.push('init-runtime-options'),
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => calls.push('start-ws'),
log: () => calls.push('log'),
setLogLevel: () => calls.push('set-log-level'),
createMecabTokenizerAndCheck: async () => {
calls.push('create-mecab');
},
createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'),
createImmersionTracker: () => calls.push('create-immersion'),
startJellyfinRemoteSession: async () => {
calls.push('start-jellyfin');
},
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm-dicts');
},
startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'),
onCriticalConfigErrors: () => {
throw new Error('should not call');
},
logDebug: () => calls.push('debug'),
now: () => 123,
})();
assert.equal(onReady.defaultSecondarySubMode, 'hover');
assert.equal(onReady.defaultWebsocketPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.now?.(), 123);
onReady.loadSubtitlePosition();
onReady.resolveKeybindings();
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
assert.deepEqual(calls, [
'load-subtitle-position',
'resolve-keybindings',
'create-mpv-client',
'create-mecab',
'load-yomitan',
'prewarm-dicts',
'start-warmups',
]);
});

View File

@@ -0,0 +1,38 @@
import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
export function createBuildAppReadyRuntimeMainDepsHandler(
deps: AppReadyRuntimeDepsFactoryInput,
) {
return (): AppReadyRuntimeDepsFactoryInput => ({
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings,
logConfigWarning: deps.logConfigWarning,
initRuntimeOptionsManager: deps.initRuntimeOptionsManager,
setSecondarySubMode: deps.setSecondarySubMode,
defaultSecondarySubMode: deps.defaultSecondarySubMode,
defaultWebsocketPort: deps.defaultWebsocketPort,
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
startSubtitleWebsocket: deps.startSubtitleWebsocket,
log: deps.log,
setLogLevel: deps.setLogLevel,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: deps.createSubtitleTimingTracker,
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
deps.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug,
now: deps.now,
});
}

View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
test('cli prechecks main deps builder maps transition handlers', () => {
const calls: string[] = [];
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
commandNeedsOverlayRuntime: () => true,
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`info:${message}`),
})();
assert.equal(deps.isTexthookerOnlyMode(), true);
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
deps.setTexthookerOnlyMode(false);
deps.startBackgroundWarmups();
deps.logInfo('x');
assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
});

View File

@@ -0,0 +1,17 @@
import type { CliArgs } from '../../cli/args';
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
startBackgroundWarmups: () => void;
logInfo: (message: string) => void;
}) {
return () => ({
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
logInfo: (message: string) => deps.logInfo(message),
});
}

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetFieldGroupingResolverHandler,
createSetFieldGroupingResolverHandler,
} from './field-grouping-resolver';
test('get field grouping resolver returns current resolver', () => {
const resolver = () => undefined;
const getResolver = createGetFieldGroupingResolverHandler({
getResolver: () => resolver,
});
assert.equal(getResolver(), resolver);
});
test('set field grouping resolver clears resolver when null is provided', () => {
let current: ((choice: unknown) => void) | null = () => undefined;
const setResolver = createSetFieldGroupingResolverHandler({
setResolver: (resolver) => {
current = resolver as never;
},
nextSequence: () => 1,
getSequence: () => 1,
});
setResolver(null);
assert.equal(current, null);
});
test('set field grouping resolver wraps resolver and ignores stale sequence', () => {
const calls: string[] = [];
let current: ((choice: unknown) => void) | null = null;
let sequence = 0;
const setResolver = createSetFieldGroupingResolverHandler({
setResolver: (resolver) => {
current = resolver as never;
},
nextSequence: () => {
sequence += 1;
return sequence;
},
getSequence: () => sequence,
});
setResolver((choice) => calls.push(`new:${choice}`));
const firstWrapped = current!;
setResolver((choice) => calls.push(`latest:${choice}`));
const latestWrapped = current!;
firstWrapped('A');
latestWrapped('B');
assert.deepEqual(calls, ['latest:B']);
});

View File

@@ -0,0 +1,29 @@
import type { KikuFieldGroupingChoice } from '../../types';
type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null;
export function createGetFieldGroupingResolverHandler(deps: {
getResolver: () => FieldGroupingResolver;
}) {
return (): FieldGroupingResolver => deps.getResolver();
}
export function createSetFieldGroupingResolverHandler(deps: {
setResolver: (resolver: FieldGroupingResolver) => void;
nextSequence: () => number;
getSequence: () => number;
}) {
return (resolver: FieldGroupingResolver): void => {
if (!resolver) {
deps.setResolver(null);
return;
}
const sequence = deps.nextSequence();
const wrappedResolver = (choice: KikuFieldGroupingChoice): void => {
if (sequence !== deps.getSequence()) return;
resolver(choice);
};
deps.setResolver(wrappedResolver);
};
}

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetJellyfinClientInfoHandler,
createGetResolvedJellyfinConfigHandler,
} from './jellyfin-client-info';
test('get resolved jellyfin config returns jellyfin section from resolved config', () => {
const jellyfin = { url: 'https://jellyfin.local' } as never;
const getConfig = createGetResolvedJellyfinConfigHandler({
getResolvedConfig: () => ({ jellyfin } as never),
});
assert.equal(getConfig(), jellyfin);
});
test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' } as never),
getDefaultJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
});
});
test('jellyfin client info keeps explicit config values', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'Custom',
clientVersion: '2.3.4',
deviceId: 'custom-device',
}) as never,
getDefaultJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
});
assert.deepEqual(getClientInfo(), {
clientName: 'Custom',
clientVersion: '2.3.4',
deviceId: 'custom-device',
});
});

View File

@@ -0,0 +1,33 @@
export function createGetResolvedJellyfinConfigHandler(deps: {
getResolvedConfig: () => { jellyfin: unknown };
}) {
return () => deps.getResolvedConfig().jellyfin as never;
}
export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => {
clientName?: string;
clientVersion?: string;
deviceId?: string;
};
getDefaultJellyfinConfig: () => {
clientName?: string;
clientVersion?: string;
deviceId?: string;
};
}) {
return (
config = deps.getResolvedJellyfinConfig(),
): {
clientName: string;
clientVersion: string;
deviceId: string;
} => {
const defaults = deps.getDefaultJellyfinConfig();
return {
clientName: config.clientName || defaults.clientName || '',
clientVersion: config.clientVersion || defaults.clientVersion || '',
deviceId: config.deviceId || defaults.deviceId || '',
};
};
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createApplyJellyfinMpvDefaultsHandler,
createGetDefaultSocketPathHandler,
} from './mpv-jellyfin-defaults';
test('apply jellyfin mpv defaults sends expected property commands', () => {
const calls: string[] = [];
const applyDefaults = createApplyJellyfinMpvDefaultsHandler({
sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')),
jellyfinLangPref: 'ja,jp',
});
applyDefaults({});
assert.deepEqual(calls, [
'set_property:sub-auto:fuzzy',
'set_property:aid:auto',
'set_property:sid:auto',
'set_property:secondary-sid:auto',
'set_property:secondary-sub-visibility:no',
'set_property:alang:ja,jp',
'set_property:slang:ja,jp',
]);
});
test('get default socket path returns platform specific value', () => {
const getWindowsPath = createGetDefaultSocketPathHandler({ platform: 'win32' });
const getUnixPath = createGetDefaultSocketPathHandler({ platform: 'darwin' });
assert.equal(getWindowsPath(), '\\\\.\\pipe\\subminer-socket');
assert.equal(getUnixPath(), '/tmp/subminer-socket');
});

View File

@@ -0,0 +1,30 @@
type MpvClientLike = unknown;
export function createApplyJellyfinMpvDefaultsHandler(deps: {
sendMpvCommandRuntime: (
client: MpvClientLike,
command: [string, string, string],
) => void;
jellyfinLangPref: string;
}) {
return (client: MpvClientLike): void => {
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]);
deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]);
};
}
export function createGetDefaultSocketPathHandler(deps: {
platform: string;
}) {
return (): string => {
if (deps.platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket';
}
return '/tmp/subminer-socket';
};
}

View File

@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBroadcastRuntimeOptionsChangedHandler,
createGetRuntimeOptionsStateHandler,
createOpenRuntimeOptionsPaletteHandler,
createRestorePreviousSecondarySubVisibilityHandler,
createSendToActiveOverlayWindowHandler,
createSetOverlayDebugVisualizationEnabledHandler,
} from './overlay-runtime-main-actions';
test('runtime options state handler returns empty list without manager', () => {
const getState = createGetRuntimeOptionsStateHandler({
getRuntimeOptionsManager: () => null,
});
assert.deepEqual(getState(), []);
});
test('runtime options state handler returns list from manager', () => {
const getState = createGetRuntimeOptionsStateHandler({
getRuntimeOptionsManager: () =>
({
listOptions: () => [
{
id: 'anki.autoUpdateNewCards',
label: 'X',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
],
}) as never,
});
assert.deepEqual(getState(), [
{
id: 'anki.autoUpdateNewCards',
label: 'X',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
]);
});
test('restore previous secondary subtitle visibility no-ops without connected mpv client', () => {
let restored = false;
const restore = createRestorePreviousSecondarySubVisibilityHandler({
getMpvClient: () => ({ connected: false, restorePreviousSecondarySubVisibility: () => (restored = true) }),
});
restore();
assert.equal(restored, false);
});
test('restore previous secondary subtitle visibility calls runtime when connected', () => {
let restored = false;
const restore = createRestorePreviousSecondarySubVisibilityHandler({
getMpvClient: () => ({ connected: true, restorePreviousSecondarySubVisibility: () => (restored = true) }),
});
restore();
assert.equal(restored, true);
});
test('broadcast runtime options changed passes through state getter and broadcaster', () => {
const calls: string[] = [];
const broadcast = createBroadcastRuntimeOptionsChangedHandler({
broadcastRuntimeOptionsChangedRuntime: (getState, emit) => {
calls.push(`state:${JSON.stringify(getState())}`);
emit('runtime-options:changed', { id: 1 });
},
getRuntimeOptionsState: () => [
{
id: 'anki.autoUpdateNewCards',
label: 'X',
scope: 'ankiConnect',
valueType: 'boolean',
value: true,
allowedValues: [true, false],
requiresRestart: false,
},
],
broadcastToOverlayWindows: (channel, payload) => calls.push(`emit:${channel}:${JSON.stringify(payload)}`),
});
broadcast();
assert.deepEqual(calls, [
'state:[{"id":"anki.autoUpdateNewCards","label":"X","scope":"ankiConnect","valueType":"boolean","value":true,"allowedValues":[true,false],"requiresRestart":false}]',
'emit:runtime-options:changed:{"id":1}',
]);
});
test('send to active overlay window delegates to runtime sender', () => {
const send = createSendToActiveOverlayWindowHandler({
sendToActiveOverlayWindowRuntime: (channel, payload) => channel === 'ok' && payload === 1,
});
assert.equal(send('ok', 1), true);
assert.equal(send('no', 1), false);
});
test('set overlay debug visualization enabled delegates with current state and broadcast', () => {
const calls: string[] = [];
let current = false;
const setEnabled = createSetOverlayDebugVisualizationEnabledHandler({
setOverlayDebugVisualizationEnabledRuntime: (curr, next, setCurrent, broadcast) => {
calls.push(`runtime:${curr}->${next}`);
setCurrent(next);
broadcast('overlay-debug:set', next);
},
getCurrentEnabled: () => current,
setCurrentEnabled: (enabled) => {
current = enabled;
calls.push(`set:${enabled}`);
},
broadcastToOverlayWindows: (channel, value) => calls.push(`emit:${channel}:${value}`),
});
setEnabled(true);
assert.equal(current, true);
assert.deepEqual(calls, ['runtime:false->true', 'set:true', 'emit:overlay-debug:set:true']);
});
test('open runtime options palette handler delegates to runtime', () => {
let opened = false;
const open = createOpenRuntimeOptionsPaletteHandler({
openRuntimeOptionsPaletteRuntime: () => {
opened = true;
},
});
open();
assert.equal(opened, true);
});

View File

@@ -0,0 +1,90 @@
import type { RuntimeOptionState } from '../../types';
import type { OverlayHostedModal } from '../overlay-runtime';
type RuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[];
};
type MpvClientLike = {
connected: boolean;
restorePreviousSecondarySubVisibility: () => void;
};
export function createGetRuntimeOptionsStateHandler(deps: {
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
}) {
return (): RuntimeOptionState[] => {
const manager = deps.getRuntimeOptionsManager();
if (!manager) return [];
return manager.listOptions();
};
}
export function createRestorePreviousSecondarySubVisibilityHandler(deps: {
getMpvClient: () => MpvClientLike | null;
}) {
return (): void => {
const client = deps.getMpvClient();
if (!client || !client.connected) return;
client.restorePreviousSecondarySubVisibility();
};
}
export function createBroadcastRuntimeOptionsChangedHandler(deps: {
broadcastRuntimeOptionsChangedRuntime: (
getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
) => void;
getRuntimeOptionsState: () => RuntimeOptionState[];
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}) {
return (): void => {
deps.broadcastRuntimeOptionsChangedRuntime(
() => deps.getRuntimeOptionsState(),
(channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args),
);
};
}
export function createSendToActiveOverlayWindowHandler(deps: {
sendToActiveOverlayWindowRuntime: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
) => boolean;
}) {
return (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
): boolean => deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions);
}
export function createSetOverlayDebugVisualizationEnabledHandler(deps: {
setOverlayDebugVisualizationEnabledRuntime: (
currentEnabled: boolean,
nextEnabled: boolean,
setCurrentEnabled: (enabled: boolean) => void,
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
) => void;
getCurrentEnabled: () => boolean;
setCurrentEnabled: (enabled: boolean) => void;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}) {
return (enabled: boolean): void => {
deps.setOverlayDebugVisualizationEnabledRuntime(
deps.getCurrentEnabled(),
enabled,
(next) => deps.setCurrentEnabled(next),
(channel, ...args) => deps.broadcastToOverlayWindows(channel, ...args),
);
};
}
export function createOpenRuntimeOptionsPaletteHandler(deps: {
openRuntimeOptionsPaletteRuntime: () => void;
}) {
return (): void => {
deps.openRuntimeOptionsPaletteRuntime();
};
}

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildCycleSecondarySubModeMainDepsHandler } from './secondary-sub-mode-main-deps';
import type { SecondarySubMode } from '../../types';
test('cycle secondary sub mode main deps builder maps state and broadcasts with channel', () => {
const calls: string[] = [];
let mode: SecondarySubMode = 'hover';
let lastToggleAt = 100;
const deps = createBuildCycleSecondarySubModeMainDepsHandler({
getSecondarySubMode: () => mode,
setSecondarySubMode: (nextMode) => {
mode = nextMode;
calls.push(`set-mode:${nextMode}`);
},
getLastSecondarySubToggleAtMs: () => lastToggleAt,
setLastSecondarySubToggleAtMs: (timestampMs) => {
lastToggleAt = timestampMs;
calls.push(`set-ts:${timestampMs}`);
},
broadcastToOverlayWindows: (channel, nextMode) => calls.push(`broadcast:${channel}:${nextMode}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
})();
assert.equal(deps.getSecondarySubMode(), 'hover');
assert.equal(deps.getLastSecondarySubToggleAtMs(), 100);
deps.setSecondarySubMode('visible');
deps.setLastSecondarySubToggleAtMs(200);
deps.broadcastSecondarySubMode('visible');
deps.showMpvOsd('Secondary subtitle: visible');
assert.equal(mode, 'visible');
assert.equal(lastToggleAt, 200);
assert.deepEqual(calls, [
'set-mode:visible',
'set-ts:200',
'broadcast:secondary-subtitle:mode:visible',
'osd:Secondary subtitle: visible',
]);
});

View File

@@ -0,0 +1,21 @@
import type { SecondarySubMode } from '../../types';
export function createBuildCycleSecondarySubModeMainDepsHandler(deps: {
getSecondarySubMode: () => SecondarySubMode;
setSecondarySubMode: (mode: SecondarySubMode) => void;
getLastSecondarySubToggleAtMs: () => number;
setLastSecondarySubToggleAtMs: (timestampMs: number) => void;
broadcastToOverlayWindows: (channel: string, mode: SecondarySubMode) => void;
showMpvOsd: (text: string) => void;
}) {
return () => ({
getSecondarySubMode: () => deps.getSecondarySubMode(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
getLastSecondarySubToggleAtMs: () => deps.getLastSecondarySubToggleAtMs(),
setLastSecondarySubToggleAtMs: (timestampMs: number) =>
deps.setLastSecondarySubToggleAtMs(timestampMs),
broadcastSecondarySubMode: (mode: SecondarySubMode) =>
deps.broadcastToOverlayWindows('secondary-subtitle:mode', mode),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
});
}

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildStartupBootstrapMainDepsHandler } from './startup-bootstrap-main-deps';
test('startup bootstrap main deps builder maps deps and handles generate-config callbacks', () => {
const calls: string[] = [];
let exitCode = 0;
const deps = createBuildStartupBootstrapMainDepsHandler({
argv: ['node', 'main.js'],
parseArgs: () => ({}) as never,
setLogLevel: (level) => calls.push(`log:${level}`),
forceX11Backend: () => calls.push('force-x11'),
enforceUnsupportedWaylandMode: () => calls.push('guard-wayland'),
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: (code) => {
exitCode = code;
calls.push(`exit:${code}`);
},
quitApp: () => calls.push('quit'),
logGenerateConfigError: (message) => calls.push(`error:${message}`),
startAppLifecycle: () => calls.push('start-lifecycle'),
})();
assert.deepEqual(deps.argv, ['node', 'main.js']);
assert.equal(deps.getDefaultSocketPath(), '/tmp/mpv.sock');
deps.setLogLevel('debug', 'config');
deps.forceX11Backend({} as never);
deps.enforceUnsupportedWaylandMode({} as never);
deps.startAppLifecycle({} as never);
deps.onConfigGenerated(7);
assert.equal(exitCode, 7);
deps.onGenerateConfigError(new Error('boom'));
assert.equal(exitCode, 1);
assert.deepEqual(calls, [
'log:debug',
'force-x11',
'guard-wayland',
'start-lifecycle',
'exit:7',
'quit',
'error:Failed to generate config: boom',
'exit:1',
'quit',
]);
});

View File

@@ -0,0 +1,62 @@
import type { CliArgs } from '../../cli/args';
import type { LogLevelSource } from '../../logger';
import type { ResolvedConfig } from '../../types';
import type { StartupBootstrapRuntimeFactoryDeps } from '../startup';
export function createBuildStartupBootstrapMainDepsHandler(deps: {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
shouldStartApp: (args: CliArgs) => boolean;
getDefaultSocketPath: () => string;
defaultTexthookerPort: number;
configDir: string;
defaultConfig: ResolvedConfig;
generateConfigTemplate: (config: ResolvedConfig) => string;
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => Promise<number>;
setExitCode: (code: number) => void;
quitApp: () => void;
logGenerateConfigError: (message: string) => void;
startAppLifecycle: (args: CliArgs) => void;
}) {
return (): StartupBootstrapRuntimeFactoryDeps => ({
argv: deps.argv,
parseArgs: (argv: string[]) => deps.parseArgs(argv),
setLogLevel: (level: string, source: LogLevelSource) => deps.setLogLevel(level, source),
forceX11Backend: (args: CliArgs) => deps.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args: CliArgs) => deps.enforceUnsupportedWaylandMode(args),
shouldStartApp: (args: CliArgs) => deps.shouldStartApp(args),
getDefaultSocketPath: () => deps.getDefaultSocketPath(),
defaultTexthookerPort: deps.defaultTexthookerPort,
configDir: deps.configDir,
defaultConfig: deps.defaultConfig,
generateConfigTemplate: (config: ResolvedConfig) => deps.generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => deps.generateDefaultConfigFile(args, options),
onConfigGenerated: (exitCode: number) => {
deps.setExitCode(exitCode);
deps.quitApp();
},
onGenerateConfigError: (error: Error) => {
deps.logGenerateConfigError(`Failed to generate config: ${error.message}`);
deps.setExitCode(1);
deps.quitApp();
},
startAppLifecycle: (args: CliArgs) => deps.startAppLifecycle(args),
});
}

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildCriticalConfigErrorMainDepsHandler,
createBuildReloadConfigMainDepsHandler,
} from './startup-config-main-deps';
test('reload config main deps builder maps callbacks and fail handlers', async () => {
const calls: string[] = [];
const deps = createBuildReloadConfigMainDepsHandler({
reloadConfigStrict: () => ({ ok: true }),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('start-hot-reload'),
refreshAnilistClientSecretState: async (options) => {
calls.push(`refresh:${options.force}`);
return true;
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`),
quit: () => calls.push('quit'),
},
})();
assert.deepEqual(deps.reloadConfigStrict(), { ok: true });
deps.logInfo('x');
deps.logWarning('y');
deps.showDesktopNotification('SubMiner', { body: 'warn' });
deps.startConfigHotReload();
await deps.refreshAnilistClientSecretState({ force: true });
deps.failHandlers.logError('bad');
deps.failHandlers.showErrorBox('Oops', 'Details');
deps.failHandlers.quit();
assert.deepEqual(calls, [
'info:x',
'warn:y',
'notify:SubMiner:warn',
'start-hot-reload',
'refresh:true',
'error:bad',
'error-box:Oops:Details',
'quit',
]);
});
test('critical config main deps builder maps config path and fail handlers', () => {
const calls: string[] = [];
const deps = createBuildCriticalConfigErrorMainDepsHandler({
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`error-box:${title}:${details}`),
quit: () => calls.push('quit'),
},
})();
assert.equal(deps.getConfigPath(), '/tmp/config.jsonc');
deps.failHandlers.logError('bad');
deps.failHandlers.showErrorBox('Oops', 'Details');
deps.failHandlers.quit();
assert.deepEqual(calls, ['error:bad', 'error-box:Oops:Details', 'quit']);
});

View File

@@ -0,0 +1,47 @@
export function createBuildReloadConfigMainDepsHandler(deps: {
reloadConfigStrict: () => unknown;
logInfo: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
}) {
return () => ({
reloadConfigStrict: () => deps.reloadConfigStrict() as never,
logInfo: (message: string) => deps.logInfo(message),
logWarning: (message: string) => deps.logWarning(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
startConfigHotReload: () => deps.startConfigHotReload(),
refreshAnilistClientSecretState: (options: { force: boolean }) =>
deps.refreshAnilistClientSecretState(options),
failHandlers: {
logError: (details: string) => deps.failHandlers.logError(details),
showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details),
quit: () => deps.failHandlers.quit(),
},
});
}
export function createBuildCriticalConfigErrorMainDepsHandler(deps: {
getConfigPath: () => string;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
}) {
return () => ({
getConfigPath: () => deps.getConfigPath(),
failHandlers: {
logError: (details: string) => deps.failHandlers.logError(details),
showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details),
quit: () => deps.failHandlers.quit(),
},
});
}

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './startup-lifecycle-main-deps';
test('app lifecycle runtime runner main deps builder maps lifecycle callbacks', async () => {
const calls: string[] = [];
const deps = createBuildAppLifecycleRuntimeRunnerMainDepsHandler({
app: {} as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => calls.push('handle-cli'),
printHelp: () => calls.push('help'),
logNoRunningInstance: () => calls.push('no-instance'),
onReady: async () => {
calls.push('ready');
},
onWillQuitCleanup: () => calls.push('cleanup'),
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push('restore'),
shouldQuitOnWindowAllClosed: () => false,
})();
assert.equal(deps.platform, 'darwin');
assert.equal(deps.shouldStartApp({} as never), true);
deps.handleCliCommand({} as never, 'initial');
deps.printHelp();
deps.logNoRunningInstance();
await deps.onReady();
deps.onWillQuitCleanup();
deps.restoreWindowsOnActivate();
assert.equal(deps.shouldRestoreWindowsOnActivate(), true);
assert.equal(deps.shouldQuitOnWindowAllClosed(), false);
assert.deepEqual(calls, ['handle-cli', 'help', 'no-instance', 'ready', 'cleanup', 'restore']);
});

View File

@@ -0,0 +1,20 @@
import type { AppLifecycleRuntimeRunnerParams } from '../startup-lifecycle';
export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
deps: AppLifecycleRuntimeRunnerParams,
) {
return (): AppLifecycleRuntimeRunnerParams => ({
app: deps.app,
platform: deps.platform,
shouldStartApp: deps.shouldStartApp,
parseArgs: deps.parseArgs,
handleCliCommand: deps.handleCliCommand,
printHelp: deps.printHelp,
logNoRunningInstance: deps.logNoRunningInstance,
onReady: deps.onReady,
onWillQuitCleanup: deps.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: deps.restoreWindowsOnActivate,
shouldQuitOnWindowAllClosed: deps.shouldQuitOnWindowAllClosed,
});
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildTokenizerDepsMainHandler,
createCreateMecabTokenizerAndCheckMainHandler,
createPrewarmSubtitleDictionariesMainHandler,
} from './subtitle-tokenization-main-deps';
test('tokenizer deps builder records known-word lookups and maps readers', () => {
const calls: string[] = [];
const deps = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => ({ id: 'ext' }),
getYomitanParserWindow: () => ({ id: 'window' }),
setYomitanParserWindow: () => calls.push('set-window'),
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => calls.push('set-ready'),
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => calls.push('set-init'),
isKnownWord: (text) => text === 'known',
recordLookup: (hit) => calls.push(`lookup:${hit}`),
getKnownWordMatchMode: () => 'exact',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: () => 5,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => ({ id: 'mecab' }),
})();
assert.equal(deps.isKnownWord('known'), true);
assert.equal(deps.isKnownWord('unknown'), false);
deps.setYomitanParserWindow({});
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
assert.equal(deps.getMinSentenceWordsForNPlusOne(), 3);
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
const calls: string[] = [];
type Tokenizer = { id: string };
let tokenizer: Tokenizer | null = null;
const run = createCreateMecabTokenizerAndCheckMainHandler<Tokenizer>({
getMecabTokenizer: () => tokenizer,
setMecabTokenizer: (next) => {
tokenizer = next;
calls.push('set');
},
createMecabTokenizer: () => {
calls.push('create');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check');
},
});
await run();
await run();
assert.deepEqual(calls, ['create', 'set', 'check', 'check']);
});
test('dictionary prewarm runs both dictionary loaders', async () => {
const calls: string[] = [];
const prewarm = createPrewarmSubtitleDictionariesMainHandler({
ensureJlptDictionaryLookup: async () => {
calls.push('jlpt');
},
ensureFrequencyDictionaryLookup: async () => {
calls.push('freq');
},
});
await prewarm();
assert.deepEqual(calls.sort(), ['freq', 'jlpt']);
});

View File

@@ -0,0 +1,69 @@
export function createBuildTokenizerDepsMainHandler(deps: {
getYomitanExt: () => unknown;
getYomitanParserWindow: () => unknown;
setYomitanParserWindow: (window: unknown) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
recordLookup: (hit: boolean) => void;
getKnownWordMatchMode: () => unknown;
getMinSentenceWordsForNPlusOne: () => number;
getJlptLevel: (text: string) => unknown;
getJlptEnabled: () => boolean;
getFrequencyDictionaryEnabled: () => boolean;
getFrequencyRank: (text: string) => unknown;
getYomitanGroupDebugEnabled: () => boolean;
getMecabTokenizer: () => unknown;
}) {
return () => ({
getYomitanExt: () => deps.getYomitanExt() as never,
getYomitanParserWindow: () => deps.getYomitanParserWindow() as never,
setYomitanParserWindow: (window: unknown) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise() as never,
setYomitanParserReadyPromise: (promise: Promise<void> | null) =>
deps.setYomitanParserReadyPromise(promise),
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise() as never,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
deps.setYomitanParserInitPromise(promise),
isKnownWord: (text: string) => {
const hit = deps.isKnownWord(text);
deps.recordLookup(hit);
return hit;
},
getKnownWordMatchMode: () => deps.getKnownWordMatchMode() as never,
getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(),
getJlptLevel: (text: string) => deps.getJlptLevel(text) as never,
getJlptEnabled: () => deps.getJlptEnabled(),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text) as never,
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
getMecabTokenizer: () => deps.getMecabTokenizer() as never,
});
}
export function createCreateMecabTokenizerAndCheckMainHandler<TMecab>(deps: {
getMecabTokenizer: () => TMecab | null;
setMecabTokenizer: (tokenizer: TMecab) => void;
createMecabTokenizer: () => TMecab;
checkAvailability: (tokenizer: TMecab) => Promise<unknown>;
}) {
return async (): Promise<void> => {
let tokenizer = deps.getMecabTokenizer();
if (!tokenizer) {
tokenizer = deps.createMecabTokenizer();
deps.setMecabTokenizer(tokenizer);
}
await deps.checkAvailability(tokenizer);
};
}
export function createPrewarmSubtitleDictionariesMainHandler(deps: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
}) {
return async (): Promise<void> => {
await Promise.all([deps.ensureJlptDictionaryLookup(), deps.ensureFrequencyDictionaryLookup()]);
};
}