refactor: split main boot phases

This commit is contained in:
2026-03-27 22:54:03 -07:00
parent 742a0dabe5
commit 23b2360ac4
10 changed files with 1165 additions and 157 deletions

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-238.7 id: TASK-238.7
title: Split src/main.ts into boot-phase services, runtimes, and handlers title: Split src/main.ts into boot-phase services, runtimes, and handlers
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-03-27 00:00' created_date: '2026-03-27 00:00'
updated_date: '2026-03-27 22:45'
labels: labels:
- tech-debt - tech-debt
- runtime - runtime
@@ -31,11 +32,11 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`. - [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
- [ ] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch. - [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
- [ ] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection. - [x] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection.
- [ ] #4 Existing startup behavior remains unchanged across desktop and headless flows. - [x] #4 Existing startup behavior remains unchanged across desktop and headless flows.
- [ ] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes. - [x] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -56,3 +57,29 @@ Guardrails:
- Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer. - Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer.
- Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here. - Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added boot-phase modules under `src/main/boot/`:
`services.ts` for config/user-data/runtime-registry/overlay bootstrap service construction,
`runtimes.ts` for named runtime/composer entrypoints and grouped boot-phase seams,
and `handlers.ts` for handler/composer boot entrypoints.
Rewired `src/main.ts` to source boot-phase service construction from `createMainBootServices(...)` and to route runtime/handler composition through boot-level exports instead of keeping the entrypoint as the direct owner of every composition import.
Added focused tests for the new boot seams in
`src/main/boot/services.test.ts`,
`src/main/boot/runtimes.test.ts`,
and `src/main/boot/handlers.test.ts`.
Updated internal architecture docs to note that `src/main/boot/` now owns boot-phase assembly seams so `src/main.ts` can stay centered on lifecycle coordination and startup-path selection.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-238.7 is complete. Verification passed with focused boot tests, `bun run typecheck`, `bun run test:fast`, and `bun run build`. `src/main.ts` still acts as the composition root, but the boot-phase split now moves service instantiation, runtime composition seams, and handler composition seams into dedicated `src/main/boot/*` modules so the entrypoint reads more like a lifecycle coordinator than a single monolithic bootstrap file.
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.7-main-boot-split.md` for the internal runtime architecture pass.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,6 @@
type: internal
area: runtime
- Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
- Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
- Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.

View File

@@ -24,6 +24,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
## Current Shape ## Current Shape
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/renderer/` owns overlay rendering and input behavior. - `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `src/config/` owns config definitions, defaults, loading, and resolution.

View File

@@ -401,6 +401,31 @@ import {
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices } from './main/boot/services';
import {
composeBootOverlayVisibilityRuntime,
composeBootJellyfinRuntimeHandlers,
composeBootAnilistSetupHandlers,
createBootMaybeFocusExistingAnilistSetupWindowHandler,
createBootBuildOpenAnilistSetupWindowMainDepsHandler,
createBootOpenAnilistSetupWindowHandler,
composeBootAnilistTrackingHandlers,
composeBootStatsStartupRuntime,
createBootRunStatsCliCommandHandler,
composeBootAppReadyRuntime,
composeBootMpvRuntimeHandlers,
createBootTrayRuntimeHandlers,
createBootYomitanProfilePolicy,
createBootYomitanExtensionRuntime,
createBootYomitanSettingsRuntime,
} from './main/boot/runtimes';
import {
composeBootStartupLifecycleHandlers,
composeBootIpcRuntimeHandlers,
composeBootCliStartupHandlers,
composeBootHeadlessStartupHandlers,
composeBootOverlayWindowHandlers,
} from './main/boot/handlers';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
@@ -555,59 +580,6 @@ function applyJellyfinMpvDefaults(
applyJellyfinMpvDefaultsHandler(client); applyJellyfinMpvDefaultsHandler(client);
} }
const CONFIG_DIR = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
const USER_DATA_PATH = CONFIG_DIR;
const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE;
const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, 'immersion.sqlite');
const configService = (() => {
try {
return new ConfigService(CONFIG_DIR);
} catch (error) {
if (error instanceof ConfigStartupParseError) {
failStartupFromConfig(
'SubMiner config parse error',
buildConfigParseErrorDetails(error.path, error.parseError),
{
logError: (details) => console.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
quit: () => requestAppQuit(),
},
);
}
throw error;
}
})();
const anilistTokenStore = createAnilistTokenStore(
path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE),
{
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
},
);
const jellyfinTokenStore = createJellyfinTokenStore(
path.join(USER_DATA_PATH, JELLYFIN_TOKEN_STORE_FILE),
{
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
},
);
const anilistUpdateQueue = createAnilistUpdateQueue(
path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE),
{
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
},
);
const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); const isDev = process.argv.includes('--dev') || process.argv.includes('--debug');
const texthookerService = new Texthooker(() => { const texthookerService = new Texthooker(() => {
const config = getResolvedConfig(); const config = getResolvedConfig();
@@ -644,9 +616,141 @@ const texthookerService = new Texthooker(() => {
}, },
}; };
}); });
const subtitleWsService = new SubtitleWebSocket(); let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
const annotationSubtitleWsService = new SubtitleWebSocket(); let syncOverlayVisibilityForModal: () => void = () => {};
const logger = createLogger('main'); const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
platform: process.platform,
});
const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler();
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps);
function getDefaultSocketPath(): string {
return getDefaultSocketPathHandler();
}
const bootServices = createMainBootServices({
platform: process.platform,
argv: process.argv,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
defaultMpvLogFile: DEFAULT_MPV_LOG_FILE,
envMpvLog: process.env.SUBMINER_MPV_LOG,
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
getDefaultSocketPath: () => getDefaultSocketPath(),
resolveConfigDir,
existsSync: fs.existsSync,
mkdirSync: fs.mkdirSync,
joinPath: (...parts) => path.join(...parts),
app,
shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(process.argv),
requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(app),
registerSecondInstanceHandlerEarly: (listener) => {
registerSecondInstanceHandlerEarly(app, listener);
},
onConfigStartupParseError: (error) => {
failStartupFromConfig(
'SubMiner config parse error',
buildConfigParseErrorDetails(error.path, error.parseError),
{
logError: (details) => console.error(details),
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
quit: () => requestAppQuit(),
},
);
},
createConfigService: (configDir) => new ConfigService(configDir),
createAnilistTokenStore: (targetPath) =>
createAnilistTokenStore(targetPath, {
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
}),
createJellyfinTokenStore: (targetPath) =>
createJellyfinTokenStore(targetPath, {
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
createAnilistUpdateQueue: (targetPath) =>
createAnilistUpdateQueue(targetPath, {
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
createSubtitleWebSocket: () => new SubtitleWebSocket(),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
createOverlayModalInputState,
createOverlayContentMeasurementStore: ({ logger }) => {
const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({
now: () => Date.now(),
warn: (message: string) => logger.warn(message),
});
return createOverlayContentMeasurementStore(buildHandler());
},
getSyncOverlayShortcutsForModal: () => syncOverlayShortcutsForModal,
getSyncOverlayVisibilityForModal: () => syncOverlayVisibilityForModal,
createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) => {
const buildHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(),
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
});
return createOverlayModalRuntimeService(buildHandler(), {
onModalStateChange: (isActive: boolean) =>
overlayModalInputState.handleModalInputStateChange(isActive),
});
},
createAppState,
}) as {
configDir: string;
userDataPath: string;
defaultMpvLogPath: string;
defaultImmersionDbPath: string;
configService: ConfigService;
anilistTokenStore: ReturnType<typeof createAnilistTokenStore>;
jellyfinTokenStore: ReturnType<typeof createJellyfinTokenStore>;
anilistUpdateQueue: ReturnType<typeof createAnilistUpdateQueue>;
subtitleWsService: SubtitleWebSocket;
annotationSubtitleWsService: SubtitleWebSocket;
logger: ReturnType<typeof createLogger>;
runtimeRegistry: ReturnType<typeof createMainRuntimeRegistry>;
overlayManager: ReturnType<typeof createOverlayManager>;
overlayModalInputState: ReturnType<typeof createOverlayModalInputState>;
overlayContentMeasurementStore: ReturnType<typeof createOverlayContentMeasurementStore>;
overlayModalRuntime: ReturnType<typeof createOverlayModalRuntimeService>;
appState: ReturnType<typeof createAppState>;
appLifecycleApp: {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
};
};
const {
configDir: CONFIG_DIR,
userDataPath: USER_DATA_PATH,
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultImmersionDbPath: DEFAULT_IMMERSION_DB_PATH,
configService,
anilistTokenStore,
jellyfinTokenStore,
anilistUpdateQueue,
subtitleWsService,
annotationSubtitleWsService,
logger,
runtimeRegistry,
overlayManager,
overlayModalInputState,
overlayContentMeasurementStore,
overlayModalRuntime,
appState,
appLifecycleApp,
} = bootServices;
notifyAnilistTokenStoreWarning = (message: string) => { notifyAnilistTokenStoreWarning = (message: string) => {
logger.warn(`[AniList] ${message}`); logger.warn(`[AniList] ${message}`);
try { try {
@@ -681,41 +785,6 @@ const appLogger = {
); );
}, },
}; };
const runtimeRegistry = createMainRuntimeRegistry();
const appLifecycleApp = {
requestSingleInstanceLock: () =>
shouldBypassSingleInstanceLockForArgv(process.argv)
? true
: requestSingleInstanceLockEarly(app),
quit: () => app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
registerSecondInstanceHandlerEarly(
app,
listener as (_event: unknown, argv: string[]) => void,
);
return app;
}
app.on(event as Parameters<typeof app.on>[0], listener as (...args: any[]) => void);
return app;
},
whenReady: () => app.whenReady(),
};
const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
platform: process.platform,
});
const getDefaultSocketPathMainDeps = buildGetDefaultSocketPathMainDepsHandler();
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(getDefaultSocketPathMainDeps);
function getDefaultSocketPath(): string {
return getDefaultSocketPathHandler();
}
if (!fs.existsSync(USER_DATA_PATH)) {
fs.mkdirSync(USER_DATA_PATH, { recursive: true });
}
app.setPath('userData', USER_DATA_PATH);
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null; let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
let statsServer: ReturnType<typeof startStatsServer> | null = null; let statsServer: ReturnType<typeof startStatsServer> | null = null;
@@ -777,46 +846,6 @@ process.on('SIGTERM', () => {
requestAppQuit(); requestAppQuit();
}); });
const overlayManager = createOverlayManager();
let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
let syncOverlayVisibilityForModal: () => void = () => {};
const overlayModalInputState = createOverlayModalInputState({
getModalWindow: () => overlayManager.getModalWindow(),
syncOverlayShortcutsForModal: (isActive) => {
syncOverlayShortcutsForModal(isActive);
},
syncOverlayVisibilityForModal: () => {
syncOverlayVisibilityForModal();
},
});
const buildOverlayContentMeasurementStoreMainDepsHandler =
createBuildOverlayContentMeasurementStoreMainDepsHandler({
now: () => Date.now(),
warn: (message: string) => logger.warn(message),
});
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => createModalWindow(),
getModalGeometry: () => getCurrentOverlayGeometry(),
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
});
const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler();
const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
overlayContentMeasurementStoreMainDeps,
);
const overlayModalRuntime = createOverlayModalRuntimeService(
buildOverlayModalRuntimeMainDepsHandler(),
{
onModalStateChange: (isActive: boolean) =>
overlayModalInputState.handleModalInputStateChange(isActive),
},
);
const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const startBackgroundWarmupsIfAllowed = (): void => { const startBackgroundWarmupsIfAllowed = (): void => {
startBackgroundWarmups(); startBackgroundWarmups();
}; };
@@ -1887,7 +1916,7 @@ const buildOpenRuntimeOptionsPaletteMainDepsHandler =
createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(),
}); });
const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ const overlayVisibilityComposer = composeBootOverlayVisibilityRuntime({
overlayVisibilityRuntime, overlayVisibilityRuntime,
restorePreviousSecondarySubVisibilityMainDeps: restorePreviousSecondarySubVisibilityMainDeps:
buildRestorePreviousSecondarySubVisibilityMainDepsHandler(), buildRestorePreviousSecondarySubVisibilityMainDepsHandler(),
@@ -1959,7 +1988,7 @@ const {
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
} = composeJellyfinRuntimeHandlers({ } = composeBootJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: { getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
loadStoredSession: () => jellyfinTokenStore.loadSession(), loadStoredSession: () => jellyfinTokenStore.loadSession(),
@@ -2259,7 +2288,7 @@ const {
consumeAnilistSetupTokenFromUrl, consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl, handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient, registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({ } = composeBootAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient), hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showMpvOsd(message), showMpvOsd: (message) => showMpvOsd(message),
@@ -2310,10 +2339,10 @@ const {
}, },
}); });
const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({ const maybeFocusExistingAnilistSetupWindow = createBootMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => appState.anilistSetupWindow, getSetupWindow: () => appState.anilistSetupWindow,
}); });
const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler( const buildOpenAnilistSetupWindowMainDepsHandler = createBootBuildOpenAnilistSetupWindowMainDepsHandler(
{ {
maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow,
createSetupWindow: createCreateAnilistSetupWindowHandler({ createSetupWindow: createCreateAnilistSetupWindowHandler({
@@ -2361,7 +2390,7 @@ const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWi
); );
function openAnilistSetupWindow(): void { function openAnilistSetupWindow(): void {
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); createBootOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())();
} }
const { const {
@@ -2375,7 +2404,7 @@ const {
ensureAnilistMediaGuess, ensureAnilistMediaGuess,
processNextAnilistRetryUpdate, processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate, maybeRunAnilistPostWatchUpdate,
} = composeAnilistTrackingHandlers({ } = composeBootAnilistTrackingHandlers({
refreshClientSecretMainDeps: { refreshClientSecretMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
@@ -2642,7 +2671,7 @@ const {
onWillQuitCleanup: onWillQuitCleanupHandler, onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler, restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
} = composeStartupLifecycleHandlers({ } = composeBootStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: { registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: (listener) => { registerOpenUrl: (listener) => {
app.on('open-url', listener); app.on('open-url', listener);
@@ -2950,7 +2979,7 @@ const ensureImmersionTrackerStarted = (): void => {
hasAttemptedImmersionTrackerStartup = true; hasAttemptedImmersionTrackerStartup = true;
createImmersionTrackerStartup(); createImmersionTrackerStartup();
}; };
const statsStartupRuntime = composeStatsStartupRuntime({ const statsStartupRuntime = composeBootStatsStartupRuntime({
ensureStatsServerStarted: () => ensureStatsServerStarted(), ensureStatsServerStarted: () => ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(), stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
@@ -2964,7 +2993,7 @@ const statsStartupRuntime = composeStatsStartupRuntime({
}, },
}); });
const runStatsCliCommand = createRunStatsCliCommandHandler({ const runStatsCliCommand = createBootRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(),
ensureVocabularyCleanupTokenizerReady: async () => { ensureVocabularyCleanupTokenizerReady: async () => {
@@ -3031,7 +3060,7 @@ async function runHeadlessInitialCommand(): Promise<void> {
} }
} }
const { appReadyRuntimeRunner } = composeAppReadyRuntime({ const { appReadyRuntimeRunner } = composeBootAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(), reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message), logInfo: (message) => appLogger.logInfo(message),
@@ -3266,7 +3295,7 @@ const {
startBackgroundWarmups, startBackgroundWarmups,
startTokenizationWarmups, startTokenizationWarmups,
isTokenizationWarmupReady, isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers< } = composeBootMpvRuntimeHandlers<
MpvIpcClient, MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>, ReturnType<typeof createTokenizerDepsRuntime>,
SubtitleData SubtitleData
@@ -4113,7 +4142,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ const { registerIpcRuntimeHandlers } = composeBootIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
@@ -4333,7 +4362,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
registerIpcRuntimeServices, registerIpcRuntimeServices,
}, },
}); });
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ const { handleCliCommand, handleInitialArgs } = composeBootCliStartupHandlers({
cliCommandContextMainDeps: { cliCommandContextMainDeps: {
appState, appState,
setLogLevel: (level) => setLogLevel(level, 'cli'), setLogLevel: (level) => setLogLevel(level, 'cli'),
@@ -4419,7 +4448,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
}); });
const { runAndApplyStartupState } = composeHeadlessStartupHandlers< const { runAndApplyStartupState } = composeBootHeadlessStartupHandlers<
CliArgs, CliArgs,
StartupState, StartupState,
ReturnType<typeof createStartupBootstrapRuntimeDeps> ReturnType<typeof createStartupBootstrapRuntimeDeps>
@@ -4487,7 +4516,7 @@ if (isAnilistTrackingEnabled(getResolvedConfig())) {
} }
void initializeDiscordPresenceService(); void initializeDiscordPresenceService();
const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } =
composeOverlayWindowHandlers<BrowserWindow>({ composeBootOverlayWindowHandlers<BrowserWindow>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev, isDev,
@@ -4513,7 +4542,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setModalWindow: (window) => overlayManager.setModalWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
createTrayRuntimeHandlers({ createBootTrayRuntimeHandlers({
resolveTrayIconPathDeps: { resolveTrayIconPathDeps: {
resolveTrayIconPathRuntime, resolveTrayIconPathRuntime,
platform: process.platform, platform: process.platform,
@@ -4560,12 +4589,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
}); });
const yomitanProfilePolicy = createYomitanProfilePolicy({ const yomitanProfilePolicy = createBootYomitanProfilePolicy({
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}); });
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({ const yomitanExtensionRuntime = createBootYomitanExtensionRuntime({
loadYomitanExtensionCore, loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath, externalProfilePath: configuredExternalYomitanProfilePath,
@@ -4647,7 +4676,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
}, },
}, },
}); });
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ const { openYomitanSettings: openYomitanSettingsHandler } = createBootYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
getYomitanSession: () => appState.yomitanSession, getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => { openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootHandlers } from './handlers';
test('createMainBootHandlers returns grouped handler bundles', () => {
const handlers = createMainBootHandlers<any, any, any, any>({
startupLifecycleDeps: {
registerProtocolUrlHandlersMainDeps: {} as never,
onWillQuitCleanupMainDeps: {} as never,
shouldRestoreWindowsOnActivateMainDeps: {} as never,
restoreWindowsOnActivateMainDeps: {} as never,
},
ipcRuntimeDeps: {
mpvCommandMainDeps: {} as never,
handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never,
runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never,
registration: {
runtimeOptions: {} as never,
mainDeps: {} as never,
ankiJimakuDeps: {} as never,
registerIpcRuntimeServices: () => {},
},
},
cliStartupDeps: {
cliCommandContextMainDeps: {} as never,
cliCommandRuntimeHandlerMainDeps: {} as never,
initialArgsRuntimeHandlerMainDeps: {} as never,
},
headlessStartupDeps: {
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => () => {},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: () => {},
quitApp: () => {},
logGenerateConfigError: () => {},
startAppLifecycle: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: () => ({ mode: 'started' } as never),
applyStartupState: () => {},
},
},
overlayWindowDeps: {
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: () => {},
setModalWindow: () => {},
},
});
assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function');
assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function');
assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function');
assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function');
assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function');
});

40
src/main/boot/handlers.ts Normal file
View File

@@ -0,0 +1,40 @@
import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer';
import {
composeCliStartupHandlers,
composeHeadlessStartupHandlers,
composeIpcRuntimeHandlers,
composeStartupLifecycleHandlers,
} from '../runtime/composers';
export interface MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps> {
startupLifecycleDeps: Parameters<typeof composeStartupLifecycleHandlers>[0];
ipcRuntimeDeps: Parameters<typeof composeIpcRuntimeHandlers>[0];
cliStartupDeps: Parameters<typeof composeCliStartupHandlers>[0];
headlessStartupDeps: Parameters<
typeof composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>
>[0];
overlayWindowDeps: Parameters<typeof composeOverlayWindowHandlers<TBrowserWindow>>[0];
}
export function createMainBootHandlers<
TBrowserWindow,
TCliArgs,
TStartupState,
TBootstrapDeps,
>(params: MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps>) {
return {
startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps),
ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps),
cliStartup: composeCliStartupHandlers(params.cliStartupDeps),
headlessStartup: composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>(
params.headlessStartupDeps,
),
overlayWindow: composeOverlayWindowHandlers<TBrowserWindow>(params.overlayWindowDeps),
};
}
export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers;
export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers;
export const composeBootCliStartupHandlers = composeCliStartupHandlers;
export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers;
export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers;

View File

@@ -0,0 +1,339 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootRuntimes } from './runtimes';
test('createMainBootRuntimes returns grouped runtime bundles', () => {
const runtimes = createMainBootRuntimes<any, any, any, any>({
overlayVisibilityRuntimeDeps: {
overlayVisibilityRuntime: {} as never,
restorePreviousSecondarySubVisibilityMainDeps: {} as never,
broadcastRuntimeOptionsChangedMainDeps: {} as never,
sendToActiveOverlayWindowMainDeps: {} as never,
setOverlayDebugVisualizationEnabledMainDeps: {} as never,
openRuntimeOptionsPaletteMainDeps: {} as never,
},
jellyfinRuntimeHandlerDeps: {
getResolvedJellyfinConfigMainDeps: {} as never,
getJellyfinClientInfoMainDeps: {} as never,
waitForMpvConnectedMainDeps: {} as never,
launchMpvIdleForJellyfinPlaybackMainDeps: {} as never,
ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never,
preloadJellyfinExternalSubtitlesMainDeps: {} as never,
playJellyfinItemInMpvMainDeps: {} as never,
remoteComposerOptions: {} as never,
handleJellyfinAuthCommandsMainDeps: {} as never,
handleJellyfinListCommandsMainDeps: {} as never,
handleJellyfinPlayCommandMainDeps: {} as never,
handleJellyfinRemoteAnnounceCommandMainDeps: {} as never,
startJellyfinRemoteSessionMainDeps: {} as never,
stopJellyfinRemoteSessionMainDeps: {} as never,
runJellyfinCommandMainDeps: {} as never,
maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never,
openJellyfinSetupWindowMainDeps: {} as never,
},
anilistSetupDeps: {
notifyDeps: {} as never,
consumeTokenDeps: {} as never,
handleProtocolDeps: {} as never,
registerProtocolClientDeps: {} as never,
},
buildOpenAnilistSetupWindowMainDeps: {
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => null as never,
buildAuthorizeUrl: () => 'https://example.test',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => Promise.resolve(),
redirectUri: 'https://example.test/callback',
developerSettingsUrl: 'https://example.test/dev',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
},
anilistTrackingDeps: {
refreshClientSecretMainDeps: {} as never,
getCurrentMediaKeyMainDeps: {} as never,
resetMediaTrackingMainDeps: {} as never,
getMediaGuessRuntimeStateMainDeps: {} as never,
setMediaGuessRuntimeStateMainDeps: {} as never,
resetMediaGuessStateMainDeps: {} as never,
maybeProbeDurationMainDeps: {} as never,
ensureMediaGuessMainDeps: {} as never,
processNextRetryUpdateMainDeps: {} as never,
maybeRunPostWatchUpdateMainDeps: {} as never,
},
statsStartupRuntimeDeps: {
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
},
runStatsCliCommandDeps: {
getResolvedConfig: () => ({}) as never,
ensureImmersionTrackerStarted: () => {},
ensureVocabularyCleanupTokenizerReady: async () => {},
getImmersionTracker: () => null,
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: () => Promise.resolve(),
writeResponse: () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
appReadyRuntimeDeps: {
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
},
mpvRuntimeDeps: {
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class FakeMpvClient {
connected = true;
on(): void {}
connect(): void {}
} as never,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => ({
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
}),
setCurrentMetrics: () => {},
applyPatch: (current: any) => ({ next: current, changed: false }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({}),
tokenizeSubtitle: async () => ({ text: '' }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({}) as never,
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
},
trayRuntimeDeps: {
resolveTrayIconPathDeps: {} as never,
buildTrayMenuTemplateDeps: {} as never,
ensureTrayDeps: {} as never,
destroyTrayDeps: {} as never,
buildMenuFromTemplate: () => ({}) as never,
},
yomitanProfilePolicyDeps: {
externalProfilePath: '',
logInfo: () => {},
},
yomitanExtensionRuntimeDeps: {
loadYomitanExtensionCore: async () => null,
userDataPath: '/tmp',
externalProfilePath: '',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: () => {},
getYomitanExtension: () => null,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
},
yomitanSettingsRuntimeDeps: {
ensureYomitanExtensionLoaded: async () => {},
getYomitanSession: () => null,
openYomitanSettingsWindow: () => {},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
},
createOverlayRuntimeBootstrapHandlers: () => ({
initializeOverlayRuntime: () => {},
}),
initializeOverlayRuntimeMainDeps: {},
initializeOverlayRuntimeBootstrapDeps: {},
});
assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function');
assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function');
assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function');
assert.equal(typeof runtimes.openAnilistSetupWindow, 'function');
assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function');
assert.equal(typeof runtimes.runStatsCliCommand, 'function');
assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function');
assert.equal(typeof runtimes.initializeOverlayRuntime, 'function');
});

127
src/main/boot/runtimes.ts Normal file
View File

@@ -0,0 +1,127 @@
import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window';
import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command';
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import {
createBuildOpenAnilistSetupWindowMainDepsHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
} from '../runtime/domains/anilist';
import {
createTrayRuntimeHandlers,
createYomitanExtensionRuntime,
createYomitanSettingsRuntime,
} from '../runtime/domains/overlay';
import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
composeAppReadyRuntime,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayVisibilityRuntime,
composeStatsStartupRuntime,
} from '../runtime/composers';
export interface MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData> {
overlayVisibilityRuntimeDeps: Parameters<typeof composeOverlayVisibilityRuntime>[0];
jellyfinRuntimeHandlerDeps: Parameters<typeof composeJellyfinRuntimeHandlers>[0];
anilistSetupDeps: Parameters<typeof composeAnilistSetupHandlers>[0];
buildOpenAnilistSetupWindowMainDeps: Parameters<
typeof createBuildOpenAnilistSetupWindowMainDepsHandler
>[0];
anilistTrackingDeps: Parameters<typeof composeAnilistTrackingHandlers>[0];
statsStartupRuntimeDeps: Parameters<typeof composeStatsStartupRuntime>[0];
runStatsCliCommandDeps: Parameters<typeof createRunStatsCliCommandHandler>[0];
appReadyRuntimeDeps: Parameters<typeof composeAppReadyRuntime>[0];
mpvRuntimeDeps: any;
trayRuntimeDeps: Parameters<typeof createTrayRuntimeHandlers>[0];
yomitanProfilePolicyDeps: Parameters<typeof createYomitanProfilePolicy>[0];
yomitanExtensionRuntimeDeps: Parameters<typeof createYomitanExtensionRuntime>[0];
yomitanSettingsRuntimeDeps: Parameters<typeof createYomitanSettingsRuntime>[0];
createOverlayRuntimeBootstrapHandlers: (params: {
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}) => {
initializeOverlayRuntime: () => void;
};
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}
export function createMainBootRuntimes<
TBrowserWindow,
TMpvClient,
TTokenizerDeps,
TSubtitleData,
>(
params: MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData>,
) {
const overlayVisibilityComposer = composeOverlayVisibilityRuntime(
params.overlayVisibilityRuntimeDeps,
);
const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers(
params.jellyfinRuntimeHandlerDeps,
);
const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps);
const buildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps);
const maybeFocusExistingAnilistSetupWindow =
params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow;
const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps);
const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps);
const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps);
const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps);
const mpvRuntimeHandlers = composeMpvRuntimeHandlers<any, any, any>(
params.mpvRuntimeDeps as any,
);
const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps);
const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps);
const yomitanExtensionRuntime = createYomitanExtensionRuntime(
params.yomitanExtensionRuntimeDeps,
);
const yomitanSettingsRuntime = createYomitanSettingsRuntime(
params.yomitanSettingsRuntimeDeps,
);
const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps,
initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps,
});
return {
overlayVisibilityComposer,
jellyfinRuntimeHandlers,
anilistSetupHandlers,
maybeFocusExistingAnilistSetupWindow,
buildOpenAnilistSetupWindowMainDepsHandler,
openAnilistSetupWindow: () =>
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(),
anilistTrackingHandlers,
statsStartupRuntime,
runStatsCliCommand,
appReadyRuntime,
mpvRuntimeHandlers,
trayRuntimeHandlers,
yomitanProfilePolicy,
yomitanExtensionRuntime,
yomitanSettingsRuntime,
initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime,
openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler,
};
}
export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime;
export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers;
export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers;
export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers;
export const composeBootStatsStartupRuntime = composeStatsStartupRuntime;
export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler;
export const composeBootAppReadyRuntime = composeAppReadyRuntime;
export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers;
export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers;
export const createBootYomitanProfilePolicy = createYomitanProfilePolicy;
export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime;
export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime;
export const createBootMaybeFocusExistingAnilistSetupWindowHandler =
createMaybeFocusExistingAnilistSetupWindowHandler;
export const createBootBuildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler;
export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler;

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootServices } from './services';
test('createMainBootServices builds boot-phase service bundle', () => {
const calls: string[] = [];
let setPathValue: string | null = null;
const services = createMainBootServices({
platform: 'linux',
argv: ['node', 'main.ts'],
appDataDir: undefined,
xdgConfigHome: undefined,
homeDir: '/home/tester',
defaultMpvLogFile: '/tmp/default.log',
envMpvLog: ' /tmp/custom.log ',
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/subminer.sock',
resolveConfigDir: () => '/tmp/subminer-config',
existsSync: () => false,
mkdirSync: (targetPath) => {
calls.push(`mkdir:${targetPath}`);
},
joinPath: (...parts) => parts.join('/'),
app: {
setPath: (_name, value) => {
setPathValue = value;
},
quit: () => {},
on: () => ({}),
whenReady: async () => {},
},
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => {},
onConfigStartupParseError: () => {
throw new Error('unexpected parse failure');
},
createConfigService: (configDir) => ({ configDir }),
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: () => ({ kind: 'ws' }),
createLogger: (scope) =>
({
scope,
warn: () => {},
info: () => {},
error: () => {},
}) as const,
createMainRuntimeRegistry: () => ({ registry: true }),
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true }),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
createOverlayModalRuntime: () => ({ modalRuntime: true }),
createAppState: (input) => ({ ...input }),
});
assert.equal(services.configDir, '/tmp/subminer-config');
assert.equal(services.userDataPath, '/tmp/subminer-config');
assert.equal(services.defaultMpvLogPath, '/tmp/custom.log');
assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite');
assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' });
assert.deepEqual(services.anilistTokenStore, {
targetPath: '/tmp/subminer-config/anilist-token-store.json',
});
assert.deepEqual(services.jellyfinTokenStore, {
targetPath: '/tmp/subminer-config/jellyfin-token-store.json',
});
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
});
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
assert.equal(setPathValue, '/tmp/subminer-config');
});

262
src/main/boot/services.ts Normal file
View File

@@ -0,0 +1,262 @@
import { ConfigStartupParseError } from '../../config';
export interface MainBootServicesParams<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
> {
platform: NodeJS.Platform;
argv: string[];
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
defaultMpvLogFile: string;
envMpvLog: string | undefined;
defaultTexthookerPort: number;
getDefaultSocketPath: () => string;
resolveConfigDir: (input: {
platform: NodeJS.Platform;
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
existsSync: (targetPath: string) => boolean;
}) => string;
existsSync: (targetPath: string) => boolean;
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
joinPath: (...parts: string[]) => string;
app: {
setPath: (name: string, value: string) => void;
quit: () => void;
on: (...args: any[]) => unknown;
whenReady: () => Promise<void>;
};
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: () => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
error: (message: string, details?: unknown) => void;
};
createMainRuntimeRegistry: () => TRuntimeRegistry;
createOverlayManager: () => TOverlayManager;
createOverlayModalInputState: (params: any) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: {
logger: TLogger;
}) => TOverlayContentMeasurementStore;
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
getSyncOverlayVisibilityForModal: () => () => void;
createOverlayModalRuntime: (params: {
overlayManager: TOverlayManager;
overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime;
createAppState: (input: {
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
}
export interface MainBootServicesResult<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
> {
configDir: string;
userDataPath: string;
defaultMpvLogPath: string;
defaultImmersionDbPath: string;
configService: TConfigService;
anilistTokenStore: TAnilistTokenStore;
jellyfinTokenStore: TJellyfinTokenStore;
anilistUpdateQueue: TAnilistUpdateQueue;
subtitleWsService: TSubtitleWebSocket;
annotationSubtitleWsService: TSubtitleWebSocket;
logger: TLogger;
runtimeRegistry: TRuntimeRegistry;
overlayManager: TOverlayManager;
overlayModalInputState: TOverlayModalInputState;
overlayContentMeasurementStore: TOverlayContentMeasurementStore;
overlayModalRuntime: TOverlayModalRuntime;
appState: TAppState;
appLifecycleApp: TAppLifecycleApp;
}
export function createMainBootServices<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => unknown },
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
>(
params: MainBootServicesParams<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp
>,
): MainBootServicesResult<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp
> {
const configDir = params.resolveConfigDir({
platform: params.platform,
appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome,
homeDir: params.homeDir,
existsSync: params.existsSync,
});
const userDataPath = configDir;
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
const configService = (() => {
try {
return params.createConfigService(configDir);
} catch (error) {
if (error instanceof ConfigStartupParseError) {
params.onConfigStartupParseError(error);
}
throw error;
}
})();
const anilistTokenStore = params.createAnilistTokenStore(
params.joinPath(userDataPath, 'anilist-token-store.json'),
);
const jellyfinTokenStore = params.createJellyfinTokenStore(
params.joinPath(userDataPath, 'jellyfin-token-store.json'),
);
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
const subtitleWsService = params.createSubtitleWebSocket();
const annotationSubtitleWsService = params.createSubtitleWebSocket();
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
const overlayModalInputState = params.createOverlayModalInputState({
getModalWindow: () => overlayManager.getModalWindow(),
syncOverlayShortcutsForModal: (isActive: boolean) => {
params.getSyncOverlayShortcutsForModal()(isActive);
},
syncOverlayVisibilityForModal: () => {
params.getSyncOverlayVisibilityForModal()();
},
});
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
logger,
});
const overlayModalRuntime = params.createOverlayModalRuntime({
overlayManager,
overlayModalInputState,
onModalStateChange: (isActive: boolean) =>
(overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void })
.handleModalInputStateChange?.(isActive),
});
const appState = params.createAppState({
mpvSocketPath: params.getDefaultSocketPath(),
texthookerPort: params.defaultTexthookerPort,
});
if (!params.existsSync(userDataPath)) {
params.mkdirSync(userDataPath, { recursive: true });
}
params.app.setPath('userData', userDataPath);
const appLifecycleApp = {
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock()
? true
: params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
params.registerSecondInstanceHandlerEarly(
listener as (_event: unknown, argv: string[]) => void,
);
return params.app;
}
params.app.on(event, listener);
return params.app;
},
whenReady: () => params.app.whenReady(),
} as TAppLifecycleApp;
return {
configDir,
userDataPath,
defaultMpvLogPath,
defaultImmersionDbPath,
configService,
anilistTokenStore,
jellyfinTokenStore,
anilistUpdateQueue,
subtitleWsService,
annotationSubtitleWsService,
logger,
runtimeRegistry,
overlayManager,
overlayModalInputState,
overlayContentMeasurementStore,
overlayModalRuntime,
appState,
appLifecycleApp,
};
}