refactor: extract startup lifecycle hooks orchestration

This commit is contained in:
2026-02-10 01:44:20 -08:00
parent 31f76ad476
commit 2d3bb19458
4 changed files with 265 additions and 169 deletions

View File

@@ -16,7 +16,7 @@
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "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: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/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/anki-jimaku-ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/overlay-visibility-facade-deps-runtime-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-runtime-deps-service.test.js dist/core/services/startup-lifecycle-runtime-deps-service.test.js dist/core/services/overlay-shortcut-runtime-deps-service.test.js dist/core/services/mining-runtime-deps-service.test.js dist/core/services/shortcut-ui-runtime-deps-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 dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/mpv-client-deps-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-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/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/anki-jimaku-ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/subsync-deps-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/overlay-visibility-facade-deps-runtime-service.test.js dist/core/services/mpv-command-ipc-deps-runtime-service.test.js dist/core/services/runtime-options-ipc-deps-runtime-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/overlay-runtime-deps-service.test.js dist/core/services/startup-lifecycle-runtime-deps-service.test.js dist/core/services/startup-lifecycle-hooks-runtime-service.test.js dist/core/services/overlay-shortcut-runtime-deps-service.test.js dist/core/services/mining-runtime-deps-service.test.js dist/core/services/shortcut-ui-runtime-deps-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 dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/mpv-client-deps-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js",
"test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.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", "generate:config-example": "pnpm run build && node dist/generate-config-example.js",
"start": "pnpm run build && electron . --start", "start": "pnpm run build && electron . --start",

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createStartupLifecycleHooksRuntimeService } from "./startup-lifecycle-hooks-runtime-service";
test("createStartupLifecycleHooksRuntimeService wires app-ready and app-shutdown handlers", async () => {
const calls: string[] = [];
const hooks = createStartupLifecycleHooksRuntimeService({
appReadyDeps: {
loadSubtitlePosition: () => calls.push("loadSubtitlePosition"),
resolveKeybindings: () => calls.push("resolveKeybindings"),
createMpvClient: () => calls.push("createMpvClient"),
reloadConfig: () => calls.push("reloadConfig"),
getResolvedConfig: () => ({
secondarySub: { defaultMode: "hover" },
websocket: { enabled: "auto", port: 1234 },
}),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push("logConfigWarning"),
initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"),
setSecondarySubMode: () => calls.push("setSecondarySubMode"),
defaultSecondarySubMode: "hover",
defaultWebsocketPort: 8765,
hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: () => calls.push("startSubtitleWebsocket"),
log: () => calls.push("log"),
createMecabTokenizerAndCheck: async () => {
calls.push("createMecabTokenizerAndCheck");
},
createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"),
loadYomitanExtension: async () => {
calls.push("loadYomitanExtension");
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
initializeOverlayRuntime: () => calls.push("initializeOverlayRuntime"),
handleInitialArgs: () => calls.push("handleInitialArgs"),
},
appShutdownDeps: {
unregisterAllGlobalShortcuts: () => calls.push("unregisterAllGlobalShortcuts"),
stopSubtitleWebsocket: () => calls.push("stopSubtitleWebsocket"),
stopTexthookerService: () => calls.push("stopTexthookerService"),
destroyYomitanParserWindow: () => calls.push("destroyYomitanParserWindow"),
clearYomitanParserPromises: () => calls.push("clearYomitanParserPromises"),
stopWindowTracker: () => calls.push("stopWindowTracker"),
destroyMpvSocket: () => calls.push("destroyMpvSocket"),
clearReconnectTimer: () => calls.push("clearReconnectTimer"),
destroySubtitleTimingTracker: () => calls.push("destroySubtitleTimingTracker"),
destroyAnkiIntegration: () => calls.push("destroyAnkiIntegration"),
},
shouldRestoreWindowsOnActivate: () => true,
restoreWindowsOnActivate: () => calls.push("restoreWindowsOnActivate"),
});
await hooks.onReady();
hooks.onWillQuitCleanup();
assert.equal(hooks.shouldRestoreWindowsOnActivate(), true);
hooks.restoreWindowsOnActivate();
assert.ok(calls.includes("loadSubtitlePosition"));
assert.ok(calls.includes("handleInitialArgs"));
assert.ok(calls.includes("destroyAnkiIntegration"));
assert.ok(calls.includes("restoreWindowsOnActivate"));
});

View File

@@ -0,0 +1,44 @@
import { AppLifecycleDepsRuntimeOptions } from "./app-lifecycle-deps-runtime-service";
import {
AppReadyRuntimeDeps,
runAppReadyRuntimeService,
} from "./app-ready-runtime-service";
import {
AppShutdownRuntimeDeps,
runAppShutdownRuntimeService,
} from "./app-shutdown-runtime-service";
import {
createStartupAppReadyDepsRuntimeService,
createStartupAppShutdownDepsRuntimeService,
} from "./startup-lifecycle-runtime-deps-service";
type StartupLifecycleHookDeps = Pick<
AppLifecycleDepsRuntimeOptions,
"onReady" | "onWillQuitCleanup" | "shouldRestoreWindowsOnActivate" | "restoreWindowsOnActivate"
>;
export interface StartupLifecycleHooksRuntimeOptions {
appReadyDeps: AppReadyRuntimeDeps;
appShutdownDeps: AppShutdownRuntimeDeps;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}
export function createStartupLifecycleHooksRuntimeService(
options: StartupLifecycleHooksRuntimeOptions,
): StartupLifecycleHookDeps {
return {
onReady: async () => {
await runAppReadyRuntimeService(
createStartupAppReadyDepsRuntimeService(options.appReadyDeps),
);
},
onWillQuitCleanup: () => {
runAppShutdownRuntimeService(
createStartupAppShutdownDepsRuntimeService(options.appShutdownDeps),
);
},
shouldRestoreWindowsOnActivate: options.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate: options.restoreWindowsOnActivate,
};
}

View File

@@ -186,8 +186,6 @@ import {
getOverlayWindowsRuntimeService, getOverlayWindowsRuntimeService,
setOverlayDebugVisualizationEnabledRuntimeService, setOverlayDebugVisualizationEnabledRuntimeService,
} from "./core/services/overlay-broadcast-runtime-service"; } from "./core/services/overlay-broadcast-runtime-service";
import { runAppReadyRuntimeService } from "./core/services/app-ready-runtime-service";
import { runAppShutdownRuntimeService } from "./core/services/app-shutdown-runtime-service";
import { createMpvIpcClientDepsRuntimeService } from "./core/services/mpv-client-deps-runtime-service"; import { createMpvIpcClientDepsRuntimeService } from "./core/services/mpv-client-deps-runtime-service";
import { createAppLifecycleDepsRuntimeService } from "./core/services/app-lifecycle-deps-runtime-service"; import { createAppLifecycleDepsRuntimeService } from "./core/services/app-lifecycle-deps-runtime-service";
import { createCliCommandDepsRuntimeService } from "./core/services/cli-command-deps-runtime-service"; import { createCliCommandDepsRuntimeService } from "./core/services/cli-command-deps-runtime-service";
@@ -225,10 +223,7 @@ import {
createYomitanSettingsWindowDepsRuntimeService, createYomitanSettingsWindowDepsRuntimeService,
runOverlayShortcutLocalFallbackRuntimeService, runOverlayShortcutLocalFallbackRuntimeService,
} from "./core/services/shortcut-ui-runtime-deps-service"; } from "./core/services/shortcut-ui-runtime-deps-service";
import { import { createStartupLifecycleHooksRuntimeService } from "./core/services/startup-lifecycle-hooks-runtime-service";
createStartupAppReadyDepsRuntimeService,
createStartupAppShutdownDepsRuntimeService,
} from "./core/services/startup-lifecycle-runtime-deps-service";
import { createRuntimeOptionsManagerRuntimeService } from "./core/services/runtime-options-manager-runtime-service"; import { createRuntimeOptionsManagerRuntimeService } from "./core/services/runtime-options-manager-runtime-service";
import { createAppLoggingRuntimeService } from "./core/services/app-logging-runtime-service"; import { createAppLoggingRuntimeService } from "./core/services/app-logging-runtime-service";
import { import {
@@ -525,172 +520,166 @@ const startupState = runStartupBootstrapRuntimeService({
handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source), handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(), logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => { ...createStartupLifecycleHooksRuntimeService({
await runAppReadyRuntimeService( appReadyDeps: {
createStartupAppReadyDepsRuntimeService({ loadSubtitlePosition: () => loadSubtitlePosition(),
loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => {
resolveKeybindings: () => { keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); },
}, createMpvClient: () => {
createMpvClient: () => { mpvClient = new MpvIpcClient(
mpvClient = new MpvIpcClient( mpvSocketPath,
mpvSocketPath, createMpvIpcClientDepsRuntimeService({
createMpvIpcClientDepsRuntimeService({ getResolvedConfig: () => getResolvedConfig(),
getResolvedConfig: () => getResolvedConfig(), autoStartOverlay,
autoStartOverlay, setOverlayVisible: (visible) => setOverlayVisible(visible),
setOverlayVisible: (visible) => setOverlayVisible(visible), shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibility: () => shouldBindVisibleOverlayToMpvSubVisibility(),
shouldBindVisibleOverlayToMpvSubVisibility(), isVisibleOverlayVisible: () => visibleOverlayVisible,
isVisibleOverlayVisible: () => visibleOverlayVisible, getReconnectTimer: () => reconnectTimer,
getReconnectTimer: () => reconnectTimer, setReconnectTimer: (timer) => {
setReconnectTimer: (timer) => { reconnectTimer = 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);
}
}, },
onOptionsChanged: () => { getCurrentSubText: () => currentSubText,
broadcastRuntimeOptionsChanged(); setCurrentSubText: (text) => {
refreshOverlayShortcuts(); currentSubText = text;
}, },
}); setCurrentSubAssText: (text) => {
}, currentSubAssText = text;
setSecondarySubMode: (mode) => { },
secondarySubMode = mode; getSubtitleTimingTracker: () => subtitleTimingTracker,
}, subtitleWsBroadcast: (text) => {
defaultSecondarySubMode: "hover", subtitleWsService.broadcast(text);
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, },
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), getOverlayWindowsCount: () => getOverlayWindows().length,
startSubtitleWebsocket: (port) => { tokenizeSubtitle: (text) => tokenizeSubtitle(text),
subtitleWsService.start(port, () => currentSubText); broadcastToOverlayWindows: (channel, ...channelArgs) => {
}, broadcastToOverlayWindows(channel, ...channelArgs);
log: (message) => appLogger.logInfo(message), },
createMecabTokenizerAndCheck: async () => updateCurrentMediaPath: (mediaPath) => {
createMecabTokenizerAndCheckRuntimeService({ updateCurrentMediaPath(mediaPath);
createMecabTokenizer: () => new MecabTokenizer(), },
setMecabTokenizer: (tokenizer) => { updateMpvSubtitleRenderMetrics: (patch) => {
mecabTokenizer = tokenizer; updateMpvSubtitleRenderMetrics(patch);
},
getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics,
setPreviousSecondarySubVisibility: (value) => {
previousSecondarySubVisibility = value;
},
showMpvOsd: (text) => {
showMpvOsd(text);
}, },
}), }),
createSubtitleTimingTracker: () => );
createSubtitleTimingTrackerRuntimeService({ },
createSubtitleTimingTracker: () => new SubtitleTimingTracker(), reloadConfig: () => {
setSubtitleTimingTracker: (tracker) => { configService.reloadConfig();
subtitleTimingTracker = tracker; },
}, getResolvedConfig: () => getResolvedConfig(),
}), getConfigWarnings: () => configService.getWarnings(),
loadYomitanExtension: async () => { logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
await loadYomitanExtension(); initRuntimeOptionsManager: () => {
}, runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({
texthookerOnlyMode, getAnkiConfig: () => configService.getConfig().ankiConnect,
shouldAutoInitializeOverlayRuntimeFromConfig: () => applyAnkiPatch: (patch) => {
shouldAutoInitializeOverlayRuntimeFromConfig(), if (ankiIntegration) {
initializeOverlayRuntime: () => initializeOverlayRuntime(), ankiIntegration.applyRuntimeConfigPatch(patch);
handleInitialArgs: () => handleInitialArgs(), }
}), },
); onOptionsChanged: () => {
}, broadcastRuntimeOptionsChanged();
onWillQuitCleanup: () => { refreshOverlayShortcuts();
runAppShutdownRuntimeService( },
createStartupAppShutdownDepsRuntimeService({ });
unregisterAllGlobalShortcuts: () => { },
globalShortcut.unregisterAll(); setSecondarySubMode: (mode) => {
}, secondarySubMode = mode;
stopSubtitleWebsocket: () => { },
subtitleWsService.stop(); defaultSecondarySubMode: "hover",
}, defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
stopTexthookerService: () => { hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
texthookerService.stop(); startSubtitleWebsocket: (port) => {
}, subtitleWsService.start(port, () => currentSubText);
destroyYomitanParserWindow: () => { },
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { log: (message) => appLogger.logInfo(message),
yomitanParserWindow.destroy(); createMecabTokenizerAndCheck: async () =>
} createMecabTokenizerAndCheckRuntimeService({
yomitanParserWindow = null; createMecabTokenizer: () => new MecabTokenizer(),
}, setMecabTokenizer: (tokenizer) => {
clearYomitanParserPromises: () => { mecabTokenizer = tokenizer;
yomitanParserReadyPromise = null; },
yomitanParserInitPromise = null; }),
}, createSubtitleTimingTracker: () =>
stopWindowTracker: () => { createSubtitleTimingTrackerRuntimeService({
if (windowTracker) { createSubtitleTimingTracker: () => new SubtitleTimingTracker(),
windowTracker.stop(); setSubtitleTimingTracker: (tracker) => {
} subtitleTimingTracker = tracker;
}, },
destroyMpvSocket: () => { }),
if (mpvClient && mpvClient.socket) { loadYomitanExtension: async () => {
mpvClient.socket.destroy(); await loadYomitanExtension();
} },
}, texthookerOnlyMode,
clearReconnectTimer: () => { shouldAutoInitializeOverlayRuntimeFromConfig: () =>
if (reconnectTimer) { shouldAutoInitializeOverlayRuntimeFromConfig(),
clearTimeout(reconnectTimer); initializeOverlayRuntime: () => initializeOverlayRuntime(),
} handleInitialArgs: () => handleInitialArgs(),
}, },
destroySubtitleTimingTracker: () => { appShutdownDeps: {
if (subtitleTimingTracker) { unregisterAllGlobalShortcuts: () => {
subtitleTimingTracker.destroy(); globalShortcut.unregisterAll();
} },
}, stopSubtitleWebsocket: () => {
destroyAnkiIntegration: () => { subtitleWsService.stop();
if (ankiIntegration) { },
ankiIntegration.destroy(); stopTexthookerService: () => {
} texthookerService.stop();
}, },
}), destroyYomitanParserWindow: () => {
); if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
}, yomitanParserWindow.destroy();
shouldRestoreWindowsOnActivate: () => }
overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, yomitanParserWindow = null;
restoreWindowsOnActivate: () => { },
createMainWindow(); clearYomitanParserPromises: () => {
createInvisibleWindow(); yomitanParserReadyPromise = null;
updateVisibleOverlayVisibility(); yomitanParserInitPromise = null;
updateInvisibleOverlayVisibility(); },
}, 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();
},
}),
})); }));
}, },
}); });