From 23b2360ac4e9d20c89cc980cbd7b8e76d1a421c5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 27 Mar 2026 22:54:03 -0700 Subject: [PATCH] refactor: split main boot phases --- ...ot-phase-services-runtimes-and-handlers.md | 39 +- .../2026-03-27-task-238.7-main-boot-split.md | 6 + docs/architecture/README.md | 1 + src/main.ts | 331 +++++++++-------- src/main/boot/handlers.test.ts | 94 +++++ src/main/boot/handlers.ts | 40 +++ src/main/boot/runtimes.test.ts | 339 ++++++++++++++++++ src/main/boot/runtimes.ts | 127 +++++++ src/main/boot/services.test.ts | 83 +++++ src/main/boot/services.ts | 262 ++++++++++++++ 10 files changed, 1165 insertions(+), 157 deletions(-) create mode 100644 changes/2026-03-27-task-238.7-main-boot-split.md create mode 100644 src/main/boot/handlers.test.ts create mode 100644 src/main/boot/handlers.ts create mode 100644 src/main/boot/runtimes.test.ts create mode 100644 src/main/boot/runtimes.ts create mode 100644 src/main/boot/services.test.ts create mode 100644 src/main/boot/services.ts diff --git a/backlog/tasks/task-238.7 - Split-src-main.ts-into-boot-phase-services-runtimes-and-handlers.md b/backlog/tasks/task-238.7 - Split-src-main.ts-into-boot-phase-services-runtimes-and-handlers.md index 10d2c82..8fe8aad 100644 --- a/backlog/tasks/task-238.7 - Split-src-main.ts-into-boot-phase-services-runtimes-and-handlers.md +++ b/backlog/tasks/task-238.7 - Split-src-main.ts-into-boot-phase-services-runtimes-and-handlers.md @@ -1,9 +1,10 @@ --- id: TASK-238.7 title: Split src/main.ts into boot-phase services, runtimes, and handlers -status: To Do +status: Done assignee: [] created_date: '2026-03-27 00:00' +updated_date: '2026-03-27 22:45' labels: - tech-debt - runtime @@ -31,11 +32,11 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m ## Acceptance Criteria -- [ ] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`. -- [ ] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch. -- [ ] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection. -- [ ] #4 Existing startup behavior remains unchanged across desktop and headless flows. -- [ ] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes. +- [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`. +- [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch. +- [x] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection. +- [x] #4 Existing startup behavior remains unchanged across desktop and headless flows. +- [x] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes. ## Implementation Plan @@ -56,3 +57,29 @@ Guardrails: - Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer. - Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here. + +## Implementation Notes + + +Added boot-phase modules under `src/main/boot/`: +`services.ts` for config/user-data/runtime-registry/overlay bootstrap service construction, +`runtimes.ts` for named runtime/composer entrypoints and grouped boot-phase seams, +and `handlers.ts` for handler/composer boot entrypoints. + +Rewired `src/main.ts` to source boot-phase service construction from `createMainBootServices(...)` and to route runtime/handler composition through boot-level exports instead of keeping the entrypoint as the direct owner of every composition import. + +Added focused tests for the new boot seams in +`src/main/boot/services.test.ts`, +`src/main/boot/runtimes.test.ts`, +and `src/main/boot/handlers.test.ts`. + +Updated internal architecture docs to note that `src/main/boot/` now owns boot-phase assembly seams so `src/main.ts` can stay centered on lifecycle coordination and startup-path selection. + + +## Final Summary + + +TASK-238.7 is complete. Verification passed with focused boot tests, `bun run typecheck`, `bun run test:fast`, and `bun run build`. `src/main.ts` still acts as the composition root, but the boot-phase split now moves service instantiation, runtime composition seams, and handler composition seams into dedicated `src/main/boot/*` modules so the entrypoint reads more like a lifecycle coordinator than a single monolithic bootstrap file. + +Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.7-main-boot-split.md` for the internal runtime architecture pass. + diff --git a/changes/2026-03-27-task-238.7-main-boot-split.md b/changes/2026-03-27-task-238.7-main-boot-split.md new file mode 100644 index 0000000..f5e01d6 --- /dev/null +++ b/changes/2026-03-27-task-238.7-main-boot-split.md @@ -0,0 +1,6 @@ +type: internal +area: runtime + +- Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules. +- Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green. +- Updated internal architecture/task docs to record the boot-phase split and new ownership boundary. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index fef7a27..c384176 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -24,6 +24,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into ## Current Shape - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. +- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. diff --git a/src/main.ts b/src/main.ts index 564df97..a11941f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -401,6 +401,31 @@ import { import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; +import { createMainBootServices } from './main/boot/services'; +import { + composeBootOverlayVisibilityRuntime, + composeBootJellyfinRuntimeHandlers, + composeBootAnilistSetupHandlers, + createBootMaybeFocusExistingAnilistSetupWindowHandler, + createBootBuildOpenAnilistSetupWindowMainDepsHandler, + createBootOpenAnilistSetupWindowHandler, + composeBootAnilistTrackingHandlers, + composeBootStatsStartupRuntime, + createBootRunStatsCliCommandHandler, + composeBootAppReadyRuntime, + composeBootMpvRuntimeHandlers, + createBootTrayRuntimeHandlers, + createBootYomitanProfilePolicy, + createBootYomitanExtensionRuntime, + createBootYomitanSettingsRuntime, +} from './main/boot/runtimes'; +import { + composeBootStartupLifecycleHandlers, + composeBootIpcRuntimeHandlers, + composeBootCliStartupHandlers, + composeBootHeadlessStartupHandlers, + composeBootOverlayWindowHandlers, +} from './main/boot/handlers'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; @@ -555,59 +580,6 @@ function applyJellyfinMpvDefaults( applyJellyfinMpvDefaultsHandler(client); } -const CONFIG_DIR = resolveConfigDir({ - platform: process.platform, - appDataDir: process.env.APPDATA, - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, -}); -const USER_DATA_PATH = CONFIG_DIR; -const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; -const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, 'immersion.sqlite'); -const configService = (() => { - try { - return new ConfigService(CONFIG_DIR); - } catch (error) { - if (error instanceof ConfigStartupParseError) { - failStartupFromConfig( - 'SubMiner config parse error', - buildConfigParseErrorDetails(error.path, error.parseError), - { - logError: (details) => console.error(details), - showErrorBox: (title, details) => dialog.showErrorBox(title, details), - quit: () => requestAppQuit(), - }, - ); - } - throw error; - } -})(); -const anilistTokenStore = createAnilistTokenStore( - path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE), - { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - warnUser: (message: string) => notifyAnilistTokenStoreWarning(message), - }, -); -const jellyfinTokenStore = createJellyfinTokenStore( - path.join(USER_DATA_PATH, JELLYFIN_TOKEN_STORE_FILE), - { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - }, -); -const anilistUpdateQueue = createAnilistUpdateQueue( - path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE), - { - info: (message: string) => console.info(message), - warn: (message: string, details?: unknown) => console.warn(message, details), - error: (message: string, details?: unknown) => console.error(message, details), - }, -); const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); const texthookerService = new Texthooker(() => { const config = getResolvedConfig(); @@ -644,9 +616,141 @@ const texthookerService = new Texthooker(() => { }, }; }); -const subtitleWsService = new SubtitleWebSocket(); -const annotationSubtitleWsService = new SubtitleWebSocket(); -const logger = createLogger('main'); +let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; +let syncOverlayVisibilityForModal: () => void = () => {}; +const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({ + platform: process.platform, +}); +const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); +const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps); + +function getDefaultSocketPath(): string { + return getDefaultSocketPathHandler(); +} +const bootServices = createMainBootServices({ + platform: process.platform, + argv: process.argv, + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + defaultMpvLogFile: DEFAULT_MPV_LOG_FILE, + envMpvLog: process.env.SUBMINER_MPV_LOG, + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + getDefaultSocketPath: () => getDefaultSocketPath(), + resolveConfigDir, + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + joinPath: (...parts) => path.join(...parts), + app, + shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(process.argv), + requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(app), + registerSecondInstanceHandlerEarly: (listener) => { + registerSecondInstanceHandlerEarly(app, listener); + }, + onConfigStartupParseError: (error) => { + failStartupFromConfig( + 'SubMiner config parse error', + buildConfigParseErrorDetails(error.path, error.parseError), + { + logError: (details) => console.error(details), + showErrorBox: (title, details) => dialog.showErrorBox(title, details), + quit: () => requestAppQuit(), + }, + ); + }, + createConfigService: (configDir) => new ConfigService(configDir), + createAnilistTokenStore: (targetPath) => + createAnilistTokenStore(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + warnUser: (message: string) => notifyAnilistTokenStoreWarning(message), + }), + createJellyfinTokenStore: (targetPath) => + createJellyfinTokenStore(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }), + createAnilistUpdateQueue: (targetPath) => + createAnilistUpdateQueue(targetPath, { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }), + createSubtitleWebSocket: () => new SubtitleWebSocket(), + createLogger, + createMainRuntimeRegistry, + createOverlayManager, + createOverlayModalInputState, + createOverlayContentMeasurementStore: ({ logger }) => { + const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({ + now: () => Date.now(), + warn: (message: string) => logger.warn(message), + }); + return createOverlayContentMeasurementStore(buildHandler()); + }, + getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal, + getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal, + createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => { + const buildHandler = createBuildOverlayModalRuntimeMainDepsHandler({ + getMainWindow: () => overlayManager.getMainWindow(), + getModalWindow: () => overlayManager.getModalWindow(), + createModalWindow: () => createModalWindow(), + getModalGeometry: () => getCurrentOverlayGeometry(), + setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), + }); + return createOverlayModalRuntimeService(buildHandler(), { + onModalStateChange: (isActive: boolean) => + overlayModalInputState.handleModalInputStateChange(isActive), + }); + }, + createAppState, +}) as { + configDir: string; + userDataPath: string; + defaultMpvLogPath: string; + defaultImmersionDbPath: string; + configService: ConfigService; + anilistTokenStore: ReturnType; + jellyfinTokenStore: ReturnType; + anilistUpdateQueue: ReturnType; + subtitleWsService: SubtitleWebSocket; + annotationSubtitleWsService: SubtitleWebSocket; + logger: ReturnType; + runtimeRegistry: ReturnType; + overlayManager: ReturnType; + overlayModalInputState: ReturnType; + overlayContentMeasurementStore: ReturnType; + overlayModalRuntime: ReturnType; + appState: ReturnType; + appLifecycleApp: { + requestSingleInstanceLock: () => boolean; + quit: () => void; + on: (event: string, listener: (...args: unknown[]) => void) => unknown; + whenReady: () => Promise; + }; +}; +const { + configDir: CONFIG_DIR, + userDataPath: USER_DATA_PATH, + defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, + defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH, + configService, + anilistTokenStore, + jellyfinTokenStore, + anilistUpdateQueue, + subtitleWsService, + annotationSubtitleWsService, + logger, + runtimeRegistry, + overlayManager, + overlayModalInputState, + overlayContentMeasurementStore, + overlayModalRuntime, + appState, + appLifecycleApp, +} = bootServices; notifyAnilistTokenStoreWarning = (message: string) => { logger.warn(`[AniList] ${message}`); try { @@ -681,41 +785,6 @@ const appLogger = { ); }, }; -const runtimeRegistry = createMainRuntimeRegistry(); -const appLifecycleApp = { - requestSingleInstanceLock: () => - shouldBypassSingleInstanceLockForArgv(process.argv) - ? true - : requestSingleInstanceLockEarly(app), - quit: () => app.quit(), - on: (event: string, listener: (...args: unknown[]) => void) => { - if (event === 'second-instance') { - registerSecondInstanceHandlerEarly( - app, - listener as (_event: unknown, argv: string[]) => void, - ); - return app; - } - app.on(event as Parameters[0], listener as (...args: any[]) => void); - return app; - }, - whenReady: () => app.whenReady(), -}; - -const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({ - platform: process.platform, -}); -const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler(); -const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps); - -function getDefaultSocketPath(): string { - return getDefaultSocketPathHandler(); -} - -if (!fs.existsSync(USER_DATA_PATH)) { - fs.mkdirSync(USER_DATA_PATH, { recursive: true }); -} -app.setPath('userData', USER_DATA_PATH); let forceQuitTimer: ReturnType | null = null; let statsServer: ReturnType | null = null; @@ -777,46 +846,6 @@ process.on('SIGTERM', () => { requestAppQuit(); }); -const overlayManager = createOverlayManager(); -let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; -let syncOverlayVisibilityForModal: () => void = () => {}; -const overlayModalInputState = createOverlayModalInputState({ - getModalWindow: () => overlayManager.getModalWindow(), - syncOverlayShortcutsForModal: (isActive) => { - syncOverlayShortcutsForModal(isActive); - }, - syncOverlayVisibilityForModal: () => { - syncOverlayVisibilityForModal(); - }, -}); - -const buildOverlayContentMeasurementStoreMainDepsHandler = - createBuildOverlayContentMeasurementStoreMainDepsHandler({ - now: () => Date.now(), - warn: (message: string) => logger.warn(message), - }); -const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({ - getMainWindow: () => overlayManager.getMainWindow(), - getModalWindow: () => overlayManager.getModalWindow(), - createModalWindow: () => createModalWindow(), - getModalGeometry: () => getCurrentOverlayGeometry(), - setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry), -}); -const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler(); -const overlayContentMeasurementStore = createOverlayContentMeasurementStore( - overlayContentMeasurementStoreMainDeps, -); -const overlayModalRuntime = createOverlayModalRuntimeService( - buildOverlayModalRuntimeMainDepsHandler(), - { - onModalStateChange: (isActive: boolean) => - overlayModalInputState.handleModalInputStateChange(isActive), - }, -); -const appState = createAppState({ - mpvSocketPath: getDefaultSocketPath(), - texthookerPort: DEFAULT_TEXTHOOKER_PORT, -}); const startBackgroundWarmupsIfAllowed = (): void => { startBackgroundWarmups(); }; @@ -1887,7 +1916,7 @@ const buildOpenRuntimeOptionsPaletteMainDepsHandler = createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), }); -const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ +const overlayVisibilityComposer = composeBootOverlayVisibilityRuntime({ overlayVisibilityRuntime, restorePreviousSecondarySubVisibilityMainDeps: buildRestorePreviousSecondarySubVisibilityMainDepsHandler(), @@ -1959,7 +1988,7 @@ const { stopJellyfinRemoteSession, runJellyfinCommand, openJellyfinSetupWindow, -} = composeJellyfinRuntimeHandlers({ +} = composeBootJellyfinRuntimeHandlers({ getResolvedJellyfinConfigMainDeps: { getResolvedConfig: () => getResolvedConfig(), loadStoredSession: () => jellyfinTokenStore.loadSession(), @@ -2259,7 +2288,7 @@ const { consumeAnilistSetupTokenFromUrl, handleAnilistSetupProtocolUrl, registerSubminerProtocolClient, -} = composeAnilistSetupHandlers({ +} = composeBootAnilistSetupHandlers({ notifyDeps: { hasMpvClient: () => Boolean(appState.mpvClient), showMpvOsd: (message) => showMpvOsd(message), @@ -2310,10 +2339,10 @@ const { }, }); -const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ +const maybeFocusExistingAnilistSetupWindow = createBootMaybeFocusExistingAnilistSetupWindowHandler({ getSetupWindow: () => appState.anilistSetupWindow, }); -const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler( +const buildOpenAnilistSetupWindowMainDepsHandler = createBootBuildOpenAnilistSetupWindowMainDepsHandler( { maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, createSetupWindow: createCreateAnilistSetupWindowHandler({ @@ -2361,7 +2390,7 @@ const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWi ); function openAnilistSetupWindow(): void { - createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); + createBootOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); } const { @@ -2375,7 +2404,7 @@ const { ensureAnilistMediaGuess, processNextAnilistRetryUpdate, maybeRunAnilistPostWatchUpdate, -} = composeAnilistTrackingHandlers({ +} = composeBootAnilistTrackingHandlers({ refreshClientSecretMainDeps: { getResolvedConfig: () => getResolvedConfig(), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), @@ -2642,7 +2671,7 @@ const { onWillQuitCleanup: onWillQuitCleanupHandler, shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, restoreWindowsOnActivate: restoreWindowsOnActivateHandler, -} = composeStartupLifecycleHandlers({ +} = composeBootStartupLifecycleHandlers({ registerProtocolUrlHandlersMainDeps: { registerOpenUrl: (listener) => { app.on('open-url', listener); @@ -2950,7 +2979,7 @@ const ensureImmersionTrackerStarted = (): void => { hasAttemptedImmersionTrackerStartup = true; createImmersionTrackerStartup(); }; -const statsStartupRuntime = composeStatsStartupRuntime({ +const statsStartupRuntime = composeBootStatsStartupRuntime({ ensureStatsServerStarted: () => ensureStatsServerStarted(), ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), stopBackgroundStatsServer: () => stopBackgroundStatsServer(), @@ -2964,7 +2993,7 @@ const statsStartupRuntime = composeStatsStartupRuntime({ }, }); -const runStatsCliCommand = createRunStatsCliCommandHandler({ +const runStatsCliCommand = createBootRunStatsCliCommandHandler({ getResolvedConfig: () => getResolvedConfig(), ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), ensureVocabularyCleanupTokenizerReady: async () => { @@ -3031,7 +3060,7 @@ async function runHeadlessInitialCommand(): Promise { } } -const { appReadyRuntimeRunner } = composeAppReadyRuntime({ +const { appReadyRuntimeRunner } = composeBootAppReadyRuntime({ reloadConfigMainDeps: { reloadConfigStrict: () => configService.reloadConfigStrict(), logInfo: (message) => appLogger.logInfo(message), @@ -3266,7 +3295,7 @@ const { startBackgroundWarmups, startTokenizationWarmups, isTokenizationWarmupReady, -} = composeMpvRuntimeHandlers< +} = composeBootMpvRuntimeHandlers< MpvIpcClient, ReturnType, SubtitleData @@ -4113,7 +4142,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen showMpvOsd: (text) => showMpvOsd(text), }); -const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ +const { registerIpcRuntimeHandlers } = composeBootIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), @@ -4333,7 +4362,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ registerIpcRuntimeServices, }, }); -const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ +const { handleCliCommand, handleInitialArgs } = composeBootCliStartupHandlers({ cliCommandContextMainDeps: { appState, setLogLevel: (level) => setLogLevel(level, 'cli'), @@ -4419,7 +4448,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ logInfo: (message) => logger.info(message), }, }); -const { runAndApplyStartupState } = composeHeadlessStartupHandlers< +const { runAndApplyStartupState } = composeBootHeadlessStartupHandlers< CliArgs, StartupState, ReturnType @@ -4487,7 +4516,7 @@ if (isAnilistTrackingEnabled(getResolvedConfig())) { } void initializeDiscordPresenceService(); const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = - composeOverlayWindowHandlers({ + composeBootOverlayWindowHandlers({ createOverlayWindowDeps: { createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), isDev, @@ -4513,7 +4542,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa setModalWindow: (window) => overlayManager.setModalWindow(window), }); const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = - createTrayRuntimeHandlers({ + createBootTrayRuntimeHandlers({ resolveTrayIconPathDeps: { resolveTrayIconPathRuntime, platform: process.platform, @@ -4560,12 +4589,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); -const yomitanProfilePolicy = createYomitanProfilePolicy({ +const yomitanProfilePolicy = createBootYomitanProfilePolicy({ externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, logInfo: (message) => logger.info(message), }); const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; -const yomitanExtensionRuntime = createYomitanExtensionRuntime({ +const yomitanExtensionRuntime = createBootYomitanExtensionRuntime({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, externalProfilePath: configuredExternalYomitanProfilePath, @@ -4647,7 +4676,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = }, }, }); -const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ +const { openYomitanSettings: openYomitanSettingsHandler } = createBootYomitanSettingsRuntime({ ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), getYomitanSession: () => appState.yomitanSession, openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => { diff --git a/src/main/boot/handlers.test.ts b/src/main/boot/handlers.test.ts new file mode 100644 index 0000000..3b26a59 --- /dev/null +++ b/src/main/boot/handlers.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMainBootHandlers } from './handlers'; + +test('createMainBootHandlers returns grouped handler bundles', () => { + const handlers = createMainBootHandlers({ + startupLifecycleDeps: { + registerProtocolUrlHandlersMainDeps: {} as never, + onWillQuitCleanupMainDeps: {} as never, + shouldRestoreWindowsOnActivateMainDeps: {} as never, + restoreWindowsOnActivateMainDeps: {} as never, + }, + ipcRuntimeDeps: { + mpvCommandMainDeps: {} as never, + handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never, + runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never, + registration: { + runtimeOptions: {} as never, + mainDeps: {} as never, + ankiJimakuDeps: {} as never, + registerIpcRuntimeServices: () => {}, + }, + }, + cliStartupDeps: { + cliCommandContextMainDeps: {} as never, + cliCommandRuntimeHandlerMainDeps: {} as never, + initialArgsRuntimeHandlerMainDeps: {} as never, + }, + headlessStartupDeps: { + startupRuntimeHandlersDeps: { + appLifecycleRuntimeRunnerMainDeps: { + app: { on: () => {} } as never, + platform: 'darwin', + shouldStartApp: () => true, + parseArgs: () => ({}) as never, + handleCliCommand: () => {}, + printHelp: () => {}, + logNoRunningInstance: () => {}, + onReady: async () => {}, + onWillQuitCleanup: () => {}, + shouldRestoreWindowsOnActivate: () => false, + restoreWindowsOnActivate: () => {}, + shouldQuitOnWindowAllClosed: () => false, + }, + createAppLifecycleRuntimeRunner: () => () => {}, + buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ + argv: ['node', 'main.js'], + parseArgs: () => ({ command: 'start' }) as never, + setLogLevel: () => {}, + forceX11Backend: () => {}, + enforceUnsupportedWaylandMode: () => {}, + shouldStartApp: () => true, + getDefaultSocketPath: () => '/tmp/mpv.sock', + defaultTexthookerPort: 5174, + configDir: '/tmp/config', + defaultConfig: {} as never, + generateConfigTemplate: () => 'template', + generateDefaultConfigFile: async () => 0, + setExitCode: () => {}, + quitApp: () => {}, + logGenerateConfigError: () => {}, + startAppLifecycle: (args) => startAppLifecycle(args as never), + }), + createStartupBootstrapRuntimeDeps: (deps) => ({ + startAppLifecycle: deps.startAppLifecycle, + }), + runStartupBootstrapRuntime: () => ({ mode: 'started' } as never), + applyStartupState: () => {}, + }, + }, + overlayWindowDeps: { + createOverlayWindowDeps: { + createOverlayWindowCore: (kind) => ({ kind }), + isDev: false, + ensureOverlayWindowLevel: () => {}, + onRuntimeOptionsChanged: () => {}, + setOverlayDebugVisualizationEnabled: () => {}, + isOverlayVisible: () => false, + tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => {}, + onWindowClosed: () => {}, + getYomitanSession: () => null, + }, + setMainWindow: () => {}, + setModalWindow: () => {}, + }, + }); + + assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function'); + assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function'); + assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function'); + assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function'); + assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function'); +}); diff --git a/src/main/boot/handlers.ts b/src/main/boot/handlers.ts new file mode 100644 index 0000000..135abbf --- /dev/null +++ b/src/main/boot/handlers.ts @@ -0,0 +1,40 @@ +import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer'; +import { + composeCliStartupHandlers, + composeHeadlessStartupHandlers, + composeIpcRuntimeHandlers, + composeStartupLifecycleHandlers, +} from '../runtime/composers'; + +export interface MainBootHandlersParams { + startupLifecycleDeps: Parameters[0]; + ipcRuntimeDeps: Parameters[0]; + cliStartupDeps: Parameters[0]; + headlessStartupDeps: Parameters< + typeof composeHeadlessStartupHandlers + >[0]; + overlayWindowDeps: Parameters>[0]; +} + +export function createMainBootHandlers< + TBrowserWindow, + TCliArgs, + TStartupState, + TBootstrapDeps, +>(params: MainBootHandlersParams) { + return { + startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps), + ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps), + cliStartup: composeCliStartupHandlers(params.cliStartupDeps), + headlessStartup: composeHeadlessStartupHandlers( + params.headlessStartupDeps, + ), + overlayWindow: composeOverlayWindowHandlers(params.overlayWindowDeps), + }; +} + +export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers; +export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers; +export const composeBootCliStartupHandlers = composeCliStartupHandlers; +export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers; +export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers; diff --git a/src/main/boot/runtimes.test.ts b/src/main/boot/runtimes.test.ts new file mode 100644 index 0000000..c66d2e8 --- /dev/null +++ b/src/main/boot/runtimes.test.ts @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMainBootRuntimes } from './runtimes'; + +test('createMainBootRuntimes returns grouped runtime bundles', () => { + const runtimes = createMainBootRuntimes({ + overlayVisibilityRuntimeDeps: { + overlayVisibilityRuntime: {} as never, + restorePreviousSecondarySubVisibilityMainDeps: {} as never, + broadcastRuntimeOptionsChangedMainDeps: {} as never, + sendToActiveOverlayWindowMainDeps: {} as never, + setOverlayDebugVisualizationEnabledMainDeps: {} as never, + openRuntimeOptionsPaletteMainDeps: {} as never, + }, + jellyfinRuntimeHandlerDeps: { + getResolvedJellyfinConfigMainDeps: {} as never, + getJellyfinClientInfoMainDeps: {} as never, + waitForMpvConnectedMainDeps: {} as never, + launchMpvIdleForJellyfinPlaybackMainDeps: {} as never, + ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never, + preloadJellyfinExternalSubtitlesMainDeps: {} as never, + playJellyfinItemInMpvMainDeps: {} as never, + remoteComposerOptions: {} as never, + handleJellyfinAuthCommandsMainDeps: {} as never, + handleJellyfinListCommandsMainDeps: {} as never, + handleJellyfinPlayCommandMainDeps: {} as never, + handleJellyfinRemoteAnnounceCommandMainDeps: {} as never, + startJellyfinRemoteSessionMainDeps: {} as never, + stopJellyfinRemoteSessionMainDeps: {} as never, + runJellyfinCommandMainDeps: {} as never, + maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never, + openJellyfinSetupWindowMainDeps: {} as never, + }, + anilistSetupDeps: { + notifyDeps: {} as never, + consumeTokenDeps: {} as never, + handleProtocolDeps: {} as never, + registerProtocolClientDeps: {} as never, + }, + buildOpenAnilistSetupWindowMainDeps: { + maybeFocusExistingSetupWindow: () => false, + createSetupWindow: () => null as never, + buildAuthorizeUrl: () => 'https://example.test', + consumeCallbackUrl: () => false, + openSetupInBrowser: () => {}, + loadManualTokenEntry: () => Promise.resolve(), + redirectUri: 'https://example.test/callback', + developerSettingsUrl: 'https://example.test/dev', + isAllowedExternalUrl: () => true, + isAllowedNavigationUrl: () => true, + logWarn: () => {}, + logError: () => {}, + clearSetupWindow: () => {}, + setSetupPageOpened: () => {}, + setSetupWindow: () => {}, + openExternal: () => {}, + }, + anilistTrackingDeps: { + refreshClientSecretMainDeps: {} as never, + getCurrentMediaKeyMainDeps: {} as never, + resetMediaTrackingMainDeps: {} as never, + getMediaGuessRuntimeStateMainDeps: {} as never, + setMediaGuessRuntimeStateMainDeps: {} as never, + resetMediaGuessStateMainDeps: {} as never, + maybeProbeDurationMainDeps: {} as never, + ensureMediaGuessMainDeps: {} as never, + processNextRetryUpdateMainDeps: {} as never, + maybeRunPostWatchUpdateMainDeps: {} as never, + }, + statsStartupRuntimeDeps: { + ensureStatsServerStarted: () => '', + ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }), + stopBackgroundStatsServer: async () => ({ ok: true, stale: false }), + ensureImmersionTrackerStarted: () => {}, + }, + runStatsCliCommandDeps: { + getResolvedConfig: () => ({}) as never, + ensureImmersionTrackerStarted: () => {}, + ensureVocabularyCleanupTokenizerReady: async () => {}, + getImmersionTracker: () => null, + ensureStatsServerStarted: () => '', + ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }), + stopBackgroundStatsServer: async () => ({ ok: true, stale: false }), + openExternal: () => Promise.resolve(), + writeResponse: () => {}, + exitAppWithCode: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + }, + appReadyRuntimeDeps: { + reloadConfigMainDeps: { + reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), + logInfo: () => {}, + logWarning: () => {}, + showDesktopNotification: () => {}, + startConfigHotReload: () => {}, + refreshAnilistClientSecretState: async () => {}, + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + criticalConfigErrorMainDeps: { + getConfigPath: () => '/tmp/config.jsonc', + failHandlers: { + logError: () => {}, + showErrorBox: () => {}, + quit: () => {}, + }, + }, + appReadyRuntimeMainDeps: { + ensureDefaultConfigBootstrap: () => {}, + loadSubtitlePosition: () => {}, + resolveKeybindings: () => {}, + createMpvClient: () => {}, + getResolvedConfig: () => ({}) as never, + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => {}, + initRuntimeOptionsManager: () => {}, + setSecondarySubMode: () => {}, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 5174, + defaultAnnotationWebsocketPort: 6678, + defaultTexthookerPort: 5174, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, + log: () => {}, + createMecabTokenizerAndCheck: async () => {}, + createSubtitleTimingTracker: () => {}, + loadYomitanExtension: async () => {}, + handleFirstRunSetup: async () => {}, + startJellyfinRemoteSession: async () => {}, + prewarmSubtitleDictionaries: async () => {}, + startBackgroundWarmups: () => {}, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => {}, + 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: () => {}, + }, + }, + mpvRuntimeDeps: { + bindMpvMainEventHandlersMainDeps: { + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: null, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + refreshDiscordPresence: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: class FakeMpvClient { + connected = true; + on(): void {} + connect(): void {} + } as never, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ auto_start_overlay: false }), + isAutoStartOverlayEnabled: () => true, + setOverlayVisible: () => {}, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => ({ + subPos: 100, + subFontSize: 36, + subScale: 1, + subMarginY: 0, + subMarginX: 0, + subFont: '', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 0, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 0, + osdDimensions: null, + }), + setCurrentMetrics: () => {}, + applyPatch: (current: any) => ({ next: current, changed: false }), + broadcastMetrics: () => {}, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + recordLookup: () => {}, + getKnownWordMatchMode: () => 'headword', + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => null, + getJlptEnabled: () => false, + getFrequencyDictionaryEnabled: () => false, + getFrequencyDictionaryMatchMode: () => 'headword', + getFrequencyRank: () => null, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => null, + }, + createTokenizerRuntimeDeps: () => ({}), + tokenizeSubtitle: async () => ({ text: '' }), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => null, + setMecabTokenizer: () => {}, + createMecabTokenizer: () => ({}) as never, + checkAvailability: async () => {}, + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: async () => {}, + ensureFrequencyDictionaryLookup: async () => {}, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => 0, + logDebug: () => {}, + logWarn: () => {}, + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => false, + setStarted: () => {}, + isTexthookerOnlyMode: () => false, + ensureYomitanExtensionLoaded: async () => {}, + shouldWarmupMecab: () => false, + shouldWarmupYomitanExtension: () => false, + shouldWarmupSubtitleDictionaries: () => false, + shouldWarmupJellyfinRemoteSession: () => false, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => {}, + }, + }, + }, + trayRuntimeDeps: { + resolveTrayIconPathDeps: {} as never, + buildTrayMenuTemplateDeps: {} as never, + ensureTrayDeps: {} as never, + destroyTrayDeps: {} as never, + buildMenuFromTemplate: () => ({}) as never, + }, + yomitanProfilePolicyDeps: { + externalProfilePath: '', + logInfo: () => {}, + }, + yomitanExtensionRuntimeDeps: { + loadYomitanExtensionCore: async () => null, + userDataPath: '/tmp', + externalProfilePath: '', + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: () => {}, + setYomitanSession: () => {}, + getYomitanExtension: () => null, + getLoadInFlight: () => null, + setLoadInFlight: () => {}, + }, + yomitanSettingsRuntimeDeps: { + ensureYomitanExtensionLoaded: async () => {}, + getYomitanSession: () => null, + openYomitanSettingsWindow: () => {}, + getExistingWindow: () => null, + setWindow: () => {}, + logWarn: () => {}, + logError: () => {}, + }, + createOverlayRuntimeBootstrapHandlers: () => ({ + initializeOverlayRuntime: () => {}, + }), + initializeOverlayRuntimeMainDeps: {}, + initializeOverlayRuntimeBootstrapDeps: {}, + }); + + assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function'); + assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function'); + assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function'); + assert.equal(typeof runtimes.openAnilistSetupWindow, 'function'); + assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function'); + assert.equal(typeof runtimes.runStatsCliCommand, 'function'); + assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function'); + assert.equal(typeof runtimes.initializeOverlayRuntime, 'function'); +}); diff --git a/src/main/boot/runtimes.ts b/src/main/boot/runtimes.ts new file mode 100644 index 0000000..77e35f0 --- /dev/null +++ b/src/main/boot/runtimes.ts @@ -0,0 +1,127 @@ +import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window'; +import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command'; +import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy'; +import { + createBuildOpenAnilistSetupWindowMainDepsHandler, + createMaybeFocusExistingAnilistSetupWindowHandler, + createOpenAnilistSetupWindowHandler, +} from '../runtime/domains/anilist'; +import { + createTrayRuntimeHandlers, + createYomitanExtensionRuntime, + createYomitanSettingsRuntime, +} from '../runtime/domains/overlay'; +import { + composeAnilistSetupHandlers, + composeAnilistTrackingHandlers, + composeAppReadyRuntime, + composeJellyfinRuntimeHandlers, + composeMpvRuntimeHandlers, + composeOverlayVisibilityRuntime, + composeStatsStartupRuntime, +} from '../runtime/composers'; + +export interface MainBootRuntimesParams { + overlayVisibilityRuntimeDeps: Parameters[0]; + jellyfinRuntimeHandlerDeps: Parameters[0]; + anilistSetupDeps: Parameters[0]; + buildOpenAnilistSetupWindowMainDeps: Parameters< + typeof createBuildOpenAnilistSetupWindowMainDepsHandler + >[0]; + anilistTrackingDeps: Parameters[0]; + statsStartupRuntimeDeps: Parameters[0]; + runStatsCliCommandDeps: Parameters[0]; + appReadyRuntimeDeps: Parameters[0]; + mpvRuntimeDeps: any; + trayRuntimeDeps: Parameters[0]; + yomitanProfilePolicyDeps: Parameters[0]; + yomitanExtensionRuntimeDeps: Parameters[0]; + yomitanSettingsRuntimeDeps: Parameters[0]; + createOverlayRuntimeBootstrapHandlers: (params: { + initializeOverlayRuntimeMainDeps: unknown; + initializeOverlayRuntimeBootstrapDeps: unknown; + }) => { + initializeOverlayRuntime: () => void; + }; + initializeOverlayRuntimeMainDeps: unknown; + initializeOverlayRuntimeBootstrapDeps: unknown; +} + +export function createMainBootRuntimes< + TBrowserWindow, + TMpvClient, + TTokenizerDeps, + TSubtitleData, +>( + params: MainBootRuntimesParams, +) { + const overlayVisibilityComposer = composeOverlayVisibilityRuntime( + params.overlayVisibilityRuntimeDeps, + ); + const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers( + params.jellyfinRuntimeHandlerDeps, + ); + const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps); + const buildOpenAnilistSetupWindowMainDepsHandler = + createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps); + const maybeFocusExistingAnilistSetupWindow = + params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow; + const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps); + const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps); + const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps); + const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps); + const mpvRuntimeHandlers = composeMpvRuntimeHandlers( + params.mpvRuntimeDeps as any, + ); + const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps); + const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps); + const yomitanExtensionRuntime = createYomitanExtensionRuntime( + params.yomitanExtensionRuntimeDeps, + ); + const yomitanSettingsRuntime = createYomitanSettingsRuntime( + params.yomitanSettingsRuntimeDeps, + ); + const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({ + initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps, + initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps, + }); + + return { + overlayVisibilityComposer, + jellyfinRuntimeHandlers, + anilistSetupHandlers, + maybeFocusExistingAnilistSetupWindow, + buildOpenAnilistSetupWindowMainDepsHandler, + openAnilistSetupWindow: () => + createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(), + anilistTrackingHandlers, + statsStartupRuntime, + runStatsCliCommand, + appReadyRuntime, + mpvRuntimeHandlers, + trayRuntimeHandlers, + yomitanProfilePolicy, + yomitanExtensionRuntime, + yomitanSettingsRuntime, + initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime, + openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler, + }; +} + +export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime; +export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers; +export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers; +export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers; +export const composeBootStatsStartupRuntime = composeStatsStartupRuntime; +export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler; +export const composeBootAppReadyRuntime = composeAppReadyRuntime; +export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers; +export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers; +export const createBootYomitanProfilePolicy = createYomitanProfilePolicy; +export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime; +export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime; +export const createBootMaybeFocusExistingAnilistSetupWindowHandler = + createMaybeFocusExistingAnilistSetupWindowHandler; +export const createBootBuildOpenAnilistSetupWindowMainDepsHandler = + createBuildOpenAnilistSetupWindowMainDepsHandler; +export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler; diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts new file mode 100644 index 0000000..aeb99d4 --- /dev/null +++ b/src/main/boot/services.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMainBootServices } from './services'; + +test('createMainBootServices builds boot-phase service bundle', () => { + const calls: string[] = []; + let setPathValue: string | null = null; + + const services = createMainBootServices({ + platform: 'linux', + argv: ['node', 'main.ts'], + appDataDir: undefined, + xdgConfigHome: undefined, + homeDir: '/home/tester', + defaultMpvLogFile: '/tmp/default.log', + envMpvLog: ' /tmp/custom.log ', + defaultTexthookerPort: 5174, + getDefaultSocketPath: () => '/tmp/subminer.sock', + resolveConfigDir: () => '/tmp/subminer-config', + existsSync: () => false, + mkdirSync: (targetPath) => { + calls.push(`mkdir:${targetPath}`); + }, + joinPath: (...parts) => parts.join('/'), + app: { + setPath: (_name, value) => { + setPathValue = value; + }, + quit: () => {}, + on: () => ({}), + whenReady: async () => {}, + }, + shouldBypassSingleInstanceLock: () => false, + requestSingleInstanceLockEarly: () => true, + registerSecondInstanceHandlerEarly: () => {}, + onConfigStartupParseError: () => { + throw new Error('unexpected parse failure'); + }, + createConfigService: (configDir) => ({ configDir }), + createAnilistTokenStore: (targetPath) => ({ targetPath }), + createJellyfinTokenStore: (targetPath) => ({ targetPath }), + createAnilistUpdateQueue: (targetPath) => ({ targetPath }), + createSubtitleWebSocket: () => ({ kind: 'ws' }), + createLogger: (scope) => + ({ + scope, + warn: () => {}, + info: () => {}, + error: () => {}, + }) as const, + createMainRuntimeRegistry: () => ({ registry: true }), + createOverlayManager: () => ({ + getModalWindow: () => null, + }), + createOverlayModalInputState: () => ({ inputState: true }), + createOverlayContentMeasurementStore: () => ({ measurementStore: true }), + getSyncOverlayShortcutsForModal: () => () => {}, + getSyncOverlayVisibilityForModal: () => () => {}, + createOverlayModalRuntime: () => ({ modalRuntime: true }), + createAppState: (input) => ({ ...input }), + }); + + assert.equal(services.configDir, '/tmp/subminer-config'); + assert.equal(services.userDataPath, '/tmp/subminer-config'); + assert.equal(services.defaultMpvLogPath, '/tmp/custom.log'); + assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite'); + assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' }); + assert.deepEqual(services.anilistTokenStore, { + targetPath: '/tmp/subminer-config/anilist-token-store.json', + }); + assert.deepEqual(services.jellyfinTokenStore, { + targetPath: '/tmp/subminer-config/jellyfin-token-store.json', + }); + assert.deepEqual(services.anilistUpdateQueue, { + targetPath: '/tmp/subminer-config/anilist-retry-queue.json', + }); + assert.deepEqual(services.appState, { + mpvSocketPath: '/tmp/subminer.sock', + texthookerPort: 5174, + }); + assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']); + assert.equal(setPathValue, '/tmp/subminer-config'); +}); diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts new file mode 100644 index 0000000..9024597 --- /dev/null +++ b/src/main/boot/services.ts @@ -0,0 +1,262 @@ +import { ConfigStartupParseError } from '../../config'; + +export interface MainBootServicesParams< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp, +> { + platform: NodeJS.Platform; + argv: string[]; + appDataDir: string | undefined; + xdgConfigHome: string | undefined; + homeDir: string; + defaultMpvLogFile: string; + envMpvLog: string | undefined; + defaultTexthookerPort: number; + getDefaultSocketPath: () => string; + resolveConfigDir: (input: { + platform: NodeJS.Platform; + appDataDir: string | undefined; + xdgConfigHome: string | undefined; + homeDir: string; + existsSync: (targetPath: string) => boolean; + }) => string; + existsSync: (targetPath: string) => boolean; + mkdirSync: (targetPath: string, options: { recursive: true }) => void; + joinPath: (...parts: string[]) => string; + app: { + setPath: (name: string, value: string) => void; + quit: () => void; + on: (...args: any[]) => unknown; + whenReady: () => Promise; + }; + shouldBypassSingleInstanceLock: () => boolean; + requestSingleInstanceLockEarly: () => boolean; + registerSecondInstanceHandlerEarly: ( + listener: (_event: unknown, argv: string[]) => void, + ) => void; + onConfigStartupParseError: (error: ConfigStartupParseError) => void; + createConfigService: (configDir: string) => TConfigService; + createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore; + createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore; + createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue; + createSubtitleWebSocket: () => TSubtitleWebSocket; + createLogger: (scope: string) => TLogger & { + warn: (message: string) => void; + info: (message: string) => void; + error: (message: string, details?: unknown) => void; + }; + createMainRuntimeRegistry: () => TRuntimeRegistry; + createOverlayManager: () => TOverlayManager; + createOverlayModalInputState: (params: any) => TOverlayModalInputState; + createOverlayContentMeasurementStore: (params: { + logger: TLogger; + }) => TOverlayContentMeasurementStore; + getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void; + getSyncOverlayVisibilityForModal: () => () => void; + createOverlayModalRuntime: (params: { + overlayManager: TOverlayManager; + overlayModalInputState: TOverlayModalInputState; + onModalStateChange: (isActive: boolean) => void; + }) => TOverlayModalRuntime; + createAppState: (input: { + mpvSocketPath: string; + texthookerPort: number; + }) => TAppState; +} + +export interface MainBootServicesResult< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp, +> { + configDir: string; + userDataPath: string; + defaultMpvLogPath: string; + defaultImmersionDbPath: string; + configService: TConfigService; + anilistTokenStore: TAnilistTokenStore; + jellyfinTokenStore: TJellyfinTokenStore; + anilistUpdateQueue: TAnilistUpdateQueue; + subtitleWsService: TSubtitleWebSocket; + annotationSubtitleWsService: TSubtitleWebSocket; + logger: TLogger; + runtimeRegistry: TRuntimeRegistry; + overlayManager: TOverlayManager; + overlayModalInputState: TOverlayModalInputState; + overlayContentMeasurementStore: TOverlayContentMeasurementStore; + overlayModalRuntime: TOverlayModalRuntime; + appState: TAppState; + appLifecycleApp: TAppLifecycleApp; +} + +export function createMainBootServices< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager extends { getModalWindow: () => unknown }, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp, +>( + params: MainBootServicesParams< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp + >, +): MainBootServicesResult< + TConfigService, + TAnilistTokenStore, + TJellyfinTokenStore, + TAnilistUpdateQueue, + TSubtitleWebSocket, + TLogger, + TRuntimeRegistry, + TOverlayManager, + TOverlayModalInputState, + TOverlayContentMeasurementStore, + TOverlayModalRuntime, + TAppState, + TAppLifecycleApp +> { + const configDir = params.resolveConfigDir({ + platform: params.platform, + appDataDir: params.appDataDir, + xdgConfigHome: params.xdgConfigHome, + homeDir: params.homeDir, + existsSync: params.existsSync, + }); + const userDataPath = configDir; + const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile; + const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite'); + + const configService = (() => { + try { + return params.createConfigService(configDir); + } catch (error) { + if (error instanceof ConfigStartupParseError) { + params.onConfigStartupParseError(error); + } + throw error; + } + })(); + + const anilistTokenStore = params.createAnilistTokenStore( + params.joinPath(userDataPath, 'anilist-token-store.json'), + ); + const jellyfinTokenStore = params.createJellyfinTokenStore( + params.joinPath(userDataPath, 'jellyfin-token-store.json'), + ); + const anilistUpdateQueue = params.createAnilistUpdateQueue( + params.joinPath(userDataPath, 'anilist-retry-queue.json'), + ); + const subtitleWsService = params.createSubtitleWebSocket(); + const annotationSubtitleWsService = params.createSubtitleWebSocket(); + const logger = params.createLogger('main'); + const runtimeRegistry = params.createMainRuntimeRegistry(); + const overlayManager = params.createOverlayManager(); + const overlayModalInputState = params.createOverlayModalInputState({ + getModalWindow: () => overlayManager.getModalWindow(), + syncOverlayShortcutsForModal: (isActive: boolean) => { + params.getSyncOverlayShortcutsForModal()(isActive); + }, + syncOverlayVisibilityForModal: () => { + params.getSyncOverlayVisibilityForModal()(); + }, + }); + const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({ + logger, + }); + const overlayModalRuntime = params.createOverlayModalRuntime({ + overlayManager, + overlayModalInputState, + onModalStateChange: (isActive: boolean) => + (overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void }) + .handleModalInputStateChange?.(isActive), + }); + const appState = params.createAppState({ + mpvSocketPath: params.getDefaultSocketPath(), + texthookerPort: params.defaultTexthookerPort, + }); + + if (!params.existsSync(userDataPath)) { + params.mkdirSync(userDataPath, { recursive: true }); + } + params.app.setPath('userData', userDataPath); + + const appLifecycleApp = { + requestSingleInstanceLock: () => + params.shouldBypassSingleInstanceLock() + ? true + : params.requestSingleInstanceLockEarly(), + quit: () => params.app.quit(), + on: (event: string, listener: (...args: unknown[]) => void) => { + if (event === 'second-instance') { + params.registerSecondInstanceHandlerEarly( + listener as (_event: unknown, argv: string[]) => void, + ); + return params.app; + } + params.app.on(event, listener); + return params.app; + }, + whenReady: () => params.app.whenReady(), + } as TAppLifecycleApp; + + return { + configDir, + userDataPath, + defaultMpvLogPath, + defaultImmersionDbPath, + configService, + anilistTokenStore, + jellyfinTokenStore, + anilistUpdateQueue, + subtitleWsService, + annotationSubtitleWsService, + logger, + runtimeRegistry, + overlayManager, + overlayModalInputState, + overlayContentMeasurementStore, + overlayModalRuntime, + appState, + appLifecycleApp, + }; +}