refactor: extract startup bootstrap runtime orchestration

This commit is contained in:
2026-02-10 01:40:57 -08:00
parent cb93601e16
commit 31f76ad476
3 changed files with 376 additions and 208 deletions

View File

@@ -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> = {}): 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",
]);
});

View File

@@ -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;
}

View File

@@ -236,6 +236,7 @@ import {
createSubtitleTimingTrackerRuntimeService, createSubtitleTimingTrackerRuntimeService,
} from "./core/services/startup-resource-runtime-service"; } from "./core/services/startup-resource-runtime-service";
import { runGenerateConfigFlowRuntimeService } from "./core/services/config-generation-runtime-service"; import { runGenerateConfigFlowRuntimeService } from "./core/services/config-generation-runtime-service";
import { runStartupBootstrapRuntimeService } from "./core/services/startup-bootstrap-runtime-service";
import { import {
runSubsyncManualFromIpcRuntimeService, runSubsyncManualFromIpcRuntimeService,
triggerSubsyncFromConfigRuntimeService, triggerSubsyncFromConfigRuntimeService,
@@ -479,218 +480,227 @@ function updateCurrentMediaPath(mediaPath: unknown): void {
let subsyncInProgress = false; let subsyncInProgress = false;
const initialArgs = parseArgs(process.argv); const startupState = runStartupBootstrapRuntimeService({
if (initialArgs.logLevel) { argv: process.argv,
process.env.SUBMINER_LOG_LEVEL = initialArgs.logLevel; parseArgs: (argv) => parseArgs(argv),
} else if (initialArgs.verbose) { setLogLevelEnv: (level) => {
process.env.SUBMINER_LOG_LEVEL = "debug"; process.env.SUBMINER_LOG_LEVEL = level;
} },
enableVerboseLogging: () => {
forceX11Backend(initialArgs); process.env.SUBMINER_LOG_LEVEL = "debug";
enforceUnsupportedWaylandMode(initialArgs); },
forceX11Backend: (args) => {
let mpvSocketPath = initialArgs.socketPath ?? getDefaultSocketPath(); forceX11Backend(args);
let texthookerPort = initialArgs.texthookerPort ?? DEFAULT_TEXTHOOKER_PORT; },
const backendOverride = initialArgs.backend ?? null; enforceUnsupportedWaylandMode: (args) => {
const autoStartOverlay = initialArgs.autoStartOverlay; enforceUnsupportedWaylandMode(args);
const texthookerOnlyMode = initialArgs.texthooker; },
getDefaultSocketPath: () => getDefaultSocketPath(),
if ( defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
!runGenerateConfigFlowRuntimeService(initialArgs, { runGenerateConfigFlow: (args) =>
shouldStartApp: (args) => shouldStartApp(args), runGenerateConfigFlowRuntimeService(args, {
generateConfig: async (args) => shouldStartApp: (nextArgs) => shouldStartApp(nextArgs),
generateDefaultConfigFile(args, { generateConfig: async (nextArgs) =>
configDir: CONFIG_DIR, generateDefaultConfigFile(nextArgs, {
defaultConfig: DEFAULT_CONFIG, configDir: CONFIG_DIR,
generateTemplate: (config) => generateConfigTemplate(config as never), defaultConfig: DEFAULT_CONFIG,
}), generateTemplate: (config) => generateConfigTemplate(config as never),
onSuccess: (exitCode) => { }),
process.exitCode = exitCode; onSuccess: (exitCode) => {
app.quit(); process.exitCode = exitCode;
}, app.quit();
onError: (error) => { },
console.error(`Failed to generate config: ${error.message}`); onError: (error) => {
process.exitCode = 1; console.error(`Failed to generate config: ${error.message}`);
app.quit(); process.exitCode = 1;
}, app.quit();
}) },
) { }),
startAppLifecycleService(initialArgs, createAppLifecycleDepsRuntimeService({ startAppLifecycle: (args) => {
app, startAppLifecycleService(args, createAppLifecycleDepsRuntimeService({
platform: process.platform, app,
shouldStartApp: (args) => shouldStartApp(args), platform: process.platform,
parseArgs: (argv) => parseArgs(argv), shouldStartApp: (nextArgs) => shouldStartApp(nextArgs),
handleCliCommand: (args, source) => handleCliCommand(args, source), parseArgs: (argv) => parseArgs(argv),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source),
logNoRunningInstance: () => appLogger.logNoRunningInstance(), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
onReady: async () => { logNoRunningInstance: () => appLogger.logNoRunningInstance(),
await runAppReadyRuntimeService( onReady: async () => {
createStartupAppReadyDepsRuntimeService({ await runAppReadyRuntimeService(
loadSubtitlePosition: () => loadSubtitlePosition(), createStartupAppReadyDepsRuntimeService({
resolveKeybindings: () => { loadSubtitlePosition: () => loadSubtitlePosition(),
keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); resolveKeybindings: () => {
}, keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
createMpvClient: () => { },
mpvClient = new MpvIpcClient( createMpvClient: () => {
mpvSocketPath, mpvClient = new MpvIpcClient(
createMpvIpcClientDepsRuntimeService({ mpvSocketPath,
getResolvedConfig: () => getResolvedConfig(), createMpvIpcClientDepsRuntimeService({
autoStartOverlay, getResolvedConfig: () => getResolvedConfig(),
setOverlayVisible: (visible) => setOverlayVisible(visible), autoStartOverlay,
shouldBindVisibleOverlayToMpvSubVisibility: () => setOverlayVisible: (visible) => setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility(), shouldBindVisibleOverlayToMpvSubVisibility: () =>
isVisibleOverlayVisible: () => visibleOverlayVisible, shouldBindVisibleOverlayToMpvSubVisibility(),
getReconnectTimer: () => reconnectTimer, isVisibleOverlayVisible: () => visibleOverlayVisible,
setReconnectTimer: (timer) => { getReconnectTimer: () => reconnectTimer,
reconnectTimer = timer; 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, onOptionsChanged: () => {
setCurrentSubText: (text) => { broadcastRuntimeOptionsChanged();
currentSubText = text; refreshOverlayShortcuts();
}, },
setCurrentSubAssText: (text) => { });
currentSubAssText = text; },
}, setSecondarySubMode: (mode) => {
getSubtitleTimingTracker: () => subtitleTimingTracker, secondarySubMode = mode;
subtitleWsBroadcast: (text) => { },
subtitleWsService.broadcast(text); defaultSecondarySubMode: "hover",
}, defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
getOverlayWindowsCount: () => getOverlayWindows().length, hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
tokenizeSubtitle: (text) => tokenizeSubtitle(text), startSubtitleWebsocket: (port) => {
broadcastToOverlayWindows: (channel, ...args) => { subtitleWsService.start(port, () => currentSubText);
broadcastToOverlayWindows(channel, ...args); },
}, log: (message) => appLogger.logInfo(message),
updateCurrentMediaPath: (mediaPath) => { createMecabTokenizerAndCheck: async () =>
updateCurrentMediaPath(mediaPath); createMecabTokenizerAndCheckRuntimeService({
}, createMecabTokenizer: () => new MecabTokenizer(),
updateMpvSubtitleRenderMetrics: (patch) => { setMecabTokenizer: (tokenizer) => {
updateMpvSubtitleRenderMetrics(patch); mecabTokenizer = tokenizer;
},
getMpvSubtitleRenderMetrics: () => mpvSubtitleRenderMetrics,
setPreviousSecondarySubVisibility: (value) => {
previousSecondarySubVisibility = value;
},
showMpvOsd: (text) => {
showMpvOsd(text);
}, },
}), }),
); createSubtitleTimingTracker: () =>
}, createSubtitleTimingTrackerRuntimeService({
reloadConfig: () => { createSubtitleTimingTracker: () => new SubtitleTimingTracker(),
configService.reloadConfig(); setSubtitleTimingTracker: (tracker) => {
}, subtitleTimingTracker = tracker;
getResolvedConfig: () => getResolvedConfig(), },
getConfigWarnings: () => configService.getWarnings(), }),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning), loadYomitanExtension: async () => {
initRuntimeOptionsManager: () => { await loadYomitanExtension();
runtimeOptionsManager = createRuntimeOptionsManagerRuntimeService({ },
getAnkiConfig: () => configService.getConfig().ankiConnect, texthookerOnlyMode,
applyAnkiPatch: (patch) => { shouldAutoInitializeOverlayRuntimeFromConfig: () =>
if (ankiIntegration) { shouldAutoInitializeOverlayRuntimeFromConfig(),
ankiIntegration.applyRuntimeConfigPatch(patch); initializeOverlayRuntime: () => initializeOverlayRuntime(),
} handleInitialArgs: () => handleInitialArgs(),
}, }),
onOptionsChanged: () => { );
broadcastRuntimeOptionsChanged(); },
refreshOverlayShortcuts(); onWillQuitCleanup: () => {
}, runAppShutdownRuntimeService(
}); createStartupAppShutdownDepsRuntimeService({
}, unregisterAllGlobalShortcuts: () => {
setSecondarySubMode: (mode) => { globalShortcut.unregisterAll();
secondarySubMode = mode; },
}, stopSubtitleWebsocket: () => {
defaultSecondarySubMode: "hover", subtitleWsService.stop();
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, },
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), stopTexthookerService: () => {
startSubtitleWebsocket: (port) => { texthookerService.stop();
subtitleWsService.start(port, () => currentSubText); },
}, destroyYomitanParserWindow: () => {
log: (message) => appLogger.logInfo(message), if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
createMecabTokenizerAndCheck: async () => yomitanParserWindow.destroy();
createMecabTokenizerAndCheckRuntimeService({ }
createMecabTokenizer: () => new MecabTokenizer(), yomitanParserWindow = null;
setMecabTokenizer: (tokenizer) => { },
mecabTokenizer = tokenizer; clearYomitanParserPromises: () => {
}, yomitanParserReadyPromise = null;
}), yomitanParserInitPromise = null;
createSubtitleTimingTracker: () => },
createSubtitleTimingTrackerRuntimeService({ stopWindowTracker: () => {
createSubtitleTimingTracker: () => new SubtitleTimingTracker(), if (windowTracker) {
setSubtitleTimingTracker: (tracker) => { windowTracker.stop();
subtitleTimingTracker = tracker; }
}, },
}), destroyMpvSocket: () => {
loadYomitanExtension: async () => { if (mpvClient && mpvClient.socket) {
await loadYomitanExtension(); mpvClient.socket.destroy();
}, }
texthookerOnlyMode, },
shouldAutoInitializeOverlayRuntimeFromConfig: () => clearReconnectTimer: () => {
shouldAutoInitializeOverlayRuntimeFromConfig(), if (reconnectTimer) {
initializeOverlayRuntime: () => initializeOverlayRuntime(), clearTimeout(reconnectTimer);
handleInitialArgs: () => handleInitialArgs(), }
}), },
); destroySubtitleTimingTracker: () => {
}, if (subtitleTimingTracker) {
onWillQuitCleanup: () => { subtitleTimingTracker.destroy();
runAppShutdownRuntimeService( }
createStartupAppShutdownDepsRuntimeService({ },
unregisterAllGlobalShortcuts: () => { destroyAnkiIntegration: () => {
globalShortcut.unregisterAll(); if (ankiIntegration) {
}, ankiIntegration.destroy();
stopSubtitleWebsocket: () => { }
subtitleWsService.stop(); },
}, }),
stopTexthookerService: () => { );
texthookerService.stop(); },
}, shouldRestoreWindowsOnActivate: () =>
destroyYomitanParserWindow: () => { overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { restoreWindowsOnActivate: () => {
yomitanParserWindow.destroy(); createMainWindow();
} createInvisibleWindow();
yomitanParserWindow = null; updateVisibleOverlayVisibility();
}, updateInvisibleOverlayVisibility();
clearYomitanParserPromises: () => { },
yomitanParserReadyPromise = null; }));
yomitanParserInitPromise = null; },
}, });
stopWindowTracker: () => {
if (windowTracker) { const initialArgs = startupState.initialArgs;
windowTracker.stop(); let mpvSocketPath = startupState.mpvSocketPath;
} let texthookerPort = startupState.texthookerPort;
}, const backendOverride = startupState.backendOverride;
destroyMpvSocket: () => { const autoStartOverlay = startupState.autoStartOverlay;
if (mpvClient && mpvClient.socket) { const texthookerOnlyMode = startupState.texthookerOnlyMode;
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();
},
}));
}
function handleCliCommand( function handleCliCommand(
args: CliArgs, args: CliArgs,