From 3f4f9728430f355bdf8911003ec3cca38080d9fc Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Feb 2026 01:40:57 -0800 Subject: [PATCH] refactor: extract startup bootstrap runtime orchestration --- .../startup-bootstrap-runtime-service.test.ts | 105 +++++ .../startup-bootstrap-runtime-service.ts | 53 +++ src/main.ts | 426 +++++++++--------- 3 files changed, 376 insertions(+), 208 deletions(-) create mode 100644 src/core/services/startup-bootstrap-runtime-service.test.ts create mode 100644 src/core/services/startup-bootstrap-runtime-service.ts diff --git a/src/core/services/startup-bootstrap-runtime-service.test.ts b/src/core/services/startup-bootstrap-runtime-service.test.ts new file mode 100644 index 0000000..97b457b --- /dev/null +++ b/src/core/services/startup-bootstrap-runtime-service.test.ts @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + runStartupBootstrapRuntimeService, +} from "./startup-bootstrap-runtime-service"; +import { CliArgs } from "../../cli/args"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + toggleInvisibleOverlay: false, + settings: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + showInvisibleOverlay: false, + hideInvisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + openRuntimeOptions: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + verbose: false, + ...overrides, + }; +} + +test("runStartupBootstrapRuntimeService configures startup state and starts lifecycle", () => { + const calls: string[] = []; + const args = makeArgs({ + verbose: true, + socketPath: "/tmp/custom.sock", + texthookerPort: 9001, + backend: "x11", + autoStartOverlay: true, + texthooker: true, + }); + + const result = runStartupBootstrapRuntimeService({ + argv: ["node", "main.ts", "--verbose"], + parseArgs: () => args, + setLogLevelEnv: (level) => calls.push(`setLog:${level}`), + enableVerboseLogging: () => calls.push("enableVerbose"), + forceX11Backend: () => calls.push("forceX11"), + enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"), + getDefaultSocketPath: () => "/tmp/default.sock", + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => false, + startAppLifecycle: () => calls.push("startLifecycle"), + }); + + assert.equal(result.initialArgs, args); + assert.equal(result.mpvSocketPath, "/tmp/custom.sock"); + assert.equal(result.texthookerPort, 9001); + assert.equal(result.backendOverride, "x11"); + assert.equal(result.autoStartOverlay, true); + assert.equal(result.texthookerOnlyMode, true); + assert.deepEqual(calls, [ + "enableVerbose", + "forceX11", + "enforceWayland", + "startLifecycle", + ]); +}); + +test("runStartupBootstrapRuntimeService skips lifecycle when generate-config flow handled", () => { + const calls: string[] = []; + const args = makeArgs({ generateConfig: true, logLevel: "warn" }); + + const result = runStartupBootstrapRuntimeService({ + argv: ["node", "main.ts", "--generate-config"], + parseArgs: () => args, + setLogLevelEnv: (level) => calls.push(`setLog:${level}`), + enableVerboseLogging: () => calls.push("enableVerbose"), + forceX11Backend: () => calls.push("forceX11"), + enforceUnsupportedWaylandMode: () => calls.push("enforceWayland"), + getDefaultSocketPath: () => "/tmp/default.sock", + defaultTexthookerPort: 5174, + runGenerateConfigFlow: () => true, + startAppLifecycle: () => calls.push("startLifecycle"), + }); + + assert.equal(result.mpvSocketPath, "/tmp/default.sock"); + assert.equal(result.texthookerPort, 5174); + assert.equal(result.backendOverride, null); + assert.deepEqual(calls, [ + "setLog:warn", + "forceX11", + "enforceWayland", + ]); +}); diff --git a/src/core/services/startup-bootstrap-runtime-service.ts b/src/core/services/startup-bootstrap-runtime-service.ts new file mode 100644 index 0000000..6ea1f0d --- /dev/null +++ b/src/core/services/startup-bootstrap-runtime-service.ts @@ -0,0 +1,53 @@ +import { CliArgs } from "../../cli/args"; + +export interface StartupBootstrapRuntimeState { + initialArgs: CliArgs; + mpvSocketPath: string; + texthookerPort: number; + backendOverride: string | null; + autoStartOverlay: boolean; + texthookerOnlyMode: boolean; +} + +export interface StartupBootstrapRuntimeDeps { + argv: string[]; + parseArgs: (argv: string[]) => CliArgs; + setLogLevelEnv: (level: string) => void; + enableVerboseLogging: () => void; + forceX11Backend: (args: CliArgs) => void; + enforceUnsupportedWaylandMode: (args: CliArgs) => void; + getDefaultSocketPath: () => string; + defaultTexthookerPort: number; + runGenerateConfigFlow: (args: CliArgs) => boolean; + startAppLifecycle: (args: CliArgs) => void; +} + +export function runStartupBootstrapRuntimeService( + deps: StartupBootstrapRuntimeDeps, +): StartupBootstrapRuntimeState { + const initialArgs = deps.parseArgs(deps.argv); + + if (initialArgs.logLevel) { + deps.setLogLevelEnv(initialArgs.logLevel); + } else if (initialArgs.verbose) { + deps.enableVerboseLogging(); + } + + deps.forceX11Backend(initialArgs); + deps.enforceUnsupportedWaylandMode(initialArgs); + + const state: StartupBootstrapRuntimeState = { + initialArgs, + mpvSocketPath: initialArgs.socketPath ?? deps.getDefaultSocketPath(), + texthookerPort: initialArgs.texthookerPort ?? deps.defaultTexthookerPort, + backendOverride: initialArgs.backend ?? null, + autoStartOverlay: initialArgs.autoStartOverlay, + texthookerOnlyMode: initialArgs.texthooker, + }; + + if (!deps.runGenerateConfigFlow(initialArgs)) { + deps.startAppLifecycle(initialArgs); + } + + return state; +} diff --git a/src/main.ts b/src/main.ts index edbd95b..3300a58 100644 --- a/src/main.ts +++ b/src/main.ts @@ -236,6 +236,7 @@ import { createSubtitleTimingTrackerRuntimeService, } from "./core/services/startup-resource-runtime-service"; import { runGenerateConfigFlowRuntimeService } from "./core/services/config-generation-runtime-service"; +import { runStartupBootstrapRuntimeService } from "./core/services/startup-bootstrap-runtime-service"; import { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService, @@ -479,218 +480,227 @@ function updateCurrentMediaPath(mediaPath: unknown): void { let subsyncInProgress = false; -const initialArgs = parseArgs(process.argv); -if (initialArgs.logLevel) { - process.env.SUBMINER_LOG_LEVEL = initialArgs.logLevel; -} else if (initialArgs.verbose) { - process.env.SUBMINER_LOG_LEVEL = "debug"; -} - -forceX11Backend(initialArgs); -enforceUnsupportedWaylandMode(initialArgs); - -let mpvSocketPath = initialArgs.socketPath ?? getDefaultSocketPath(); -let texthookerPort = initialArgs.texthookerPort ?? DEFAULT_TEXTHOOKER_PORT; -const backendOverride = initialArgs.backend ?? null; -const autoStartOverlay = initialArgs.autoStartOverlay; -const texthookerOnlyMode = initialArgs.texthooker; - -if ( - !runGenerateConfigFlowRuntimeService(initialArgs, { - shouldStartApp: (args) => shouldStartApp(args), - generateConfig: async (args) => - generateDefaultConfigFile(args, { - configDir: CONFIG_DIR, - defaultConfig: DEFAULT_CONFIG, - generateTemplate: (config) => generateConfigTemplate(config as never), - }), - onSuccess: (exitCode) => { - process.exitCode = exitCode; - app.quit(); - }, - onError: (error) => { - console.error(`Failed to generate config: ${error.message}`); - process.exitCode = 1; - app.quit(); - }, - }) -) { - startAppLifecycleService(initialArgs, createAppLifecycleDepsRuntimeService({ - app, - platform: process.platform, - shouldStartApp: (args) => shouldStartApp(args), - parseArgs: (argv) => parseArgs(argv), - handleCliCommand: (args, source) => handleCliCommand(args, source), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - logNoRunningInstance: () => appLogger.logNoRunningInstance(), - onReady: async () => { - await runAppReadyRuntimeService( - createStartupAppReadyDepsRuntimeService({ - loadSubtitlePosition: () => loadSubtitlePosition(), - resolveKeybindings: () => { - keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - }, - createMpvClient: () => { - mpvClient = new MpvIpcClient( - mpvSocketPath, - createMpvIpcClientDepsRuntimeService({ - getResolvedConfig: () => getResolvedConfig(), - autoStartOverlay, - setOverlayVisible: (visible) => setOverlayVisible(visible), - shouldBindVisibleOverlayToMpvSubVisibility: () => - shouldBindVisibleOverlayToMpvSubVisibility(), - isVisibleOverlayVisible: () => visibleOverlayVisible, - getReconnectTimer: () => reconnectTimer, - setReconnectTimer: (timer) => { - reconnectTimer = timer; +const startupState = runStartupBootstrapRuntimeService({ + argv: process.argv, + parseArgs: (argv) => parseArgs(argv), + setLogLevelEnv: (level) => { + process.env.SUBMINER_LOG_LEVEL = level; + }, + enableVerboseLogging: () => { + process.env.SUBMINER_LOG_LEVEL = "debug"; + }, + forceX11Backend: (args) => { + forceX11Backend(args); + }, + enforceUnsupportedWaylandMode: (args) => { + enforceUnsupportedWaylandMode(args); + }, + getDefaultSocketPath: () => getDefaultSocketPath(), + defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, + runGenerateConfigFlow: (args) => + runGenerateConfigFlowRuntimeService(args, { + shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), + generateConfig: async (nextArgs) => + generateDefaultConfigFile(nextArgs, { + configDir: CONFIG_DIR, + defaultConfig: DEFAULT_CONFIG, + generateTemplate: (config) => generateConfigTemplate(config as never), + }), + onSuccess: (exitCode) => { + process.exitCode = exitCode; + app.quit(); + }, + onError: (error) => { + console.error(`Failed to generate config: ${error.message}`); + process.exitCode = 1; + app.quit(); + }, + }), + startAppLifecycle: (args) => { + startAppLifecycleService(args, createAppLifecycleDepsRuntimeService({ + app, + platform: process.platform, + shouldStartApp: (nextArgs) => shouldStartApp(nextArgs), + parseArgs: (argv) => parseArgs(argv), + handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + logNoRunningInstance: () => appLogger.logNoRunningInstance(), + onReady: async () => { + await runAppReadyRuntimeService( + createStartupAppReadyDepsRuntimeService({ + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + }, + createMpvClient: () => { + mpvClient = new MpvIpcClient( + mpvSocketPath, + createMpvIpcClientDepsRuntimeService({ + getResolvedConfig: () => getResolvedConfig(), + autoStartOverlay, + setOverlayVisible: (visible) => setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => visibleOverlayVisible, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (timer) => { + reconnectTimer = timer; + }, + getCurrentSubText: () => currentSubText, + setCurrentSubText: (text) => { + currentSubText = text; + }, + setCurrentSubAssText: (text) => { + currentSubAssText = text; + }, + getSubtitleTimingTracker: () => subtitleTimingTracker, + subtitleWsBroadcast: (text) => { + subtitleWsService.broadcast(text); + }, + getOverlayWindowsCount: () => getOverlayWindows().length, + tokenizeSubtitle: (text) => tokenizeSubtitle(text), + broadcastToOverlayWindows: (channel, ...channelArgs) => { + broadcastToOverlayWindows(channel, ...channelArgs); + }, + updateCurrentMediaPath: (mediaPath) => { + updateCurrentMediaPath(mediaPath); + }, + updateMpvSubtitleRenderMetrics: (patch) => { + updateMpvSubtitleRenderMetrics(patch); + }, + getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics, + setPreviousSecondarySubVisibility: (value) => { + previousSecondarySubVisibility = value; + }, + showMpvOsd: (text) => { + showMpvOsd(text); + }, + }), + ); + }, + reloadConfig: () => { + configService.reloadConfig(); + }, + getResolvedConfig: () => getResolvedConfig(), + getConfigWarnings: () => configService.getWarnings(), + logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + initRuntimeOptionsManager: () => { + runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({ + getAnkiConfig: () => configService.getConfig().ankiConnect, + applyAnkiPatch: (patch) => { + if (ankiIntegration) { + ankiIntegration.applyRuntimeConfigPatch(patch); + } }, - getCurrentSubText: () => currentSubText, - setCurrentSubText: (text) => { - currentSubText = text; + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); }, - setCurrentSubAssText: (text) => { - currentSubAssText = text; - }, - getSubtitleTimingTracker: () => subtitleTimingTracker, - subtitleWsBroadcast: (text) => { - subtitleWsService.broadcast(text); - }, - getOverlayWindowsCount: () => getOverlayWindows().length, - tokenizeSubtitle: (text) => tokenizeSubtitle(text), - broadcastToOverlayWindows: (channel, ...args) => { - broadcastToOverlayWindows(channel, ...args); - }, - updateCurrentMediaPath: (mediaPath) => { - updateCurrentMediaPath(mediaPath); - }, - updateMpvSubtitleRenderMetrics: (patch) => { - updateMpvSubtitleRenderMetrics(patch); - }, - getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics, - setPreviousSecondarySubVisibility: (value) => { - previousSecondarySubVisibility = value; - }, - showMpvOsd: (text) => { - showMpvOsd(text); + }); + }, + setSecondarySubMode: (mode) => { + secondarySubMode = mode; + }, + defaultSecondarySubMode: "hover", + defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, + hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port) => { + subtitleWsService.start(port, () => currentSubText); + }, + log: (message) => appLogger.logInfo(message), + createMecabTokenizerAndCheck: async () => + createMecabTokenizerAndCheckRuntimeService({ + createMecabTokenizer: () => new MecabTokenizer(), + setMecabTokenizer: (tokenizer) => { + mecabTokenizer = tokenizer; }, }), - ); - }, - reloadConfig: () => { - configService.reloadConfig(); - }, - getResolvedConfig: () => getResolvedConfig(), - getConfigWarnings: () => configService.getWarnings(), - logConfigWarning: (warning) => appLogger.logConfigWarning(warning), - initRuntimeOptionsManager: () => { - runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({ - getAnkiConfig: () => configService.getConfig().ankiConnect, - applyAnkiPatch: (patch) => { - if (ankiIntegration) { - ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - onOptionsChanged: () => { - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }); - }, - setSecondarySubMode: (mode) => { - secondarySubMode = mode; - }, - defaultSecondarySubMode: "hover", - defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, - hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), - startSubtitleWebsocket: (port) => { - subtitleWsService.start(port, () => currentSubText); - }, - log: (message) => appLogger.logInfo(message), - createMecabTokenizerAndCheck: async () => - createMecabTokenizerAndCheckRuntimeService({ - createMecabTokenizer: () => new MecabTokenizer(), - setMecabTokenizer: (tokenizer) => { - mecabTokenizer = tokenizer; - }, - }), - createSubtitleTimingTracker: () => - createSubtitleTimingTrackerRuntimeService({ - createSubtitleTimingTracker: () => new SubtitleTimingTracker(), - setSubtitleTimingTracker: (tracker) => { - subtitleTimingTracker = tracker; - }, - }), - loadYomitanExtension: async () => { - await loadYomitanExtension(); - }, - texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: () => - shouldAutoInitializeOverlayRuntimeFromConfig(), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - handleInitialArgs: () => handleInitialArgs(), - }), - ); - }, - onWillQuitCleanup: () => { - runAppShutdownRuntimeService( - createStartupAppShutdownDepsRuntimeService({ - unregisterAllGlobalShortcuts: () => { - globalShortcut.unregisterAll(); - }, - stopSubtitleWebsocket: () => { - subtitleWsService.stop(); - }, - stopTexthookerService: () => { - texthookerService.stop(); - }, - destroyYomitanParserWindow: () => { - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - yomitanParserWindow.destroy(); - } - yomitanParserWindow = null; - }, - clearYomitanParserPromises: () => { - yomitanParserReadyPromise = null; - yomitanParserInitPromise = null; - }, - stopWindowTracker: () => { - if (windowTracker) { - windowTracker.stop(); - } - }, - destroyMpvSocket: () => { - if (mpvClient && mpvClient.socket) { - mpvClient.socket.destroy(); - } - }, - clearReconnectTimer: () => { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - }, - destroySubtitleTimingTracker: () => { - if (subtitleTimingTracker) { - subtitleTimingTracker.destroy(); - } - }, - destroyAnkiIntegration: () => { - if (ankiIntegration) { - ankiIntegration.destroy(); - } - }, - }), - ); - }, - shouldRestoreWindowsOnActivate: () => - overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, - restoreWindowsOnActivate: () => { - createMainWindow(); - createInvisibleWindow(); - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); - }, - })); -} + createSubtitleTimingTracker: () => + createSubtitleTimingTrackerRuntimeService({ + createSubtitleTimingTracker: () => new SubtitleTimingTracker(), + setSubtitleTimingTracker: (tracker) => { + subtitleTimingTracker = tracker; + }, + }), + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + shouldAutoInitializeOverlayRuntimeFromConfig(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + handleInitialArgs: () => handleInitialArgs(), + }), + ); + }, + onWillQuitCleanup: () => { + runAppShutdownRuntimeService( + createStartupAppShutdownDepsRuntimeService({ + unregisterAllGlobalShortcuts: () => { + globalShortcut.unregisterAll(); + }, + stopSubtitleWebsocket: () => { + subtitleWsService.stop(); + }, + stopTexthookerService: () => { + texthookerService.stop(); + }, + destroyYomitanParserWindow: () => { + if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { + yomitanParserWindow.destroy(); + } + yomitanParserWindow = null; + }, + clearYomitanParserPromises: () => { + yomitanParserReadyPromise = null; + yomitanParserInitPromise = null; + }, + stopWindowTracker: () => { + if (windowTracker) { + windowTracker.stop(); + } + }, + destroyMpvSocket: () => { + if (mpvClient && mpvClient.socket) { + mpvClient.socket.destroy(); + } + }, + clearReconnectTimer: () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + }, + destroySubtitleTimingTracker: () => { + if (subtitleTimingTracker) { + subtitleTimingTracker.destroy(); + } + }, + destroyAnkiIntegration: () => { + if (ankiIntegration) { + ankiIntegration.destroy(); + } + }, + }), + ); + }, + shouldRestoreWindowsOnActivate: () => + overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, + restoreWindowsOnActivate: () => { + createMainWindow(); + createInvisibleWindow(); + updateVisibleOverlayVisibility(); + updateInvisibleOverlayVisibility(); + }, + })); + }, +}); + +const initialArgs = startupState.initialArgs; +let mpvSocketPath = startupState.mpvSocketPath; +let texthookerPort = startupState.texthookerPort; +const backendOverride = startupState.backendOverride; +const autoStartOverlay = startupState.autoStartOverlay; +const texthookerOnlyMode = startupState.texthookerOnlyMode; function handleCliCommand( args: CliArgs,