From d516238aba5694782ecd5bd82ccc69efb39d7e68 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 9 Feb 2026 22:46:07 -0800 Subject: [PATCH] refactor: extract app lifecycle orchestration service --- src/core/services/app-lifecycle-service.ts | 72 ++++++ src/main.ts | 259 ++++++++++----------- 2 files changed, 199 insertions(+), 132 deletions(-) create mode 100644 src/core/services/app-lifecycle-service.ts diff --git a/src/core/services/app-lifecycle-service.ts b/src/core/services/app-lifecycle-service.ts new file mode 100644 index 0000000..2b11a7b --- /dev/null +++ b/src/core/services/app-lifecycle-service.ts @@ -0,0 +1,72 @@ +import { CliArgs, CliCommandSource } from "../../cli/args"; + +export interface AppLifecycleServiceDeps { + shouldStartApp: (args: CliArgs) => boolean; + parseArgs: (argv: string[]) => CliArgs; + requestSingleInstanceLock: () => boolean; + quitApp: () => void; + onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void; + handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; + printHelp: () => void; + logNoRunningInstance: () => void; + whenReady: (handler: () => Promise) => void; + onWindowAllClosed: (handler: () => void) => void; + onWillQuit: (handler: () => void) => void; + onActivate: (handler: () => void) => void; + isDarwinPlatform: () => boolean; + onReady: () => Promise; + onWillQuitCleanup: () => void; + shouldRestoreWindowsOnActivate: () => boolean; + restoreWindowsOnActivate: () => void; +} + +export function startAppLifecycleService( + initialArgs: CliArgs, + deps: AppLifecycleServiceDeps, +): void { + const gotTheLock = deps.requestSingleInstanceLock(); + if (!gotTheLock) { + deps.quitApp(); + return; + } + + deps.onSecondInstance((_event, argv) => { + deps.handleCliCommand(deps.parseArgs(argv), "second-instance"); + }); + + if (initialArgs.help && !deps.shouldStartApp(initialArgs)) { + deps.printHelp(); + deps.quitApp(); + return; + } + + if (!deps.shouldStartApp(initialArgs)) { + if (initialArgs.stop && !initialArgs.start) { + deps.quitApp(); + } else { + deps.logNoRunningInstance(); + deps.quitApp(); + } + return; + } + + deps.whenReady(async () => { + await deps.onReady(); + }); + + deps.onWindowAllClosed(() => { + if (!deps.isDarwinPlatform()) { + deps.quitApp(); + } + }); + + deps.onWillQuit(() => { + deps.onWillQuitCleanup(); + }); + + deps.onActivate(() => { + if (deps.shouldRestoreWindowsOnActivate()) { + deps.restoreWindowsOnActivate(); + } + }); +} diff --git a/src/main.ts b/src/main.ts index dfb0c44..59725ad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -127,6 +127,7 @@ import { triggerFieldGroupingService, updateLastCardFromClipboardService, } from "./core/services/mining-runtime-service"; +import { startAppLifecycleService } from "./core/services/app-lifecycle-service"; import { showDesktopNotification } from "./core/utils/notification"; import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; import { tokenizeSubtitleService } from "./core/services/tokenizer-service"; @@ -420,145 +421,139 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) { app.quit(); }); } else { - const gotTheLock = app.requestSingleInstanceLock(); + startAppLifecycleService(initialArgs, { + shouldStartApp: (args) => shouldStartApp(args), + parseArgs: (argv) => parseArgs(argv), + requestSingleInstanceLock: () => app.requestSingleInstanceLock(), + quitApp: () => app.quit(), + onSecondInstance: (handler) => { + app.on("second-instance", handler); + }, + handleCliCommand: (args, source) => handleCliCommand(args, source), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + logNoRunningInstance: () => { + console.error("No running instance. Use --start to launch the app."); + }, + whenReady: (handler) => { + app.whenReady().then(handler); + }, + onWindowAllClosed: (handler) => { + app.on("window-all-closed", handler); + }, + onWillQuit: (handler) => { + app.on("will-quit", handler); + }, + onActivate: (handler) => { + app.on("activate", handler); + }, + isDarwinPlatform: () => process.platform === "darwin", + onReady: async () => { + loadSubtitlePosition(); + keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - if (!gotTheLock) { - app.quit(); - } else { - app.on("second-instance", (_event, argv) => { - handleCliCommand(parseArgs(argv), "second-instance"); - }); - if (initialArgs.help && !shouldStartApp(initialArgs)) { - printHelp(DEFAULT_TEXTHOOKER_PORT); - app.quit(); - } else if (!shouldStartApp(initialArgs)) { - if (initialArgs.stop && !initialArgs.start) { - app.quit(); - } else { - console.error("No running instance. Use --start to launch the app."); - app.quit(); - } - } else { - app.whenReady().then(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); }, + }); - 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(); - }, - }, + 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)}`, ); - secondarySubMode = config.secondarySub?.defaultMode ?? "hover"; - const wsConfig = config.websocket || {}; - const wsEnabled = wsConfig.enabled ?? "auto"; - const wsPort = wsConfig.port || DEFAULT_CONFIG.websocket.port; + } + runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (ankiIntegration) { + ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }, + ); + 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", - ); - } + 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(); + mecabTokenizer = new MecabTokenizer(); + await mecabTokenizer.checkAvailability(); - subtitleTimingTracker = new SubtitleTimingTracker(); + 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(); - }); - - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - app.on("will-quit", () => { - globalShortcut.unregisterAll(); - subtitleWsService.stop(); - texthookerService.stop(); - if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { - yomitanParserWindow.destroy(); - } - yomitanParserWindow = null; - yomitanParserReadyPromise = null; - yomitanParserInitPromise = null; - if (windowTracker) { - windowTracker.stop(); - } - if (mpvClient && mpvClient.socket) { - mpvClient.socket.destroy(); - } - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - if (subtitleTimingTracker) { - subtitleTimingTracker.destroy(); - } - if (ankiIntegration) { - ankiIntegration.destroy(); - } - }); - - app.on("activate", () => { - if ( - overlayRuntimeInitialized && - BrowserWindow.getAllWindows().length === 0 - ) { - createMainWindow(); - createInvisibleWindow(); - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); - } - }); - } - } + 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(); + }, + onWillQuitCleanup: () => { + globalShortcut.unregisterAll(); + subtitleWsService.stop(); + texthookerService.stop(); + if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { + yomitanParserWindow.destroy(); + } + yomitanParserWindow = null; + yomitanParserReadyPromise = null; + yomitanParserInitPromise = null; + if (windowTracker) { + windowTracker.stop(); + } + if (mpvClient && mpvClient.socket) { + mpvClient.socket.destroy(); + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (subtitleTimingTracker) { + subtitleTimingTracker.destroy(); + } + if (ankiIntegration) { + ankiIntegration.destroy(); + } + }, + shouldRestoreWindowsOnActivate: () => + overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, + restoreWindowsOnActivate: () => { + createMainWindow(); + createInvisibleWindow(); + updateVisibleOverlayVisibility(); + updateInvisibleOverlayVisibility(); + }, + }); } function handleCliCommand(