refactor(main): finish TASK-94 composition-root extraction

Move IPC, shortcuts, startup lifecycle, and app-ready assembly behind dedicated runtime composers so main.ts stays focused on boot wiring while preserving behavior and test coverage.
This commit is contained in:
2026-02-20 20:14:39 -08:00
parent 8ad8ff1671
commit 23b88bf20e
13 changed files with 1421 additions and 890 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const composed = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ config: {} as never, warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
});
assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
});

View File

@@ -0,0 +1,58 @@
import { createAppReadyRuntimeRunner } from '../../app-lifecycle';
import { createBuildAppReadyRuntimeMainDepsHandler } from '../app-ready-main-deps';
import {
createBuildCriticalConfigErrorMainDepsHandler,
createBuildReloadConfigMainDepsHandler,
} from '../startup-config-main-deps';
import { createCriticalConfigErrorHandler, createReloadConfigHandler } from '../startup-config';
import { createBuildImmersionTrackerStartupMainDepsHandler } from '../immersion-startup-main-deps';
import { createImmersionTrackerStartupHandler } from '../immersion-startup';
type ReloadConfigMainDeps = Parameters<typeof createBuildReloadConfigMainDepsHandler>[0];
type CriticalConfigErrorMainDeps = Parameters<
typeof createBuildCriticalConfigErrorMainDepsHandler
>[0];
type AppReadyRuntimeMainDeps = Parameters<typeof createBuildAppReadyRuntimeMainDepsHandler>[0];
export type AppReadyComposerOptions = {
reloadConfigMainDeps: ReloadConfigMainDeps;
criticalConfigErrorMainDeps: CriticalConfigErrorMainDeps;
appReadyRuntimeMainDeps: Omit<AppReadyRuntimeMainDeps, 'reloadConfig' | 'onCriticalConfigErrors'>;
immersionTrackerStartupMainDeps: Parameters<
typeof createBuildImmersionTrackerStartupMainDepsHandler
>[0];
};
export type AppReadyComposerResult = {
reloadConfig: ReturnType<typeof createReloadConfigHandler>;
criticalConfigError: ReturnType<typeof createCriticalConfigErrorHandler>;
appReadyRuntimeRunner: ReturnType<typeof createAppReadyRuntimeRunner>;
};
export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppReadyComposerResult {
const reloadConfig = createReloadConfigHandler(
createBuildReloadConfigMainDepsHandler(options.reloadConfigMainDeps)(),
);
const criticalConfigError = createCriticalConfigErrorHandler(
createBuildCriticalConfigErrorMainDepsHandler(options.criticalConfigErrorMainDeps)(),
);
const appReadyRuntimeRunner = createAppReadyRuntimeRunner(
createBuildAppReadyRuntimeMainDepsHandler({
...options.appReadyRuntimeMainDeps,
reloadConfig,
createImmersionTracker: createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(
options.immersionTrackerStartupMainDeps,
)(),
),
onCriticalConfigErrors: criticalConfigError,
})(),
);
return {
reloadConfig,
criticalConfigError,
appReadyRuntimeRunner,
};
}

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false;
const composed = composeIpcRuntimeHandlers<{ value: number }, { ok: boolean; received: number }>({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
isMpvConnected: () => false,
hasRuntimeOptionsManager: () => true,
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => ({ ok: true, received: request.value }),
registration: {
runtimeOptions: {
getRuntimeOptionsManager: () => null,
showMpvOsd: () => {},
},
mainDeps: {
getInvisibleWindow: () => null,
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
getInvisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => ({}) as never,
getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never,
getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null,
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {
patchAnkiConnectEnabled: () => {},
getResolvedConfig: () => ({}) as never,
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getAnkiIntegration: () => null,
setAnkiIntegration: () => {},
getKnownWordCacheStatePath: () => '',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => (() => {}) as never,
broadcastRuntimeOptionsChanged: () => {},
getFieldGroupingResolver: () => null,
setFieldGroupingResolver: () => {},
parseMediaInfo: () => ({}) as never,
getCurrentMediaPath: () => null,
jimakuFetchJson: async () => ({ data: null }) as never,
getJimakuMaxEntryResults: () => 0,
getJimakuLanguagePreference: () => 'ja' as never,
resolveJimakuApiKey: async () => null,
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }),
},
registerIpcRuntimeServices: () => {
registered = true;
},
},
});
assert.equal(typeof composed.handleMpvCommandFromIpc, 'function');
assert.equal(typeof composed.runSubsyncManualFromIpc, 'function');
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
const result = await composed.runSubsyncManualFromIpc({ value: 7 });
assert.deepEqual(result, { ok: true, received: 7 });
composed.registerIpcRuntimeHandlers();
assert.equal(registered, true);
});

