From 2878a1f3d1fd8d47b8c8fb65a5e38472a67c3c29 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 9 Feb 2026 23:56:26 -0800 Subject: [PATCH] refactor: extract app-ready startup orchestration service --- package.json | 2 +- .../app-ready-runtime-service.test.ts | 57 +++++++ .../services/app-ready-runtime-service.ts | 77 ++++++++++ src/main.ts | 143 +++++++++--------- 4 files changed, 209 insertions(+), 70 deletions(-) create mode 100644 src/core/services/app-ready-runtime-service.test.ts create mode 100644 src/core/services/app-ready-runtime-service.ts diff --git a/package.json b/package.json index 0ca135f..8138fcb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-visibility-facade-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/app-ready-runtime-service.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/services/app-ready-runtime-service.test.ts b/src/core/services/app-ready-runtime-service.test.ts new file mode 100644 index 0000000..7c1190d --- /dev/null +++ b/src/core/services/app-ready-runtime-service.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AppReadyRuntimeDeps, runAppReadyRuntimeService } from "./app-ready-runtime-service"; + +function makeDeps(overrides: Partial = {}) { + const calls: string[] = []; + const deps: AppReadyRuntimeDeps = { + loadSubtitlePosition: () => calls.push("loadSubtitlePosition"), + resolveKeybindings: () => calls.push("resolveKeybindings"), + createMpvClient: () => calls.push("createMpvClient"), + reloadConfig: () => calls.push("reloadConfig"), + getResolvedConfig: () => ({ websocket: { enabled: "auto" }, secondarySub: {} }), + getConfigWarnings: () => [], + logConfigWarning: () => calls.push("logConfigWarning"), + initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), + setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`), + defaultSecondarySubMode: "hover", + defaultWebsocketPort: 9001, + hasMpvWebsocketPlugin: () => true, + startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`), + log: (message) => calls.push(`log:${message}`), + createMecabTokenizerAndCheck: async () => { + calls.push("createMecabTokenizerAndCheck"); + }, + createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), + loadYomitanExtension: async () => { + calls.push("loadYomitanExtension"); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + initializeOverlayRuntime: () => calls.push("initializeOverlayRuntime"), + handleInitialArgs: () => calls.push("handleInitialArgs"), + ...overrides, + }; + return { deps, calls }; +} + +test("runAppReadyRuntimeService starts websocket in auto mode when plugin missing", async () => { + const { deps, calls } = makeDeps({ + hasMpvWebsocketPlugin: () => false, + }); + await runAppReadyRuntimeService(deps); + assert.ok(calls.includes("startSubtitleWebsocket:9001")); + assert.ok(calls.includes("initializeOverlayRuntime")); +}); + +test("runAppReadyRuntimeService logs defer message when overlay not auto-started", async () => { + const { deps, calls } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + }); + await runAppReadyRuntimeService(deps); + assert.ok( + calls.includes( + "log:Overlay runtime deferred: waiting for explicit overlay command.", + ), + ); +}); diff --git a/src/core/services/app-ready-runtime-service.ts b/src/core/services/app-ready-runtime-service.ts new file mode 100644 index 0000000..99372cc --- /dev/null +++ b/src/core/services/app-ready-runtime-service.ts @@ -0,0 +1,77 @@ +import { ConfigValidationWarning, SecondarySubMode } from "../../types"; + +interface AppReadyConfigLike { + secondarySub?: { + defaultMode?: SecondarySubMode; + }; + websocket?: { + enabled?: boolean | "auto"; + port?: number; + }; +} + +export interface AppReadyRuntimeDeps { + loadSubtitlePosition: () => void; + resolveKeybindings: () => void; + createMpvClient: () => void; + reloadConfig: () => void; + getResolvedConfig: () => AppReadyConfigLike; + getConfigWarnings: () => ConfigValidationWarning[]; + logConfigWarning: (warning: ConfigValidationWarning) => void; + initRuntimeOptionsManager: () => void; + setSecondarySubMode: (mode: SecondarySubMode) => void; + defaultSecondarySubMode: SecondarySubMode; + defaultWebsocketPort: number; + hasMpvWebsocketPlugin: () => boolean; + startSubtitleWebsocket: (port: number) => void; + log: (message: string) => void; + createMecabTokenizerAndCheck: () => Promise; + createSubtitleTimingTracker: () => void; + loadYomitanExtension: () => Promise; + texthookerOnlyMode: boolean; + shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + initializeOverlayRuntime: () => void; + handleInitialArgs: () => void; +} + +export async function runAppReadyRuntimeService( + deps: AppReadyRuntimeDeps, +): Promise { + deps.loadSubtitlePosition(); + deps.resolveKeybindings(); + deps.createMpvClient(); + + deps.reloadConfig(); + const config = deps.getResolvedConfig(); + for (const warning of deps.getConfigWarnings()) { + deps.logConfigWarning(warning); + } + deps.initRuntimeOptionsManager(); + deps.setSecondarySubMode( + config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode, + ); + + const wsConfig = config.websocket || {}; + const wsEnabled = wsConfig.enabled ?? "auto"; + const wsPort = wsConfig.port || deps.defaultWebsocketPort; + + if (wsEnabled === true || (wsEnabled === "auto" && !deps.hasMpvWebsocketPlugin())) { + deps.startSubtitleWebsocket(wsPort); + } else if (wsEnabled === "auto") { + deps.log("mpv_websocket detected, skipping built-in WebSocket server"); + } + + await deps.createMecabTokenizerAndCheck(); + deps.createSubtitleTimingTracker(); + await deps.loadYomitanExtension(); + + if (deps.texthookerOnlyMode) { + deps.log("Texthooker-only mode enabled; skipping overlay window."); + } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { + deps.initializeOverlayRuntime(); + } else { + deps.log("Overlay runtime deferred: waiting for explicit overlay command."); + } + + deps.handleInitialArgs(); +} diff --git a/src/main.ts b/src/main.ts index bc5ecef..008a013 100644 --- a/src/main.ts +++ b/src/main.ts @@ -201,6 +201,7 @@ import { getOverlayWindowsRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService, } from "./core/services/overlay-broadcast-runtime-service"; +import { runAppReadyRuntimeService } from "./core/services/app-ready-runtime-service"; import { runSubsyncManualFromIpcRuntimeService, triggerSubsyncFromConfigRuntimeService, @@ -487,76 +488,80 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) { }, isDarwinPlatform: () => process.platform === "darwin", onReady: async () => { - loadSubtitlePosition(); - keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - - mpvClient = new MpvIpcClient(mpvSocketPath, { - 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, ...args) => { broadcastToOverlayWindows(channel, ...args); }, - updateCurrentMediaPath: (mediaPath) => { updateCurrentMediaPath(mediaPath); }, updateMpvSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch); }, - getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics, setPreviousSecondarySubVisibility: (value) => { previousSecondarySubVisibility = value; }, showMpvOsd: (text) => { showMpvOsd(text); }, - }); - - configService.reloadConfig(); - const config = getResolvedConfig(); - for (const warning of configService.getWarnings()) { - console.warn( - `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, - ); - } - runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (ankiIntegration) { - ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - onOptionsChanged: () => { - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, + await runAppReadyRuntimeService({ + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); }, - ); - secondarySubMode = config.secondarySub?.defaultMode ?? "hover"; - const wsConfig = config.websocket || {}; - const wsEnabled = wsConfig.enabled ?? "auto"; - const wsPort = wsConfig.port || DEFAULT_CONFIG.websocket.port; - - if ( - wsEnabled === true || - (wsEnabled === "auto" && !hasMpvWebsocketPlugin()) - ) { - subtitleWsService.start(wsPort, () => currentSubText); - } else if (wsEnabled === "auto") { - console.log( - "mpv_websocket detected, skipping built-in WebSocket server", - ); - } - - mecabTokenizer = new MecabTokenizer(); - await mecabTokenizer.checkAvailability(); - - subtitleTimingTracker = new SubtitleTimingTracker(); - - await loadYomitanExtension(); - if (texthookerOnlyMode) { - console.log("Texthooker-only mode enabled; skipping overlay window."); - } else if (shouldAutoInitializeOverlayRuntimeFromConfig()) { - initializeOverlayRuntime(); - } else { - console.log( - "Overlay runtime deferred: waiting for explicit overlay command.", - ); - } - handleInitialArgs(); + createMpvClient: () => { + mpvClient = new MpvIpcClient(mpvSocketPath, { + 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, ...args) => { broadcastToOverlayWindows(channel, ...args); }, + 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) => { + console.warn( + `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`, + ); + }, + initRuntimeOptionsManager: () => { + runtimeOptionsManager = new RuntimeOptionsManager( + () => 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) => { + console.log(message); + }, + createMecabTokenizerAndCheck: async () => { + mecabTokenizer = new MecabTokenizer(); + await mecabTokenizer.checkAvailability(); + }, + createSubtitleTimingTracker: () => { + subtitleTimingTracker = new SubtitleTimingTracker(); + }, + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + shouldAutoInitializeOverlayRuntimeFromConfig(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + handleInitialArgs: () => handleInitialArgs(), + }); }, onWillQuitCleanup: () => { globalShortcut.unregisterAll();