refactor: extract app-ready startup orchestration service

This commit is contained in:
2026-02-09 23:56:26 -08:00
parent 83fd351080
commit 2878a1f3d1
4 changed files with 209 additions and 70 deletions

View File

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

View File

@@ -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<void>;
createSubtitleTimingTracker: () => void;
loadYomitanExtension: () => Promise<void>;
texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
initializeOverlayRuntime: () => void;
handleInitialArgs: () => void;
}
export async function runAppReadyRuntimeService(
deps: AppReadyRuntimeDeps,
): Promise<void> {
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();
}

View File

@@ -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();