View File

@@ -0,0 +1,75 @@
import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime';
import {
createBuildMpvCommandFromIpcRuntimeMainDepsHandler,
createIpcRuntimeHandlers,
} from '../domains/ipc';
type MpvCommand = (string | number)[];
type IpcMainDepsWithoutHandlers = Omit<
RegisterIpcRuntimeServicesParams['mainDeps'],
'handleMpvCommand' | 'runSubsyncManual'
>;
type IpcRuntimeDeps<TRequest, TResult> = Parameters<
typeof createIpcRuntimeHandlers<TRequest, TResult>
>[0];
export type IpcRuntimeComposerOptions<TRequest, TResult> = {
mpvCommandMainDeps: Parameters<typeof createBuildMpvCommandFromIpcRuntimeMainDepsHandler>[0];
handleMpvCommandFromIpcRuntime: IpcRuntimeDeps<
TRequest,
TResult
>['handleMpvCommandFromIpcDeps']['handleMpvCommandFromIpcRuntime'];
runSubsyncManualFromIpc: (request: TRequest) => Promise<TResult>;
registration: {
runtimeOptions: RegisterIpcRuntimeServicesParams['runtimeOptions'];
mainDeps: IpcMainDepsWithoutHandlers;
ankiJimakuDeps: RegisterIpcRuntimeServicesParams['ankiJimakuDeps'];
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
};
};
export type IpcRuntimeComposerResult<TRequest, TResult> = {
handleMpvCommandFromIpc: (command: MpvCommand) => void;
runSubsyncManualFromIpc: (request: TRequest) => Promise<TResult>;
registerIpcRuntimeHandlers: () => void;
};
export function composeIpcRuntimeHandlers<TRequest, TResult>(
options: IpcRuntimeComposerOptions<TRequest, TResult>,
): IpcRuntimeComposerResult<TRequest, TResult> {
const mpvCommandFromIpcRuntimeMainDeps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
options.mpvCommandMainDeps,
)();
const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers<
TRequest,
TResult
>({
handleMpvCommandFromIpcDeps: {
handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime,
buildMpvCommandDeps: () => mpvCommandFromIpcRuntimeMainDeps,
},
runSubsyncManualFromIpcDeps: {
runManualFromIpc: (request) => options.runSubsyncManualFromIpc(request),
},
});
const registerIpcRuntimeHandlers = (): void => {
options.registration.registerIpcRuntimeServices({
runtimeOptions: options.registration.runtimeOptions,
mainDeps: {
...options.registration.mainDeps,
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
runSubsyncManual: (request) => runSubsyncManualFromIpc(request as TRequest),
},
ankiJimakuDeps: options.registration.ankiJimakuDeps,
});
};
return {
handleMpvCommandFromIpc: (command) => handleMpvCommandFromIpc(command),
runSubsyncManualFromIpc: (request) => runSubsyncManualFromIpc(request),
registerIpcRuntimeHandlers,
};
}

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const composed = composeShortcutRuntimes({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => ({}) as never,
defaultConfig: {} as never,
resolveConfiguredShortcuts: () => ({}) as never,
},
buildRegisterGlobalShortcutsMainDeps: () => ({
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
unregisterAllGlobalShortcuts: () => {},
registerGlobalShortcuts: () => {},
syncOverlayShortcuts: () => {},
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: {
register: () => true,
unregister: () => {},
},
showMpvOsd: () => {},
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: () => {},
onMineSentenceDigit: () => {},
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {},
unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {},
refreshOverlayShortcuts: () => {},
},
},
});
assert.equal(typeof composed.getConfiguredShortcuts, 'function');
assert.equal(typeof composed.registerGlobalShortcuts, 'function');
assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function');
assert.equal(typeof composed.cancelPendingMultiCopy, 'function');
assert.equal(typeof composed.startPendingMultiCopy, 'function');
assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.registerOverlayShortcuts, 'function');
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
});

View File

