refactor: extract app lifecycle orchestration service

This commit is contained in:
2026-02-09 22:46:07 -08:00
parent 8ab04c3fa6
commit d516238aba
2 changed files with 199 additions and 132 deletions

View File

@@ -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>) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
onActivate: (handler: () => void) => void;
isDarwinPlatform: () => boolean;
onReady: () => Promise<void>;
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();
}
});
}

View File

@@ -127,6 +127,7 @@ import {
triggerFieldGroupingService, triggerFieldGroupingService,
updateLastCardFromClipboardService, updateLastCardFromClipboardService,
} from "./core/services/mining-runtime-service"; } from "./core/services/mining-runtime-service";
import { startAppLifecycleService } from "./core/services/app-lifecycle-service";
import { showDesktopNotification } from "./core/utils/notification"; import { showDesktopNotification } from "./core/utils/notification";
import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service"; import { openYomitanSettingsWindow } from "./core/services/yomitan-settings-service";
import { tokenizeSubtitleService } from "./core/services/tokenizer-service"; import { tokenizeSubtitleService } from "./core/services/tokenizer-service";
@@ -420,145 +421,139 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) {
app.quit(); app.quit();
}); });
} else { } 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) { mpvClient = new MpvIpcClient(mpvSocketPath, {
app.quit(); getResolvedConfig: () => getResolvedConfig(), autoStartOverlay,
} else { setOverlayVisible: (visible) => setOverlayVisible(visible),
app.on("second-instance", (_event, argv) => { shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(),
handleCliCommand(parseArgs(argv), "second-instance"); isVisibleOverlayVisible: () => visibleOverlayVisible,
}); getReconnectTimer: () => reconnectTimer, setReconnectTimer: (timer) => { reconnectTimer = timer; },
if (initialArgs.help && !shouldStartApp(initialArgs)) { getCurrentSubText: () => currentSubText, setCurrentSubText: (text) => { currentSubText = text; }, setCurrentSubAssText: (text) => { currentSubAssText = text; },
printHelp(DEFAULT_TEXTHOOKER_PORT); getSubtitleTimingTracker: () => subtitleTimingTracker, subtitleWsBroadcast: (text) => { subtitleWsService.broadcast(text); },
app.quit(); getOverlayWindowsCount: () => getOverlayWindows().length, tokenizeSubtitle: (text) => tokenizeSubtitle(text),
} else if (!shouldStartApp(initialArgs)) { broadcastToOverlayWindows: (channel, ...args) => { broadcastToOverlayWindows(channel, ...args); },
if (initialArgs.stop && !initialArgs.start) { updateCurrentMediaPath: (mediaPath) => { updateCurrentMediaPath(mediaPath); }, updateMpvSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch); },
app.quit(); getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics, setPreviousSecondarySubVisibility: (value) => { previousSecondarySubVisibility = value; }, showMpvOsd: (text) => { showMpvOsd(text); },
} 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, { configService.reloadConfig();
getResolvedConfig: () => getResolvedConfig(), autoStartOverlay, const config = getResolvedConfig();
setOverlayVisible: (visible) => setOverlayVisible(visible), for (const warning of configService.getWarnings()) {
shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(), console.warn(
isVisibleOverlayVisible: () => visibleOverlayVisible, `[config] ${warning.path}: ${warning.message} value=${JSON.stringify(warning.value)} fallback=${JSON.stringify(warning.fallback)}`,
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();
},
},
); );
secondarySubMode = config.secondarySub?.defaultMode ?? "hover"; }
const wsConfig = config.websocket || {}; runtimeOptionsManager = new RuntimeOptionsManager(
const wsEnabled = wsConfig.enabled ?? "auto"; () => configService.getConfig().ankiConnect,
const wsPort = wsConfig.port || DEFAULT_CONFIG.websocket.port; {
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 ( if (
wsEnabled === true || wsEnabled === true ||
(wsEnabled === "auto" && !hasMpvWebsocketPlugin()) (wsEnabled === "auto" && !hasMpvWebsocketPlugin())
) { ) {
subtitleWsService.start(wsPort, () => currentSubText); subtitleWsService.start(wsPort, () => currentSubText);
} else if (wsEnabled === "auto") { } else if (wsEnabled === "auto") {
console.log( console.log(
"mpv_websocket detected, skipping built-in WebSocket server", "mpv_websocket detected, skipping built-in WebSocket server",
); );
} }
mecabTokenizer = new MecabTokenizer(); mecabTokenizer = new MecabTokenizer();
await mecabTokenizer.checkAvailability(); await mecabTokenizer.checkAvailability();
subtitleTimingTracker = new SubtitleTimingTracker(); subtitleTimingTracker = new SubtitleTimingTracker();
await loadYomitanExtension(); await loadYomitanExtension();
if (texthookerOnlyMode) { if (texthookerOnlyMode) {
console.log("Texthooker-only mode enabled; skipping overlay window."); console.log("Texthooker-only mode enabled; skipping overlay window.");
} else if (shouldAutoInitializeOverlayRuntimeFromConfig()) { } else if (shouldAutoInitializeOverlayRuntimeFromConfig()) {
initializeOverlayRuntime(); initializeOverlayRuntime();
} else { } else {
console.log( console.log(
"Overlay runtime deferred: waiting for explicit overlay command.", "Overlay runtime deferred: waiting for explicit overlay command.",
); );
} }
handleInitialArgs();
handleInitialArgs(); },
}); onWillQuitCleanup: () => {
globalShortcut.unregisterAll();
app.on("window-all-closed", () => { subtitleWsService.stop();
if (process.platform !== "darwin") { texthookerService.stop();
app.quit(); if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
} yomitanParserWindow.destroy();
}); }
yomitanParserWindow = null;
app.on("will-quit", () => { yomitanParserReadyPromise = null;
globalShortcut.unregisterAll(); yomitanParserInitPromise = null;
subtitleWsService.stop(); if (windowTracker) {
texthookerService.stop(); windowTracker.stop();
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { }
yomitanParserWindow.destroy(); if (mpvClient && mpvClient.socket) {
} mpvClient.socket.destroy();
yomitanParserWindow = null; }
yomitanParserReadyPromise = null; if (reconnectTimer) {
yomitanParserInitPromise = null; clearTimeout(reconnectTimer);
if (windowTracker) { }
windowTracker.stop(); if (subtitleTimingTracker) {
} subtitleTimingTracker.destroy();
if (mpvClient && mpvClient.socket) { }
mpvClient.socket.destroy(); if (ankiIntegration) {
} ankiIntegration.destroy();
if (reconnectTimer) { }
clearTimeout(reconnectTimer); },
} shouldRestoreWindowsOnActivate: () =>
if (subtitleTimingTracker) { overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
subtitleTimingTracker.destroy(); restoreWindowsOnActivate: () => {
} createMainWindow();
if (ankiIntegration) { createInvisibleWindow();
ankiIntegration.destroy(); updateVisibleOverlayVisibility();
} updateInvisibleOverlayVisibility();
}); },
});
app.on("activate", () => {
if (
overlayRuntimeInitialized &&
BrowserWindow.getAllWindows().length === 0
) {
createMainWindow();
createInvisibleWindow();
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
}
});
}
}
} }
function handleCliCommand( function handleCliCommand(