Files
SubMiner/src/main/assemblies/startup.ts
T
sudacode f89f78b861 refactor(main): move plugins, session actions and subtitle generation into assemblies (#327)
* refactor(main): build overlay geometry and pointer interaction in the overlay assembly

Inject Electron's screen (and the stats-window promotion) into the overlay geometry and
visible-overlay interaction runtimes so they load under bun test, then construct both in
createOverlayAssembly together with the overlay visibility composer and the mpv subtitle
suppression handlers. Replace the three regex tests that pinned the moved Linux input and
shape code with behaviour tests on the runtimes.

* refactor(main): move the autoplay-ready gate into the playback assembly

The autoplay-ready gate, its post-warm subtitle prime, the current-media tokenization gate and
managed local subtitle selection all read playback state, and playback signals through the
gate, so createPlaybackAssembly now builds them. The overlay reaches the gate through a getter.

Replace the last four regex guards with behaviour tests: the gate holds the release until the
visible overlay has measured the line and ignores untokenized lines during warmup (playback),
cached autoplay priming never paints raw first and the sidebar media path is tagged only after
prefetch loads (priming runtime), and dashboard mining overrides the Hachidori Anki server only
when the proxy is on (stats server runtime). main-wiring.test.ts is now empty and removed.

* refactor(main): move the plugin host, panel and windows into a plugins assembly

* refactor(main): move session action dispatch and renderer mpv commands into an assembly

createSessionActionsAssembly owns the session action dispatcher, the secondary-subtitle mode cycle, queueing the clipboard's video, and the mpv commands the overlay renderer sends. The runtime option cycle both paths used is now one function.

* refactor(main): move Whisper subtitle generation into an assembly

* refactor(main): resolve subtitle annotation options in a runtime module

The runtime-option-over-config resolution that the texthooker page and the MeCab startup decision share moves out of main.ts into createAnnotationOptions.

* test(main): cover a non-boolean runtime annotation option value

* docs: describe the plugins, session actions and subtitle generation assemblies

Update ownership for overlay geometry and pointer interaction, the autoplay-ready gate, and annotation options; point the overlay priming entry points at their new modules; the main-wiring regex debt is gone.

* build: export MpvClientEvents so declaration emit can name the plugins assembly's return type
2026-11-04 21:54:49 -08:00

508 lines
20 KiB
TypeScript

import path from 'node:path';
import { AnkiIntegration } from '../../anki-integration';
import { isHeadlessInitialCommand } from '../../cli/args';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate } from '../../config';
import type { ConfigService } from '../../config/service';
import {
initializeOverlayRuntime as initializeOverlayRuntimeCore,
type createOverlayManager,
type SubtitleWebSocket,
type Texthooker,
} from '../../core/services';
import { resolveKeybindings, showDesktopNotification } from '../../core/utils';
import type { Logger } from '../../logger';
import { RuntimeOptionsManager } from '../../runtime-options';
import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from '../../shared/setup-state';
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
import type { SecondarySubMode, WindowGeometry } from '../../types';
import { mergeAiConfig } from '../../ai/config';
import type { OverlayModalRuntime } from '../overlay-runtime';
import { composeAppReadyRuntime, type AppReadyComposerOptions } from '../runtime/composers';
import type { createConfigDerivedRuntime } from '../runtime/domains/startup';
import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers';
import {
getStartupModeFlags,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload,
} from '../runtime/startup-mode-flags';
import type { AppState } from '../state';
import type { AnilistAssembly } from './anilist';
import type { FirstRunSetupAssembly } from './first-run-setup';
import type { JellyfinAssembly } from './jellyfin';
import type { MiningAssembly } from './mining';
import type { OverlayAssembly } from './overlay';
import type { PlaybackAssembly } from './playback';
import type { SettingsAssembly } from './settings';
import type { ShortcutsAssembly } from './shortcuts';
import type { StatsAssembly } from './stats';
import type { YomitanAssembly } from './yomitan';
import type { YoutubeAssembly } from './youtube';
type AppReadyMainDeps = AppReadyComposerOptions['appReadyRuntimeMainDeps'];
type OverlayManager = ReturnType<typeof createOverlayManager>;
type FatalErrorReporter = (error: unknown, options: { title: string; context: string }) => void;
/**
* What startup needs from the rest of the app. It touches nearly every domain, so most deps are
* sibling assemblies; those built after this one are reached through getters.
*/
export interface StartupAssemblyDeps {
appState: AppState;
configService: Pick<
ConfigService,
'getConfig' | 'getConfigPath' | 'getWarnings' | 'reloadConfigStrict'
>;
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
logConfigWarning: AppReadyMainDeps['logConfigWarning'];
configDir: string;
userDataPath: string;
defaultTexthookerPort: number;
/** Electron's `dialog.showErrorBox`, injected so the assembly loads under `bun test`. */
showErrorBox: (title: string, content: string) => void;
reportFatalError: FatalErrorReporter;
requestAppQuit: () => void;
logging: Pick<AppReadyMainDeps, 'setLogLevel' | 'setLogRotation' | 'setLogFileToggles'>;
overlayManager: Pick<OverlayManager, 'getVisibleOverlayVisible' | 'getOverlayWindows'>;
overlayModalRuntime: Pick<OverlayModalRuntime, 'primeModalWindow'>;
subtitleWsService: Pick<SubtitleWebSocket, 'start'>;
annotationSubtitleWsService: Pick<SubtitleWebSocket, 'start'>;
texthookerService: Pick<Texthooker, 'isRunning' | 'start'>;
configDerivedRuntime: Pick<
ReturnType<typeof createConfigDerivedRuntime>,
'shouldAutoInitializeOverlayRuntimeFromConfig'
>;
hasMpvWebsocketPlugin: () => boolean;
loadSubtitlePosition: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
yomitan: Pick<YomitanAssembly, 'loadExtension' | 'ensureExtensionLoaded'>;
stats: Pick<StatsAssembly, 'ensureImmersionTrackerStarted' | 'immersionTrackerStartupDeps'>;
firstRunSetup: Pick<FirstRunSetupAssembly, 'service' | 'openWindow'>;
youtube: Pick<
YoutubeAssembly,
| 'getCachedMediaPathForCurrentPlayback'
| 'shouldRequireMediaCacheForCurrentPlayback'
| 'getCurrentMediaCacheSourceUrl'
>;
overlay: Pick<
OverlayAssembly,
| 'overlayVisibilityRuntime'
| 'createMainWindow'
| 'setVisibleOverlayVisible'
| 'ensureOverlayWindowsReadyForVisibilityActions'
| 'syncOverlayMpvSubtitleSuppression'
> & {
visibilityComposer: Pick<
OverlayAssembly['visibilityComposer'],
'broadcastRuntimeOptionsChanged'
>;
interaction: Pick<
OverlayAssembly['interaction'],
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
>;
geometry: Pick<OverlayAssembly['geometry'], 'updateVisibleOverlayBounds'>;
};
shortcuts: Pick<
ShortcutsAssembly,
| 'overlayShortcutsRuntime'
| 'refreshCurrentSessionBindings'
| 'registerGlobalShortcuts'
| 'refreshOverlayShortcuts'
>;
settings: { hotReload: Pick<SettingsAssembly['hotReload'], 'start'> };
mining: Pick<MiningAssembly, 'attachToAnkiIntegration' | 'createFieldGroupingCallback'>;
getAnilist: () => Pick<AnilistAssembly, 'refreshClientSecretStateIfEnabled'>;
getJellyfin: () => Pick<JellyfinAssembly, 'startJellyfinRemoteSession'>;
getPlayback: () => Pick<
PlaybackAssembly,
| 'createMpvClient'
| 'createMecabTokenizerAndCheck'
| 'prewarmSubtitleDictionaries'
| 'startBackgroundWarmups'
| 'subtitleProcessingController'
| 'getSubtitlePrefetchService'
| 'getLastObservedTimePos'
>;
getPluginHost: () => { loadAll: () => Promise<unknown> };
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showOverlayNotification' | 'dismissOverlayNotification'
>;
/** Runs the CLI command the app was launched with. */
handleInitialArgs: () => void;
}
export type StartupAssembly = ReturnType<typeof createStartupAssembly>;
/**
* Owns how the app starts: the app-ready sequence, the headless known-word refresh, and bringing
* up the overlay runtime and its prerequisites on demand (CLI commands, YouTube playback, the
* tray). `main.ts` hands `runAppReady` to the app lifecycle.
*/
export function createStartupAssembly(deps: StartupAssemblyDeps) {
const { appState, configService, logger } = deps;
const isHeadlessLaunch = (): boolean =>
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs));
const knownWordCacheStatePath = path.join(deps.userDataPath, 'known-words-cache.json');
function createRuntimeOptionsManager(): RuntimeOptionsManager {
return new RuntimeOptionsManager(() => configService.getConfig().ankiConnect, {
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
const playback = deps.getPlayback();
playback.subtitleProcessingController.invalidateTokenizationCache();
playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos());
deps.overlay.visibilityComposer.broadcastRuntimeOptionsChanged();
deps.shortcuts.refreshOverlayShortcuts();
},
});
}
function resolveSessionKeybindings(): void {
appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS);
deps.shortcuts.refreshCurrentSessionBindings();
}
/** What the subtitle websockets send a client that connects mid-line. */
function getCurrentSubtitleForWebsocket() {
return (
appState.currentSubtitleData ??
(appState.currentSubText
? {
text: appState.currentSubText,
tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
}
: null)
);
}
function getFrequencyOptionsForWebsocket() {
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
return {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
}
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: {
appState,
overlayManager: {
getVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () =>
deps.overlay.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
},
refreshCurrentSubtitle: () => {
deps
.getPlayback()
.subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => deps.shortcuts.overlayShortcutsRuntime.syncOverlayShortcuts(),
},
createMainWindow: () => {
if (isHeadlessLaunch()) {
return;
}
deps.overlay.createMainWindow();
},
registerGlobalShortcuts: () => {
if (isHeadlessLaunch()) {
return;
}
deps.shortcuts.registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) =>
deps.overlay.interaction.createOverlayWindowTracker(override, targetMpvSocketPath),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.overlay.geometry.updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => deps.overlay.interaction.bindVisibleOverlayOwner(),
releaseOverlayOwner: () => deps.overlay.interaction.releaseVisibleOverlayOwner(),
getOverlayWindows: () => deps.overlayManager.getOverlayWindows(),
getResolvedConfig: () => configService.getConfig(),
showDesktopNotification,
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
deps.getOverlayNotifications().dismissOverlayNotification(id),
createFieldGroupingCallback: () => deps.mining.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => knownWordCacheStatePath,
getCachedMediaPath: (currentVideoPath, kind) =>
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () =>
deps.youtube.shouldRequireMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => deps.youtube.getCurrentMediaCacheSourceUrl(),
shouldStartAnkiIntegration: () => !isHeadlessLaunch(),
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore,
setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => {
if (isHeadlessLaunch()) {
return;
}
deps.getPlayback().startBackgroundWarmups();
},
},
});
/** Creates the overlay windows and runtime once, then wires the Anki integration it built. */
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!isHeadlessLaunch()) {
deps.overlayModalRuntime.primeModalWindow();
}
if (appState.ankiIntegration) {
deps.mining.attachToAnkiIntegration(appState.ankiIntegration);
}
deps.overlay.syncOverlayMpvSubtitleSuppression();
}
/** Creates whatever overlay commands need that app-ready skipped on a deferred start. */
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
deps.loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
resolveSessionKeybindings();
} else if (!appState.sessionBindingsInitialized) {
deps.shortcuts.refreshCurrentSessionBindings();
}
if (!appState.mpvClient) {
appState.mpvClient = deps.getPlayback().createMpvClient();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = createRuntimeOptionsManager();
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
}
async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayStartupPrereqs();
await deps.yomitan.ensureExtensionLoaded();
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
return;
}
deps.overlay.ensureOverlayWindowsReadyForVisibilityActions();
}
/** `--refresh-known-words` without the overlay: refresh the cache, then quit. */
async function runHeadlessInitialCommand(): Promise<void> {
if (!appState.initialArgs?.refreshKnownWords) {
deps.handleInitialArgs();
return;
}
const resolvedConfig = configService.getConfig();
if (resolvedConfig.ankiConnect.enabled !== true) {
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
process.exitCode = 1;
deps.requestAppQuit();
return;
}
const effectiveAnkiConfig =
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig,
new SubtitleTimingTracker(),
{ send: () => undefined } as never,
undefined,
undefined,
async () => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: false,
cancelled: true,
}),
knownWordCacheStatePath,
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
);
try {
await integration.refreshKnownWordCache();
} catch (error) {
logger.error('Headless known-word refresh failed:', error);
process.exitCode = 1;
} finally {
integration.stop();
deps.requestAppQuit();
}
}
const failHandlers = {
logError: (details: string) => logger.error(details),
showErrorBox: (title: string, details: string) => deps.showErrorBox(title, details),
quit: () => deps.requestAppQuit(),
};
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarning: (message) => logger.warn(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => deps.settings.hotReload.start(),
shouldRefreshAnilistClientSecretState: () =>
shouldRefreshAnilistOnConfigReload(appState.initialArgs),
refreshAnilistClientSecretState: (options) =>
deps.getAnilist().refreshClientSecretStateIfEnabled(options),
failHandlers,
},
criticalConfigErrorMainDeps: {
getConfigPath: () => configService.getConfigPath(),
failHandlers,
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {
ensureDefaultConfigBootstrap({
configDir: deps.configDir,
configFilePaths: getDefaultConfigFilePaths(deps.configDir),
generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG),
});
},
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
resolveKeybindings: () => resolveSessionKeybindings(),
createMpvClient: () => {
appState.mpvClient = deps.getPlayback().createMpvClient();
},
startPlugins: () => {
void deps.getPluginHost().loadAll();
},
getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => deps.logConfigWarning(warning),
...deps.logging,
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = createRuntimeOptionsManager();
},
setSecondarySubMode: (mode: SecondarySubMode) => {
deps.setSecondarySubMode(mode);
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
defaultTexthookerPort: deps.defaultTexthookerPort,
hasMpvWebsocketPlugin: () => deps.hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
deps.subtitleWsService.start(
port,
getCurrentSubtitleForWebsocket,
getFrequencyOptionsForWebsocket,
);
},
startAnnotationWebsocket: (port: number) => {
deps.annotationSubtitleWsService.start(
port,
getCurrentSubtitleForWebsocket,
getFrequencyOptionsForWebsocket,
);
},
startTexthooker: (port: number, websocketUrl?: string) => {
if (!deps.texthookerService.isRunning()) {
deps.texthookerService.start(port, websocketUrl);
}
},
log: (message) => logger.info(message),
createMecabTokenizerAndCheck: async () => {
await deps.getPlayback().createMecabTokenizerAndCheck();
},
createSubtitleTimingTracker: () => {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
},
loadYomitanExtension: async () => {
await deps.yomitan.loadExtension();
},
ensureYomitanExtensionLoaded: async () => {
await deps.yomitan.ensureExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await deps.firstRunSetup.service.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
const args = appState.initialArgs;
if (args && shouldAutoOpenFirstRunSetup(args)) {
const force = Boolean(args.setup);
if (force || snapshot.state.status !== 'completed') {
deps.firstRunSetup.openWindow(force);
}
}
},
startJellyfinRemoteSession: async () => {
await deps.getJellyfin().startJellyfinRemoteSession();
},
prewarmSubtitleDictionaries: async () => {
await deps.getPlayback().prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
deps.getPlayback().startBackgroundWarmups();
},
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode
? false
: deps.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible: boolean) =>
deps.overlay.setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
handleInitialArgs: () => deps.handleInitialArgs(),
shouldRunHeadlessInitialCommand: () => isHeadlessLaunch(),
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
createImmersionTracker: () => {
deps.stats.ensureImmersionTrackerStarted();
},
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: deps.stats.immersionTrackerStartupDeps,
});
/** The app-ready sequence; a failure is reported as fatal and quits the app. */
async function runAppReady(): Promise<void> {
try {
await appReadyRuntimeRunner();
} catch (error) {
deps.reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed during app-ready startup.',
});
process.exitCode = 1;
deps.requestAppQuit();
}
}
return {
runAppReady,
runHeadlessInitialCommand,
initializeOverlayRuntime,
ensureOverlayStartupPrereqs,
ensureYoutubePlaybackRuntimeReady,
};
}