@@ -0,0 +1,59 @@
import { createNumericShortcutRuntime } from '../../../core/services/numeric-shortcut';
import {
createBuildNumericShortcutRuntimeMainDepsHandler,
createGlobalShortcutsRuntimeHandlers,
createNumericShortcutSessionRuntimeHandlers,
createOverlayShortcutsRuntimeHandlers,
} from '../domains/shortcuts';
type GlobalShortcutsOptions = Parameters<typeof createGlobalShortcutsRuntimeHandlers>[0];
type NumericShortcutRuntimeMainDeps = Parameters<
typeof createBuildNumericShortcutRuntimeMainDepsHandler
>[0];
type NumericSessionOptions = Omit<
Parameters<typeof createNumericShortcutSessionRuntimeHandlers>[0],
'multiCopySession' | 'mineSentenceSession'
>;
type OverlayShortcutsMainDeps = Parameters<
typeof createOverlayShortcutsRuntimeHandlers
>[0]['overlayShortcutsRuntimeMainDeps'];
export type ShortcutsRuntimeComposerOptions = {
globalShortcuts: GlobalShortcutsOptions;
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDeps;
numericSessions: NumericSessionOptions;
overlayShortcutsRuntimeMainDeps: OverlayShortcutsMainDeps;
};
export type ShortcutsRuntimeComposerResult = ReturnType<
typeof createGlobalShortcutsRuntimeHandlers
> &
ReturnType<typeof createNumericShortcutSessionRuntimeHandlers> &
ReturnType<typeof createOverlayShortcutsRuntimeHandlers>;
export function composeShortcutRuntimes(
options: ShortcutsRuntimeComposerOptions,
): ShortcutsRuntimeComposerResult {
const globalShortcuts = createGlobalShortcutsRuntimeHandlers(options.globalShortcuts);
const numericShortcutRuntimeMainDeps = createBuildNumericShortcutRuntimeMainDepsHandler(
options.numericShortcutRuntimeMainDeps,
)();
const numericShortcutRuntime = createNumericShortcutRuntime(numericShortcutRuntimeMainDeps);
const numericSessions = createNumericShortcutSessionRuntimeHandlers({
multiCopySession: numericShortcutRuntime.createSession(),
mineSentenceSession: numericShortcutRuntime.createSession(),
onMultiCopyDigit: options.numericSessions.onMultiCopyDigit,
onMineSentenceDigit: options.numericSessions.onMineSentenceDigit,
});
const overlayShortcuts = createOverlayShortcutsRuntimeHandlers({
overlayShortcutsRuntimeMainDeps: options.overlayShortcutsRuntimeMainDeps,
});
return {
...globalShortcuts,
...numericSessions,
...overlayShortcuts,
};
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {},
registerSecondInstance: () => {},
handleAnilistSetupProtocolUrl: () => false,
findAnilistSetupDeepLinkArgvUrl: () => null,
logUnhandledOpenUrl: () => {},
logUnhandledSecondInstanceUrl: () => {},
},
onWillQuitCleanupMainDeps: {
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
stopJellyfinRemoteSession: async () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => false,
getAllWindowCount: () => 0,
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {},
createInvisibleWindow: () => {},
updateVisibleOverlayVisibility: () => {},
updateInvisibleOverlayVisibility: () => {},
},
});
assert.equal(typeof composed.registerProtocolUrlHandlers, 'function');
assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
});

View File

@@ -0,0 +1,65 @@
import {
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from '../app-lifecycle-actions';
import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
type RegisterProtocolUrlHandlersMainDeps = Parameters<
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
>[0];
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
>[0];
type RestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildRestoreWindowsOnActivateMainDepsHandler
>[0];
export type StartupLifecycleComposerOptions = {
registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps;
onWillQuitCleanupMainDeps: OnWillQuitCleanupDeps;
shouldRestoreWindowsOnActivateMainDeps: ShouldRestoreWindowsOnActivateMainDeps;
restoreWindowsOnActivateMainDeps: RestoreWindowsOnActivateMainDeps;
};
export type StartupLifecycleComposerResult = {
registerProtocolUrlHandlers: () => void;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
};
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
options.registerProtocolUrlHandlersMainDeps,
)();
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler(
createBuildShouldRestoreWindowsOnActivateMainDepsHandler(
options.shouldRestoreWindowsOnActivateMainDeps,
)(),
);
const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler(
createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(),
);
return {
registerProtocolUrlHandlers: () =>
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
};
}