refactor: split main.ts into domain runtimes

This commit is contained in:
2026-03-31 23:48:14 -07:00
parent 3502cdc607
commit 983f3b38ee
84 changed files with 15591 additions and 4251 deletions

View File

@@ -0,0 +1,36 @@
---
id: TASK-238.8
title: Refactor src/main.ts composition root into domain runtimes
status: To Do
assignee: []
created_date: '2026-03-31 06:28'
labels:
- tech-debt
- runtime
- maintainability
- composition-root
dependencies: []
references:
- src/main.ts
- src/main/boot/services
- src/main/runtime/composers
- docs/architecture/README.md
parent_task_id: TASK-238
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Refactor `src/main.ts` so it becomes a thin composition root and the domain-specific runtime wiring moves into short wrapper modules under `src/main/`. Preserve all current behavior, IPC contracts, and config/schema semantics while reducing the entrypoint to boot services, grouped runtime instantiation, startup execution, and process-level quit handling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `src/main.ts` is bootstrap/composition only: platform preflight, boot services, runtime creation, startup execution, and top-level quit/signal handling.
- [ ] #2 `src/main.ts` no longer imports `src/main/runtime/*-main-deps.ts` directly.
- [ ] #3 `src/main.ts` has no local names like `build*MainDepsHandler`, `*MainDeps`, or trivial `*Handler` pass-through wrappers.
- [ ] #4 New wrapper files stay under ~500 LOC each; if one exceeds that, split before merge.
- [ ] #5 Cross-domain coordination stays in `main.ts`; wrapper modules stay acyclic and communicate via injected callbacks.
- [ ] #6 No user-facing behavior, config fields, or IPC channel names change.
<!-- AC:END -->

View File

@@ -0,0 +1,27 @@
---
id: TASK-262
title: Create overlay UI bootstrap input helper
status: To Do
assignee: []
created_date: '2026-03-31 17:04'
labels:
- refactor
- main
- overlay-ui
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a coarse input-builder/helper module to reduce the large createOverlayUiRuntime(...) callsite in src/main.ts without changing runtime behavior. Do not edit src/main.ts in this task.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 New helper module(s) live under src/main/
- [ ] #2 Helper accepts grouped overlay UI/domain inputs instead of giant inline literals
- [ ] #3 Helper keeps files under 500 LOC
- [ ] #4 Optional focused tests added if useful
- [ ] #5 No runtime behavior changes
<!-- AC:END -->

View File

@@ -0,0 +1,28 @@
---
id: TASK-263
title: Create coarse startup bootstrap wrapper
status: To Do
assignee: []
created_date: '2026-03-31 17:21'
labels:
- refactor
- main
- startup
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Move the large createMainStartupRuntime construction and its self-reference handling out of src/main.ts into a coarse startup bootstrap wrapper. Keep behavior identical and shrink the startup section materially.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 New wrapper module(s) live under src/main/
- [ ] #2 Wrapper owns createMainStartupRuntime construction and self-reference handling
- [ ] #3 src/main.ts startup section becomes materially smaller
- [ ] #4 Files stay under 500 LOC
- [ ] #5 Focused tests cover the wrapper if useful
- [ ] #6 No runtime behavior changes
<!-- AC:END -->

View File

@@ -0,0 +1,6 @@
type: internal
area: main
- Split `src/main.ts` into domain runtime wrappers and startup sequencing helpers.
- Removed the last direct `src/main/runtime/*-main-deps.ts` imports from `src/main.ts`.
- Kept startup behavior and IPC contracts stable while reducing composition-root size.

View File

@@ -3,7 +3,7 @@
# Architecture Map
Status: active
Last verified: 2026-03-26
Last verified: 2026-03-31
Owner: Kyle Yasuda
Read when: runtime ownership, composition boundaries, or layering questions
@@ -24,6 +24,27 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
## Current Shape
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/*.ts` wrapper runtimes sit between `src/main.ts` and `src/main/runtime/**`
so the composition root stays thin while exported `createBuild*MainDepsHandler`
APIs remain internal plumbing. Key domain runtimes:
- `anilist-runtime` AniList token management, media tracking, retry queue
- `cli-startup-runtime` CLI command dispatch and initial-args handling
- `discord-presence-lifecycle-runtime` Discord Rich Presence lifecycle
- `first-run-runtime` first-run setup wizard
- `ipc-runtime` IPC handler registration and composition
- `jellyfin-runtime` Jellyfin session, playback, mpv orchestration
- `main-startup-runtime` top-level startup orchestration (app-ready → CLI → headless)
- `main-startup-bootstrap` wiring helper that builds startup runtime inputs
- `mining-runtime` Anki card mining actions
- `mpv-runtime` mpv client lifecycle
- `overlay-ui-runtime` overlay window management, visibility, tray
- `overlay-geometry-runtime` overlay bounds resolution
- `shortcuts-runtime` global shortcut registration
- `startup-sequence-runtime` headless known-word refresh and deferred startup sequencing
- `stats-runtime` immersion tracker, stats server, stats CLI
- `subtitle-runtime` subtitle prefetch, tokenization, caching
- `youtube-runtime` YouTube playback flow
- `yomitan-runtime` Yomitan extension loading and settings
- `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/renderer/` owns overlay rendering and input behavior.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
import path from 'node:path';
import { app, BrowserWindow, shell } from 'electron';
import { DEFAULT_MIN_WATCH_RATIO } from '../shared/watch-threshold';
import type { ResolvedConfig } from '../types';
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
} from '../core/services/anilist/anilist-updater';
import type { AnilistSetupWindowLike } from './anilist-runtime';
import { createAnilistRuntime } from './anilist-runtime';
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from './anilist-url-guard';
export interface AnilistRuntimeCoordinatorInput {
getResolvedConfig: () => ResolvedConfig;
isTrackingEnabled: (config: ResolvedConfig) => boolean;
tokenStore: Parameters<typeof createAnilistRuntime>[0]['tokenStore'];
updateQueue: Parameters<typeof createAnilistRuntime>[0]['updateQueue'];
appState: {
currentMediaPath: string | null;
currentMediaTitle: string | null;
mpvClient: {
currentTimePos?: number | null;
requestProperty: (name: string) => Promise<unknown>;
} | null;
anilistSetupWindow: BrowserWindow | null;
};
dictionarySupport: {
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
};
actions: {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
};
logger: {
info: (message: string) => void;
warn: (message: string, details?: unknown) => void;
error: (message: string, error?: unknown) => void;
debug: (message: string, details?: unknown) => void;
};
constants: {
authorizeUrl: string;
clientId: string;
responseType: string;
redirectUri: string;
developerSettingsUrl: string;
durationRetryIntervalMs: number;
minWatchSeconds: number;
maxAttemptedUpdateKeys: number;
};
}
export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinatorInput) {
return createAnilistRuntime<ResolvedConfig, AnilistSetupWindowLike>({
getResolvedConfig: () => input.getResolvedConfig(),
isTrackingEnabled: (config) => input.isTrackingEnabled(config),
tokenStore: input.tokenStore,
updateQueue: input.updateQueue,
getCurrentMediaPath: () => input.appState.currentMediaPath,
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
getWatchedSeconds: () => input.appState.mpvClient?.currentTimePos ?? Number.NaN,
hasMpvClient: () => Boolean(input.appState.mpvClient),
requestMpvDuration: async () => input.appState.mpvClient?.requestProperty('duration'),
resolveMediaPathForJimaku: (currentMediaPath) =>
input.dictionarySupport.resolveMediaPathForJimaku(currentMediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode),
createBrowserWindow: (options) => {
const window = new BrowserWindow(options);
input.appState.anilistSetupWindow = window;
window.on('closed', () => {
input.appState.anilistSetupWindow = null;
});
return window as unknown as AnilistSetupWindowLike;
},
authorizeUrl: input.constants.authorizeUrl,
clientId: input.constants.clientId,
responseType: input.constants.responseType,
redirectUri: input.constants.redirectUri,
developerSettingsUrl: input.constants.developerSettingsUrl,
isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url),
isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url),
openExternal: (url) => shell.openExternal(url),
showMpvOsd: (message) => input.actions.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.actions.showDesktopNotification(title, options),
logInfo: (message) => input.logger.info(message),
logWarn: (message, details) => input.logger.warn(message, details),
logError: (message, error) => input.logger.error(message, error),
logDebug: (message, details) => input.logger.debug(message, details),
isDefaultApp: () => Boolean(process.defaultApp),
getArgv: () => process.argv,
execPath: process.execPath,
resolvePath: (value) => path.resolve(value),
setAsDefaultProtocolClient: (scheme, appPath, args) =>
appPath
? app.setAsDefaultProtocolClient(scheme, appPath, args)
: app.setAsDefaultProtocolClient(scheme),
now: () => Date.now(),
durationRetryIntervalMs: input.constants.durationRetryIntervalMs,
minWatchSeconds: input.constants.minWatchSeconds,
minWatchRatio: DEFAULT_MIN_WATCH_RATIO,
maxAttemptedUpdateKeys: input.constants.maxAttemptedUpdateKeys,
});
}

View File

@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAnilistRuntime } from './anilist-runtime';
function createSetupWindow() {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
let destroyed = false;
return {
window: {
focus: () => {},
close: () => {
destroyed = true;
for (const handler of handlers.get('closed') ?? []) {
handler();
}
},
isDestroyed: () => destroyed,
on: (event: 'closed', handler: () => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
loadURL: async () => {},
webContents: {
setWindowOpenHandler: () => ({ action: 'deny' as const }),
on: (event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
getURL: () => 'about:blank',
},
},
};
}
function createRuntime(overrides: Partial<Parameters<typeof createAnilistRuntime>[0]> = {}) {
const savedTokens: string[] = [];
const queueCalls: string[] = [];
const notifications: string[] = [];
const state = {
config: {
anilist: {
enabled: true,
accessToken: '',
},
},
};
const setup = createSetupWindow();
const runtime = createAnilistRuntime({
getResolvedConfig: () => state.config,
isTrackingEnabled: (config) => config.anilist.enabled === true,
tokenStore: {
saveToken: (token) => {
savedTokens.push(token);
},
loadToken: () => null,
clearToken: () => {
savedTokens.push('cleared');
},
},
updateQueue: {
enqueue: (key, title, episode) => {
queueCalls.push(`enqueue:${key}:${title}:${episode}`);
},
nextReady: () => ({
key: 'retry-1',
title: 'Demo',
episode: 2,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 0,
lastError: null,
}),
markSuccess: (key) => {
queueCalls.push(`success:${key}`);
},
markFailure: (key, message) => {
queueCalls.push(`failure:${key}:${message}`);
},
getSnapshot: () => ({
pending: 3,
ready: 1,
deadLetter: 2,
}),
},
getCurrentMediaPath: () => '/tmp/demo.mkv',
getCurrentMediaTitle: () => 'Demo',
getWatchedSeconds: () => 0,
hasMpvClient: () => false,
requestMpvDuration: async () => 120,
resolveMediaPathForJimaku: (value) => value,
guessAnilistMediaInfo: async () => null,
updateAnilistPostWatchProgress: async () => ({
status: 'updated',
message: 'updated ok',
}),
createBrowserWindow: () => setup.window,
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
clientId: '36084',
responseType: 'token',
redirectUri: 'https://anilist.subminer.moe/',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
openExternal: async () => {},
showMpvOsd: (message) => {
notifications.push(`osd:${message}`);
},
showDesktopNotification: (_title, options) => {
notifications.push(`notify:${options.body}`);
},
logInfo: (message) => {
notifications.push(`info:${message}`);
},
logWarn: () => {},
logError: () => {},
logDebug: () => {},
isDefaultApp: () => false,
getArgv: () => [],
execPath: process.execPath,
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => true,
now: () => 1234,
...overrides,
});
return {
runtime,
state,
savedTokens,
queueCalls,
notifications,
};
}
test('anilist runtime saves setup token and updates resolved state', () => {
const harness = createRuntime();
const consumed = harness.runtime.consumeAnilistSetupTokenFromUrl(
'subminer://anilist-setup?access_token=token-123',
);
assert.equal(consumed, true);
assert.deepEqual(harness.savedTokens, ['token-123']);
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'resolved');
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'stored');
assert.equal(harness.runtime.getStatusSnapshot().tokenMessage, 'saved token from AniList login');
assert.ok(harness.notifications.includes('notify:AniList login success'));
});
test('anilist runtime bypasses refresh when tracking disabled', async () => {
const harness = createRuntime();
harness.state.config = {
anilist: {
enabled: false,
accessToken: '',
},
};
const token = await harness.runtime.refreshAnilistClientSecretStateIfEnabled({
force: true,
});
assert.equal(token, null);
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'not_checked');
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'none');
});
test('anilist runtime refreshes queue snapshot and retry state after processing', async () => {
const harness = createRuntime({
tokenStore: {
saveToken: () => {},
loadToken: () => 'stored-token',
clearToken: () => {},
},
});
harness.runtime.refreshRetryQueueState();
assert.deepEqual(harness.runtime.getQueueStatusSnapshot(), {
pending: 3,
ready: 1,
deadLetter: 2,
lastAttemptAt: null,
lastError: null,
});
const result = await harness.runtime.processNextAnilistRetryUpdate();
assert.deepEqual(result, { ok: true, message: 'updated ok' });
assert.ok(harness.queueCalls.includes('success:retry-1'));
assert.equal(harness.runtime.getQueueStatusSnapshot().lastAttemptAt, 1234);
assert.equal(harness.runtime.getQueueStatusSnapshot().lastError, null);
});

495
src/main/anilist-runtime.ts Normal file
View File

@@ -0,0 +1,495 @@
import {
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistRetryQueueState,
createInitialAnilistSecretResolutionState,
createInitialAnilistUpdateInFlightState,
type AnilistMediaGuessRuntimeState,
type AnilistRetryQueueState,
type AnilistSecretResolutionState,
} from './state';
import { createAnilistStateRuntime } from './runtime/anilist-state';
import { composeAnilistSetupHandlers } from './runtime/composers/anilist-setup-composer';
import { composeAnilistTrackingHandlers } from './runtime/composers/anilist-tracking-composer';
import {
buildAnilistSetupUrl,
consumeAnilistSetupCallbackUrl,
loadAnilistManualTokenEntry,
openAnilistSetupInBrowser,
} from './runtime/anilist-setup';
import {
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
} from './runtime/anilist-setup-window';
import {
buildAnilistAttemptKey,
rememberAnilistAttemptedUpdateKey,
} from './runtime/anilist-post-watch';
import { createCreateAnilistSetupWindowHandler } from './runtime/setup-window-factory';
import type {
AnilistMediaGuess,
AnilistPostWatchUpdateResult,
} from '../core/services/anilist/anilist-updater';
import type { AnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
export interface AnilistSetupWindowLike {
focus: () => void;
close: () => void;
isDestroyed: () => boolean;
on: (event: 'closed', handler: () => void) => void;
loadURL: (url: string) => Promise<void> | void;
webContents: {
setWindowOpenHandler: (handler: (details: { url: string }) => { action: 'deny' }) => void;
on: (event: string, handler: (...args: unknown[]) => void) => void;
getURL: () => string;
};
}
export interface AnilistTokenStoreLike {
saveToken: (token: string) => void;
loadToken: () => string | null | undefined;
clearToken: () => void;
}
export interface AnilistRuntimeInput<
TConfig extends { anilist: { accessToken: string; enabled?: boolean } } = {
anilist: { accessToken: string; enabled?: boolean };
},
TWindow extends AnilistSetupWindowLike = AnilistSetupWindowLike,
> {
getResolvedConfig: () => TConfig;
isTrackingEnabled: (config: TConfig) => boolean;
tokenStore: AnilistTokenStoreLike;
updateQueue: AnilistUpdateQueue;
getCurrentMediaPath: () => string | null;
getCurrentMediaTitle: () => string | null;
getWatchedSeconds: () => number;
hasMpvClient: () => boolean;
requestMpvDuration: () => Promise<unknown>;
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
guessAnilistMediaInfo: (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
updateAnilistPostWatchProgress: (
accessToken: string,
title: string,
episode: number,
) => Promise<AnilistPostWatchUpdateResult>;
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
authorizeUrl: string;
clientId: string;
responseType: string;
redirectUri: string;
developerSettingsUrl: string;
isAllowedExternalUrl: (url: string) => boolean;
isAllowedNavigationUrl: (url: string) => boolean;
openExternal: (url: string) => Promise<unknown> | void;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
logError: (message: string, error: unknown) => void;
logDebug: (message: string, details?: unknown) => void;
isDefaultApp: () => boolean;
getArgv: () => string[];
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
now?: () => number;
durationRetryIntervalMs?: number;
minWatchSeconds?: number;
minWatchRatio?: number;
maxAttemptedUpdateKeys?: number;
}
export interface AnilistRuntime {
notifyAnilistSetup: (message: string) => void;
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean;
registerSubminerProtocolClient: () => void;
openAnilistSetupWindow: () => void;
refreshAnilistClientSecretState: (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}) => Promise<string | null>;
refreshAnilistClientSecretStateIfEnabled: (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}) => Promise<string | null>;
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
getAnilistMediaGuessRuntimeState: () => AnilistMediaGuessRuntimeState;
setAnilistMediaGuessRuntimeState: (state: AnilistMediaGuessRuntimeState) => void;
resetAnilistMediaGuessState: () => void;
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistMediaGuess | null>;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
refreshRetryQueueState: () => void;
getStatusSnapshot: () => {
tokenStatus: AnilistSecretResolutionState['status'];
tokenSource: AnilistSecretResolutionState['source'];
tokenMessage: string | null;
tokenResolvedAt: number | null;
tokenErrorAt: number | null;
queuePending: number;
queueReady: number;
queueDeadLetter: number;
queueLastAttemptAt: number | null;
queueLastError: string | null;
};
getQueueStatusSnapshot: () => AnilistRetryQueueState;
clearTokenState: () => void;
getSetupWindow: () => AnilistSetupWindowLike | null;
}
const DEFAULT_DURATION_RETRY_INTERVAL_MS = 15_000;
const DEFAULT_MIN_WATCH_SECONDS = 10 * 60;
const DEFAULT_MIN_WATCH_RATIO = 0.85;
const DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
export function createAnilistRuntime<
TConfig extends { anilist: { accessToken: string; enabled?: boolean } },
TWindow extends AnilistSetupWindowLike,
>(input: AnilistRuntimeInput<TConfig, TWindow>): AnilistRuntime {
const now = input.now ?? Date.now;
let setupWindow: TWindow | null = null;
let setupPageOpened = false;
let cachedAccessToken: string | null = null;
let clientSecretState = createInitialAnilistSecretResolutionState();
let retryQueueState = createInitialAnilistRetryQueueState();
let mediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState();
let updateInFlightState = createInitialAnilistUpdateInFlightState();
const attemptedUpdateKeys = new Set<string>();
const stateRuntime = createAnilistStateRuntime({
getClientSecretState: () => clientSecretState,
setClientSecretState: (next) => {
clientSecretState = next;
},
getRetryQueueState: () => retryQueueState,
setRetryQueueState: (next) => {
retryQueueState = next;
},
getUpdateQueueSnapshot: () => input.updateQueue.getSnapshot(),
clearStoredToken: () => input.tokenStore.clearToken(),
clearCachedAccessToken: () => {
cachedAccessToken = null;
},
});
const rememberAttemptedUpdate = (key: string): void => {
rememberAnilistAttemptedUpdateKey(
attemptedUpdateKeys,
key,
input.maxAttemptedUpdateKeys ?? DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS,
);
};
const maybeFocusExistingSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => setupWindow,
});
const createSetupWindow = createCreateAnilistSetupWindowHandler({
createBrowserWindow: (options) => input.createBrowserWindow(options),
});
const {
notifyAnilistSetup,
consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => input.hasMpvClient(),
showMpvOsd: (message) => input.showMpvOsd(message),
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
logInfo: (message) => input.logInfo(message),
},
consumeTokenDeps: {
consumeAnilistSetupCallbackUrl,
saveToken: (token) => input.tokenStore.saveToken(token),
setCachedToken: (token) => {
cachedAccessToken = token;
},
setResolvedState: (resolvedAt) => {
stateRuntime.setClientSecretState({
status: 'resolved',
source: 'stored',
message: 'saved token from AniList login',
resolvedAt,
errorAt: null,
});
},
setSetupPageOpened: (opened) => {
setupPageOpened = opened;
},
onSuccess: () => {
notifyAnilistSetup('AniList login success');
},
closeWindow: () => {
if (setupWindow && !setupWindow.isDestroyed()) {
setupWindow.close();
}
},
},
handleProtocolDeps: {
consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
logWarn: (message, details) => input.logWarn(message, details),
},
registerProtocolClientDeps: {
isDefaultApp: () => input.isDefaultApp(),
getArgv: () => input.getArgv(),
execPath: input.execPath,
resolvePath: (value) => input.resolvePath(value),
setAsDefaultProtocolClient: (scheme, targetPath, args) =>
input.setAsDefaultProtocolClient(scheme, targetPath, args),
logDebug: (message, details) => input.logDebug(message, details),
},
});
const openAnilistSetupWindow = createOpenAnilistSetupWindowHandler({
maybeFocusExistingSetupWindow: () => maybeFocusExistingSetupWindow(),
createSetupWindow: () => createSetupWindow(),
buildAuthorizeUrl: () =>
buildAnilistSetupUrl({
authorizeUrl: input.authorizeUrl,
clientId: input.clientId,
responseType: input.responseType,
redirectUri: input.redirectUri,
}),
consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
openSetupInBrowser: (authorizeUrl) =>
openAnilistSetupInBrowser({
authorizeUrl,
openExternal: async (url) => {
await input.openExternal(url);
},
logError: (message, error) => input.logError(message, error),
}),
loadManualTokenEntry: (window, authorizeUrl) =>
loadAnilistManualTokenEntry({
setupWindow: window as never,
authorizeUrl,
developerSettingsUrl: input.developerSettingsUrl,
logWarn: (message, details) => input.logWarn(message, details),
}),
redirectUri: input.redirectUri,
developerSettingsUrl: input.developerSettingsUrl,
isAllowedExternalUrl: (url) => input.isAllowedExternalUrl(url),
isAllowedNavigationUrl: (url) => input.isAllowedNavigationUrl(url),
logWarn: (message, details) => input.logWarn(message, details),
logError: (message, details) => input.logError(message, details),
clearSetupWindow: () => {
setupWindow = null;
},
setSetupPageOpened: (opened) => {
setupPageOpened = opened;
},
setSetupWindow: (window) => {
setupWindow = window;
},
openExternal: (url) => {
void input.openExternal(url);
},
});
const trackingRuntime = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => input.getResolvedConfig(),
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
getCachedAccessToken: () => cachedAccessToken,
setCachedAccessToken: (token) => {
cachedAccessToken = token;
},
saveStoredToken: (token) => {
input.tokenStore.saveToken(token);
},
loadStoredToken: () => input.tokenStore.loadToken(),
setClientSecretState: (state) => {
clientSecretState = state;
},
getAnilistSetupPageOpened: () => setupPageOpened,
setAnilistSetupPageOpened: (opened) => {
setupPageOpened = opened;
},
openAnilistSetupWindow: () => {
openAnilistSetupWindow();
},
now,
},
getCurrentMediaKeyMainDeps: {
getCurrentMediaPath: () => input.getCurrentMediaPath(),
},
resetMediaTrackingMainDeps: {
setMediaKey: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
},
setMediaDurationSec: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
},
setMediaGuess: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
},
setMediaGuessPromise: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
},
setLastDurationProbeAtMs: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
},
},
getMediaGuessRuntimeStateMainDeps: {
getMediaKey: () => mediaGuessRuntimeState.mediaKey,
getMediaDurationSec: () => mediaGuessRuntimeState.mediaDurationSec,
getMediaGuess: () => mediaGuessRuntimeState.mediaGuess,
getMediaGuessPromise: () => mediaGuessRuntimeState.mediaGuessPromise,
getLastDurationProbeAtMs: () => mediaGuessRuntimeState.lastDurationProbeAtMs,
},
setMediaGuessRuntimeStateMainDeps: {
setMediaKey: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
},
setMediaDurationSec: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
},
setMediaGuess: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
},
setMediaGuessPromise: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
},
setLastDurationProbeAtMs: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
},
setMediaGuessPromise: (value) => {
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
},
},
maybeProbeDurationMainDeps: {
getState: () => mediaGuessRuntimeState,
setState: (state) => {
mediaGuessRuntimeState = state;
},
durationRetryIntervalMs: input.durationRetryIntervalMs ?? DEFAULT_DURATION_RETRY_INTERVAL_MS,
now,
requestMpvDuration: () => input.requestMpvDuration(),
logWarn: (message, error) => input.logWarn(message, error),
},
ensureMediaGuessMainDeps: {
getState: () => mediaGuessRuntimeState,
setState: (state) => {
mediaGuessRuntimeState = state;
},
resolveMediaPathForJimaku: (currentMediaPath) =>
input.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => input.getCurrentMediaPath(),
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
},
processNextRetryUpdateMainDeps: {
nextReady: () => input.updateQueue.nextReady(),
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
setLastAttemptAt: (value) => {
retryQueueState = { ...retryQueueState, lastAttemptAt: value };
},
setLastError: (value) => {
retryQueueState = { ...retryQueueState, lastError: value };
},
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
input.updateAnilistPostWatchProgress(accessToken, title, episode),
markSuccess: (key) => {
input.updateQueue.markSuccess(key);
},
rememberAttemptedUpdateKey: (key) => {
rememberAttemptedUpdate(key);
},
markFailure: (key, message) => {
input.updateQueue.markFailure(key, message);
},
logInfo: (message) => input.logInfo(message),
now,
},
maybeRunPostWatchUpdateMainDeps: {
getInFlight: () => updateInFlightState.inFlight,
setInFlight: (value) => {
updateInFlightState = { ...updateInFlightState, inFlight: value };
},
getResolvedConfig: () => input.getResolvedConfig(),
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
getCurrentMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
hasMpvClient: () => input.hasMpvClient(),
getTrackedMediaKey: () => mediaGuessRuntimeState.mediaKey,
resetTrackedMedia: (mediaKey) => {
trackingRuntime.resetAnilistMediaTracking(mediaKey);
},
getWatchedSeconds: () => input.getWatchedSeconds(),
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
hasAttemptedUpdateKey: (key) => attemptedUpdateKeys.has(key),
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
enqueueRetry: (key, title, episode) => {
input.updateQueue.enqueue(key, title, episode);
},
markRetryFailure: (key, message) => {
input.updateQueue.markFailure(key, message);
},
markRetrySuccess: (key) => {
input.updateQueue.markSuccess(key);
},
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
input.updateAnilistPostWatchProgress(accessToken, title, episode),
rememberAttemptedUpdateKey: (key) => {
rememberAttemptedUpdate(key);
},
showMpvOsd: (message) => input.showMpvOsd(message),
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
minWatchSeconds: input.minWatchSeconds ?? DEFAULT_MIN_WATCH_SECONDS,
minWatchRatio: input.minWatchRatio ?? DEFAULT_MIN_WATCH_RATIO,
},
});
return {
notifyAnilistSetup,
consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient,
openAnilistSetupWindow,
refreshAnilistClientSecretState: (options) =>
trackingRuntime.refreshAnilistClientSecretState(options),
refreshAnilistClientSecretStateIfEnabled: (options) => {
if (!input.isTrackingEnabled(input.getResolvedConfig())) {
return Promise.resolve(null);
}
return trackingRuntime.refreshAnilistClientSecretState(options);
},
getCurrentAnilistMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => trackingRuntime.resetAnilistMediaTracking(mediaKey),
getAnilistMediaGuessRuntimeState: () => trackingRuntime.getAnilistMediaGuessRuntimeState(),
setAnilistMediaGuessRuntimeState: (state) =>
trackingRuntime.setAnilistMediaGuessRuntimeState(state),
resetAnilistMediaGuessState: () => trackingRuntime.resetAnilistMediaGuessState(),
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
maybeRunAnilistPostWatchUpdate: () => trackingRuntime.maybeRunAnilistPostWatchUpdate(),
setClientSecretState: (partial) => stateRuntime.setClientSecretState(partial),
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
getStatusSnapshot: () => stateRuntime.getStatusSnapshot(),
getQueueStatusSnapshot: () => stateRuntime.getQueueStatusSnapshot(),
clearTokenState: () => stateRuntime.clearTokenState(),
getSetupWindow: () => setupWindow,
};
}
export { buildAnilistAttemptKey };

View File

@@ -0,0 +1,270 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAppReadyRuntime } from './app-ready-runtime';
test('app ready runtime shares overlay startup prereqs with youtube runtime init path', async () => {
let subtitlePosition: unknown | null = null;
let keybindingsCount = 0;
let hasMpvClient = false;
let runtimeOptionsManager: unknown | null = null;
let subtitleTimingTracker: unknown | null = null;
let overlayRuntimeInitialized = false;
const calls: string[] = [];
const runtime = createAppReadyRuntime({
reload: {
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => undefined,
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfig: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {
throw new Error('quit');
},
},
},
immersion: {
getResolvedConfig: () =>
({
immersionTracking: {
enabled: true,
batchSize: 1,
flushIntervalMs: 1,
queueCap: 1,
payloadCapBytes: 1,
maintenanceIntervalMs: 1,
retention: {
eventsDays: 1,
telemetryDays: 1,
sessionsDays: 1,
dailyRollupsDays: 1,
monthlyRollupsDays: 1,
vacuumIntervalDays: 1,
},
},
}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () => ({}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
runner: {
ensureDefaultConfigBootstrap: () => {},
getSubtitlePosition: () => subtitlePosition,
loadSubtitlePosition: () => {
subtitlePosition = { mode: 'bottom' };
calls.push('loadSubtitlePosition');
},
getKeybindingsCount: () => keybindingsCount,
resolveKeybindings: () => {
keybindingsCount = 3;
calls.push('resolveKeybindings');
},
hasMpvClient: () => hasMpvClient,
createMpvClient: () => {
hasMpvClient = true;
calls.push('createMpvClient');
},
getRuntimeOptionsManager: () => runtimeOptionsManager,
initRuntimeOptionsManager: () => {
runtimeOptionsManager = {};
calls.push('initRuntimeOptionsManager');
},
getSubtitleTimingTracker: () => subtitleTimingTracker,
createSubtitleTimingTracker: () => {
subtitleTimingTracker = {};
calls.push('createSubtitleTimingTracker');
},
getResolvedConfig: () =>
({
ankiConnect: {
enabled: false,
},
}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
loadYomitanExtension: async () => {},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensureYomitanExtensionLoaded');
},
handleFirstRunSetup: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {
overlayRuntimeInitialized = true;
calls.push('initializeOverlayRuntime');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
},
handleInitialArgs: () => {},
},
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
});
runtime.ensureOverlayStartupPrereqs();
runtime.ensureOverlayStartupPrereqs();
await runtime.ensureYoutubePlaybackRuntimeReady();
assert.deepEqual(calls, [
'loadSubtitlePosition',
'resolveKeybindings',
'createMpvClient',
'initRuntimeOptionsManager',
'createSubtitleTimingTracker',
'ensureYomitanExtensionLoaded',
'initializeOverlayRuntime',
]);
});
test('app ready runtime reuses existing overlay runtime during youtube readiness', async () => {
const calls: string[] = [];
const runtime = createAppReadyRuntime({
reload: {
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => undefined,
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfig: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {
throw new Error('quit');
},
},
},
immersion: {
getResolvedConfig: () =>
({
immersionTracking: {
enabled: true,
batchSize: 1,
flushIntervalMs: 1,
queueCap: 1,
payloadCapBytes: 1,
maintenanceIntervalMs: 1,
retention: {
eventsDays: 1,
telemetryDays: 1,
sessionsDays: 1,
dailyRollupsDays: 1,
monthlyRollupsDays: 1,
vacuumIntervalDays: 1,
},
},
}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () => ({}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
runner: {
ensureDefaultConfigBootstrap: () => {},
getSubtitlePosition: () => ({}) as never,
loadSubtitlePosition: () => {
throw new Error('should not load subtitle position');
},
getKeybindingsCount: () => 1,
resolveKeybindings: () => {
throw new Error('should not resolve keybindings');
},
hasMpvClient: () => true,
createMpvClient: () => {
throw new Error('should not create mpv client');
},
getRuntimeOptionsManager: () => ({}),
initRuntimeOptionsManager: () => {
throw new Error('should not init runtime options');
},
getSubtitleTimingTracker: () => ({}),
createSubtitleTimingTracker: () => {
throw new Error('should not create subtitle timing tracker');
},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
loadYomitanExtension: async () => {},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensureYomitanExtensionLoaded');
},
handleFirstRunSetup: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {
calls.push('initializeOverlayRuntime');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
},
handleInitialArgs: () => {},
},
isOverlayRuntimeInitialized: () => true,
});
await runtime.ensureYoutubePlaybackRuntimeReady();
assert.deepEqual(calls, [
'ensureYomitanExtensionLoaded',
'ensureOverlayWindowsReadyForVisibilityActions',
]);
});

View File

@@ -0,0 +1,264 @@
import type { LogLevelSource } from '../logger';
import type { ConfigValidationWarning, SecondarySubMode } from '../types';
import { composeAppReadyRuntime } from './runtime/composers/app-ready-composer';
type AppReadyConfigLike = {
logging?: {
level?: string;
};
};
type SubtitlePositionLike = unknown;
type RuntimeOptionsManagerLike = unknown;
type SubtitleTimingTrackerLike = unknown;
type ImmersionTrackingConfigLike = {
immersionTracking?: {
enabled?: boolean;
};
};
type MpvClientLike = {
connected: boolean;
connect: () => void;
};
export interface AppReadyReloadConfigInput {
reloadConfigStrict: () =>
| { ok: true; path: string; warnings: ConfigValidationWarning[] }
| { ok: false; path: string; error: string };
logInfo: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
setExitCode?: (code: number) => void;
quit: () => void;
};
}
export interface AppReadyCriticalConfigInput {
getConfigPath: () => string;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
setExitCode?: (code: number) => void;
quit: () => void;
};
}
export interface AppReadyImmersionInput {
getResolvedConfig: () => ImmersionTrackingConfigLike;
getConfiguredDbPath: () => string;
createTrackerService: (params: {
dbPath: string;
policy: {
batchSize: number;
flushIntervalMs: number;
queueCap: number;
payloadCapBytes: number;
maintenanceIntervalMs: number;
retention: {
eventsDays: number;
telemetryDays: number;
sessionsDays: number;
dailyRollupsDays: number;
monthlyRollupsDays: number;
vacuumIntervalDays: number;
};
};
}) => unknown;
setTracker: (tracker: unknown | null) => void;
getMpvClient: () => MpvClientLike | null;
shouldAutoConnectMpv?: () => boolean;
seedTrackerFromCurrentMedia: () => void;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string, details: unknown) => void;
}
export interface AppReadyRunnerInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
ensureDefaultConfigBootstrap: () => void;
getSubtitlePosition: () => SubtitlePositionLike | null;
loadSubtitlePosition: () => void;
getKeybindingsCount: () => number;
resolveKeybindings: () => void;
hasMpvClient: () => boolean;
createMpvClient: () => void;
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
initRuntimeOptionsManager: () => void;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
createSubtitleTimingTracker: () => void;
getResolvedConfig: () => TConfig;
getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
defaultTexthookerPort: number;
hasMpvWebsocketPlugin: () => boolean;
startSubtitleWebsocket: (port: number) => void;
startAnnotationWebsocket: (port: number) => void;
startTexthooker: (port: number, websocketUrl?: string) => void;
log: (message: string) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
initializeOverlayRuntime: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
runHeadlessInitialCommand?: () => Promise<void>;
handleInitialArgs: () => void;
onCriticalConfigErrors?: (errors: string[]) => void;
logDebug?: (message: string) => void;
now?: () => number;
shouldRunHeadlessInitialCommand?: () => boolean;
shouldUseMinimalStartup?: () => boolean;
shouldSkipHeavyStartup?: () => boolean;
}
export interface AppReadyRuntimeInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
reload: AppReadyReloadConfigInput;
criticalConfig: AppReadyCriticalConfigInput;
immersion: AppReadyImmersionInput;
runner: AppReadyRunnerInput<TConfig>;
isOverlayRuntimeInitialized: () => boolean;
}
export interface AppReadyRuntime {
reloadConfig: () => void;
criticalConfigError: (errors: string[]) => never;
ensureOverlayStartupPrereqs: () => void;
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
runAppReady: () => Promise<void>;
}
export function createAppReadyRuntime<TConfig extends AppReadyConfigLike>(
input: AppReadyRuntimeInput<TConfig>,
): AppReadyRuntime {
const ensureSubtitlePositionLoaded = (): void => {
if (input.runner.getSubtitlePosition() === null) {
input.runner.loadSubtitlePosition();
}
};
const ensureKeybindingsResolved = (): void => {
if (input.runner.getKeybindingsCount() === 0) {
input.runner.resolveKeybindings();
}
};
const ensureMpvClientCreated = (): void => {
if (!input.runner.hasMpvClient()) {
input.runner.createMpvClient();
}
};
const ensureRuntimeOptionsManagerInitialized = (): void => {
if (!input.runner.getRuntimeOptionsManager()) {
input.runner.initRuntimeOptionsManager();
}
};
const ensureSubtitleTimingTrackerCreated = (): void => {
if (!input.runner.getSubtitleTimingTracker()) {
input.runner.createSubtitleTimingTracker();
}
};
const ensureOverlayStartupPrereqs = (): void => {
ensureSubtitlePositionLoaded();
ensureKeybindingsResolved();
ensureMpvClientCreated();
ensureRuntimeOptionsManagerInitialized();
ensureSubtitleTimingTrackerCreated();
};
const ensureYoutubePlaybackRuntimeReady = async (): Promise<void> => {
ensureOverlayStartupPrereqs();
await input.runner.ensureYomitanExtensionLoaded();
if (!input.isOverlayRuntimeInitialized()) {
input.runner.initializeOverlayRuntime();
return;
}
input.runner.ensureOverlayWindowsReadyForVisibilityActions();
};
const createImmersionTracker = input.runner.createImmersionTracker;
const startJellyfinRemoteSession = input.runner.startJellyfinRemoteSession;
const prewarmSubtitleDictionaries = input.runner.prewarmSubtitleDictionaries;
const runHeadlessInitialCommand = input.runner.runHeadlessInitialCommand;
const { reloadConfig, criticalConfigError, appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: input.reload,
criticalConfigErrorMainDeps: input.criticalConfig,
immersionTrackerStartupMainDeps: input.immersion as never,
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => input.runner.ensureDefaultConfigBootstrap(),
loadSubtitlePosition: () => ensureSubtitlePositionLoaded(),
resolveKeybindings: () => ensureKeybindingsResolved(),
createMpvClient: () => ensureMpvClientCreated(),
initRuntimeOptionsManager: () => ensureRuntimeOptionsManagerInitialized(),
createSubtitleTimingTracker: () => ensureSubtitleTimingTrackerCreated(),
getResolvedConfig: () => input.runner.getResolvedConfig() as never,
getConfigWarnings: () => input.runner.getConfigWarnings(),
logConfigWarning: (warning) => input.runner.logConfigWarning(warning),
setLogLevel: (level, source) => input.runner.setLogLevel(level, source),
setSecondarySubMode: (mode) => input.runner.setSecondarySubMode(mode),
defaultSecondarySubMode: input.runner.defaultSecondarySubMode,
defaultWebsocketPort: input.runner.defaultWebsocketPort,
defaultAnnotationWebsocketPort: input.runner.defaultAnnotationWebsocketPort,
defaultTexthookerPort: input.runner.defaultTexthookerPort,
hasMpvWebsocketPlugin: () => input.runner.hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port) => input.runner.startSubtitleWebsocket(port),
startAnnotationWebsocket: (port) => input.runner.startAnnotationWebsocket(port),
startTexthooker: (port, websocketUrl) => input.runner.startTexthooker(port, websocketUrl),
log: (message) => input.runner.log(message),
createMecabTokenizerAndCheck: () => input.runner.createMecabTokenizerAndCheck(),
createImmersionTracker: createImmersionTracker ? () => createImmersionTracker() : undefined,
startJellyfinRemoteSession: startJellyfinRemoteSession
? () => startJellyfinRemoteSession()
: undefined,
loadYomitanExtension: () => input.runner.loadYomitanExtension(),
handleFirstRunSetup: () => input.runner.handleFirstRunSetup(),
prewarmSubtitleDictionaries: prewarmSubtitleDictionaries
? () => prewarmSubtitleDictionaries()
: undefined,
startBackgroundWarmups: () => input.runner.startBackgroundWarmups(),
texthookerOnlyMode: input.runner.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
input.runner.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible) => input.runner.setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => input.runner.initializeOverlayRuntime(),
runHeadlessInitialCommand: runHeadlessInitialCommand
? () => runHeadlessInitialCommand()
: undefined,
handleInitialArgs: () => input.runner.handleInitialArgs(),
logDebug: input.runner.logDebug,
now: input.runner.now,
shouldRunHeadlessInitialCommand: input.runner.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: input.runner.shouldUseMinimalStartup,
shouldSkipHeavyStartup: input.runner.shouldSkipHeavyStartup,
},
});
return {
reloadConfig,
criticalConfigError,
ensureOverlayStartupPrereqs,
ensureYoutubePlaybackRuntimeReady,
runAppReady: async () => {
await appReadyRuntimeRunner();
},
};
}

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../cli/args';
import { createCliStartupRuntime } from './cli-startup-runtime';
test('cli startup runtime returns callable CLI handlers', () => {
const calls: string[] = [];
const runtime = createCliStartupRuntime({
appState: {
appState: {} as never,
getInitialArgs: () => null,
isBackgroundMode: () => false,
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => {},
hasImmersionTracker: () => false,
getMpvClient: () => null,
isOverlayRuntimeInitialized: () => false,
},
config: {
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
getResolvedConfig: () => ({}) as never,
setCliLogLevel: () => {},
hasMpvWebsocketPlugin: () => false,
},
io: {
texthookerService: {} as never,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: () => {},
schedule: () => 0 as never,
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
commands: {
initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {},
openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => false,
getMultiCopyTimeoutMs: () => 0,
},
startup: {
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
startBackgroundWarmups: () => {},
},
handleCliCommandRuntimeServiceWithContext: (args) => {
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
},
});
assert.equal(typeof runtime.handleCliCommand, 'function');
assert.equal(typeof runtime.handleInitialArgs, 'function');
runtime.handleCliCommand({ command: 'start' } as unknown as CliArgs);
assert.deepEqual(calls, ['handle:start']);
});

View File

@@ -0,0 +1,220 @@
import type { CliArgs, CliCommandSource } from '../cli/args';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
} from './cli-runtime';
import { composeCliStartupHandlers } from './runtime/composers/cli-startup-composer';
/** Mpv client shape required by the CLI command context (appState.mpvClient). */
type AppStateMpvClientLike = {
setSocketPath: (socketPath: string) => void;
connect: () => void;
} | null;
/** Mpv client shape required by the initial-args handler (getMpvClient). */
type InitialArgsMpvClientLike = { connected: boolean; connect: () => void } | null;
/** Resolved config shape consumed by the CLI command context builder. */
type ResolvedConfigLike = {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
/** Mutable app state consumed by the CLI command context builder. */
type CliCommandContextMainStateLike = {
mpvSocketPath: string;
mpvClient: AppStateMpvClientLike;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
export interface CliStartupAppStateInput {
appState: CliCommandContextMainStateLike;
getInitialArgs: () => CliArgs | null | undefined;
isBackgroundMode: () => boolean;
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
hasImmersionTracker: () => boolean;
getMpvClient: () => InitialArgsMpvClientLike;
isOverlayRuntimeInitialized: () => boolean;
}
export interface CliStartupConfigInput {
defaultConfig: {
websocket: { port: number };
annotationWebsocket: { port: number };
};
getResolvedConfig: () => ResolvedConfigLike;
setCliLogLevel: (level: NonNullable<CliArgs['logLevel']>) => void;
hasMpvWebsocketPlugin: () => boolean;
}
export interface CliStartupIoInput {
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
openExternal: (url: string) => Promise<void>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}
export interface CliStartupCommandsInput {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
}
export interface CliStartupStartupInput {
shouldEnsureTrayOnStartup: () => boolean;
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
ensureTray: () => void;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
startBackgroundWarmups: () => void;
}
export interface CliStartupRuntimeInput {
appState: CliStartupAppStateInput;
config: CliStartupConfigInput;
io: CliStartupIoInput;
commands: CliStartupCommandsInput;
startup: CliStartupStartupInput;
handleCliCommandRuntimeServiceWithContext: (
args: CliArgs,
source: CliCommandSource,
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
) => void;
}
export interface CliStartupRuntime {
handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void;
handleInitialArgs: () => void;
}
export function createCliStartupRuntime(input: CliStartupRuntimeInput): CliStartupRuntime {
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState: input.appState.appState,
setLogLevel: (level) => input.config.setCliLogLevel(level),
texthookerService: input.io.texthookerService,
getResolvedConfig: () => input.config.getResolvedConfig(),
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
hasMpvWebsocketPlugin: () => input.config.hasMpvWebsocketPlugin(),
openExternal: (url: string) => input.io.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) =>
input.io.logBrowserOpenError(url, error),
showMpvOsd: (text: string) => input.io.showMpvOsd(text),
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
toggleVisibleOverlay: () => input.commands.toggleVisibleOverlay(),
openFirstRunSetupWindow: () => input.commands.openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) =>
input.commands.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => input.commands.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => input.commands.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => input.commands.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
input.commands.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => input.commands.updateLastCardFromClipboard(),
refreshKnownWordCache: () => input.commands.refreshKnownWordCache(),
triggerFieldGrouping: () => input.commands.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => input.commands.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => input.commands.markLastCardAsAudioCard(),
getAnilistStatus: () => input.commands.getAnilistStatus(),
clearAnilistToken: () => input.commands.clearAnilistToken(),
openAnilistSetupWindow: () => input.commands.openAnilistSetupWindow(),
openJellyfinSetupWindow: () => input.commands.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => input.commands.getAnilistQueueStatus(),
processNextAnilistRetryUpdate: () => input.commands.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
input.commands.generateCharacterDictionary(targetPath),
runJellyfinCommand: (argsFromCommand: CliArgs) =>
input.commands.runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
input.commands.runStatsCommand(argsFromCommand, source),
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
openYomitanSettings: () => input.commands.openYomitanSettings(),
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => input.commands.openRuntimeOptionsPalette(),
printHelp: () => input.commands.printHelp(),
stopApp: () => input.commands.stopApp(),
hasMainWindow: () => input.commands.hasMainWindow(),
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => input.io.schedule(fn, delayMs),
logInfo: (message: string) => input.io.logInfo(message),
logWarn: (message: string) => input.io.logWarn(message),
logError: (message: string, err: unknown) => input.io.logError(message, err),
},
cliCommandRuntimeHandlerMainDeps: {
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
setTexthookerOnlyMode: (enabled) => input.appState.setTexthookerOnlyMode(enabled),
commandNeedsOverlayStartupPrereqs: (args) =>
input.startup.commandNeedsOverlayStartupPrereqs(args),
startBackgroundWarmups: () => input.startup.startBackgroundWarmups(),
logInfo: (message: string) => input.io.logInfo(message),
},
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
input.handleCliCommandRuntimeServiceWithContext(args, source, context),
},
initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => input.appState.getInitialArgs() ?? null,
isBackgroundMode: () => input.appState.isBackgroundMode(),
shouldEnsureTrayOnStartup: () => input.startup.shouldEnsureTrayOnStartup(),
shouldRunHeadlessInitialCommand: (args) =>
input.startup.shouldRunHeadlessInitialCommand(args),
ensureTray: () => input.startup.ensureTray(),
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
hasImmersionTracker: () => input.appState.hasImmersionTracker(),
getMpvClient: () => input.appState.getMpvClient(),
commandNeedsOverlayStartupPrereqs: (args) =>
input.startup.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => input.startup.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => input.appState.isOverlayRuntimeInitialized(),
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
logInfo: (message) => input.io.logInfo(message),
},
});
return {
handleCliCommand,
handleInitialArgs,
};
}

View File

@@ -0,0 +1,12 @@
import {
createBuildGetDefaultSocketPathMainDepsHandler,
createGetDefaultSocketPathHandler,
} from './runtime/domains/jellyfin';
export function createDefaultSocketPathResolver(platform: NodeJS.Platform) {
return createGetDefaultSocketPathHandler(
createBuildGetDefaultSocketPathMainDepsHandler({
platform,
})(),
);
}

View File

@@ -0,0 +1,205 @@
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { FrequencyDictionaryLookup, ResolvedConfig } from '../types';
import type { JlptLookup } from './jlpt-runtime';
import type { DictionarySupportRuntimeInput } from './dictionary-support-runtime';
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './runtime/startup-osd-sequencer';
type BrowserWindowLike = {
isDestroyed: () => boolean;
webContents: {
send: (channel: string, payload?: unknown) => void;
};
};
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
};
type MpvClientLike = {
currentVideoPath?: string | null;
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
};
type StartupOsdSequencerLike = {
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
};
export interface DictionarySupportRuntimeInputBuilderInput {
env: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
subtitlePositionsDir: string;
defaultImmersionDbPath: string;
};
config: {
getResolvedConfig: () => ResolvedConfig;
};
dictionaryState: {
setJlptLevelLookup: (lookup: JlptLookup) => void;
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
};
logger: {
info: (message: string) => void;
debug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, ...args: unknown[]) => void;
};
media: {
isRemoteMediaPath: (mediaPath: string) => boolean;
getCurrentMediaPath: () => string | null;
setCurrentMediaPath: (mediaPath: string | null) => void;
getCurrentMediaTitle: () => string | null;
setCurrentMediaTitle: (title: string | null) => void;
getPendingSubtitlePosition: () => DictionarySupportRuntimeInput['getPendingSubtitlePosition'] extends () => infer T
? T
: never;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: DictionarySupportRuntimeInput['setSubtitlePosition'];
};
subtitle: {
loadSubtitlePosition: DictionarySupportRuntimeInput['loadSubtitlePosition'];
invalidateTokenizationCache: () => void;
refreshSubtitlePrefetchFromActiveTrack: () => void;
refreshCurrentSubtitle: (text: string) => void;
getCurrentSubtitleText: () => string;
};
overlay: {
broadcastSubtitlePosition: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastSubtitlePosition'];
broadcastToOverlayWindows: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastToOverlayWindows'];
getMainWindow: () => BrowserWindowLike | null;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
sendToActiveOverlayWindow: DictionarySupportRuntimeInput<OverlayHostedModal>['sendToActiveOverlayWindow'];
};
tracker: {
getTracker: () => ImmersionTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
};
anilist: {
guessAnilistMediaInfo: DictionarySupportRuntimeInput['guessAnilistMediaInfo'];
};
yomitan: {
isCharacterDictionaryEnabled: () => boolean;
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
) => Promise<boolean>;
hasParserWindow: () => boolean;
clearParserCaches: () => void;
};
startup: {
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
startupOsdSequencer?: StartupOsdSequencerLike;
};
playback: {
isYoutubePlaybackActiveNow: () => boolean;
waitForYomitanMutationReady: () => Promise<void>;
};
}
export function createDictionarySupportRuntimeInput(
input: DictionarySupportRuntimeInputBuilderInput,
): DictionarySupportRuntimeInput<OverlayHostedModal> {
return {
platform: input.env.platform,
dirname: input.env.dirname,
appPath: input.env.appPath,
resourcesPath: input.env.resourcesPath,
userDataPath: input.env.userDataPath,
appUserDataPath: input.env.appUserDataPath,
homeDir: input.env.homeDir,
appDataDir: input.env.appDataDir,
cwd: input.env.cwd,
subtitlePositionsDir: input.env.subtitlePositionsDir,
getResolvedConfig: () => input.config.getResolvedConfig(),
isJlptEnabled: () => input.config.getResolvedConfig().subtitleStyle.enableJlpt,
isFrequencyDictionaryEnabled: () =>
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
getFrequencyDictionarySourcePath: () =>
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
setJlptLevelLookup: (lookup) => input.dictionaryState.setJlptLevelLookup(lookup),
setFrequencyRankLookup: (lookup) => input.dictionaryState.setFrequencyRankLookup(lookup),
logInfo: (message) => input.logger.info(message),
logDebug: (message) => input.logger.debug(message),
logWarn: (message) => input.logger.warn(message),
isRemoteMediaPath: (mediaPath) => input.media.isRemoteMediaPath(mediaPath),
getCurrentMediaPath: () => input.media.getCurrentMediaPath(),
setCurrentMediaPath: (mediaPath) => input.media.setCurrentMediaPath(mediaPath),
getCurrentMediaTitle: () => input.media.getCurrentMediaTitle(),
setCurrentMediaTitle: (title) => input.media.setCurrentMediaTitle(title),
getPendingSubtitlePosition: () => input.media.getPendingSubtitlePosition(),
loadSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
clearPendingSubtitlePosition: () => input.media.clearPendingSubtitlePosition(),
setSubtitlePosition: (position) => input.media.setSubtitlePosition(position),
broadcastSubtitlePosition: (position) => input.overlay.broadcastSubtitlePosition(position),
broadcastToOverlayWindows: (channel, payload) =>
input.overlay.broadcastToOverlayWindows(channel, payload),
getTracker: () => input.tracker.getTracker(),
getMpvClient: () => input.tracker.getMpvClient(),
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
getCollapsibleSectionOpenState: (section) =>
input.config.getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
isYoutubePlaybackActiveNow: () => input.playback.isYoutubePlaybackActiveNow(),
waitForYomitanMutationReady: () => input.playback.waitForYomitanMutationReady(),
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
deleteYomitanDictionary: (dictionaryTitle) =>
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
getCharacterDictionaryConfig: () => {
const config = input.config.getResolvedConfig().anilist.characterDictionary;
return {
enabled:
config.enabled &&
input.yomitan.isCharacterDictionaryEnabled() &&
!input.playback.isYoutubePlaybackActiveNow(),
maxLoaded: config.maxLoaded,
profileScope: config.profileScope,
};
},
notifyCharacterDictionaryAutoSyncStatus: (event) => {
notifyCharacterDictionaryAutoSyncStatus(event, {
getNotificationType: () => input.startup.getNotificationType(),
showOsd: (message) => input.startup.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.startup.showDesktopNotification(title, options),
startupOsdSequencer: input.startup.startupOsdSequencer,
});
},
characterDictionaryAutoSyncCompleteDeps: {
hasParserWindow: () => input.yomitan.hasParserWindow(),
clearParserCaches: () => input.yomitan.clearParserCaches(),
invalidateTokenizationCache: () => input.subtitle.invalidateTokenizationCache(),
refreshSubtitlePrefetch: () => input.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
refreshCurrentSubtitle: () =>
input.subtitle.refreshCurrentSubtitle(input.subtitle.getCurrentSubtitleText()),
logInfo: (message) => input.logger.info(message),
},
getMainWindow: () => input.overlay.getMainWindow(),
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => input.overlay.setVisibleOverlayVisible(visible),
getRestoreVisibleOverlayOnModalClose: () =>
input.overlay.getRestoreVisibleOverlayOnModalClose(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
input.overlay.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
};
}

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDictionarySupportRuntime } from './dictionary-support-runtime';
function createRuntime() {
const state = {
currentMediaPath: null as string | null,
currentMediaTitle: null as string | null,
jlptLookupSet: 0,
frequencyLookupSet: 0,
trackerCalls: [] as Array<{ path: string; title: string | null }>,
characterDictionaryConfig: {
enabled: false,
maxLoaded: 1,
profileScope: 'global',
},
youtubePlaybackActive: false,
};
const runtime = createDictionarySupportRuntime({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/Library/Application Support/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
subtitlePositionsDir: '/Users/a/Library/Application Support/SubMiner/subtitle-positions',
getResolvedConfig: () =>
({
subtitleStyle: {
enableJlpt: false,
frequencyDictionary: {
enabled: false,
sourcePath: '',
},
},
anilist: {
characterDictionary: {
enabled: false,
maxLoaded: 1,
profileScope: 'global',
collapsibleSections: {
description: false,
glossary: false,
termEntry: false,
nameReading: false,
},
},
},
ankiConnect: {
behavior: {
notificationType: 'none',
},
},
}) as never,
isJlptEnabled: () => false,
isFrequencyDictionaryEnabled: () => false,
getFrequencyDictionarySourcePath: () => undefined,
setJlptLevelLookup: () => {
state.jlptLookupSet += 1;
},
setFrequencyRankLookup: () => {
state.frequencyLookupSet += 1;
},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('remote:'),
getCurrentMediaPath: () => state.currentMediaPath,
setCurrentMediaPath: (mediaPath) => {
state.currentMediaPath = mediaPath;
},
getCurrentMediaTitle: () => state.currentMediaTitle,
setCurrentMediaTitle: (title) => {
state.currentMediaTitle = title;
},
getPendingSubtitlePosition: () => null,
loadSubtitlePosition: () => null,
clearPendingSubtitlePosition: () => {},
setSubtitlePosition: () => {},
broadcastSubtitlePosition: () => {},
broadcastToOverlayWindows: () => {},
getTracker: () =>
({
handleMediaChange: (path: string, title: string | null) => {
state.trackerCalls.push({ path, title });
},
}) as never,
getMpvClient: () => null,
defaultImmersionDbPath: '/tmp/immersion.db',
guessAnilistMediaInfo: async () => null,
getCollapsibleSectionOpenState: () => false,
isCharacterDictionaryEnabled: () => state.characterDictionaryConfig.enabled,
isYoutubePlaybackActiveNow: () => state.youtubePlaybackActive,
waitForYomitanMutationReady: async () => {},
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async () => false,
deleteYomitanDictionary: async () => false,
upsertYomitanDictionarySettings: async () => false,
getCharacterDictionaryConfig: () => state.characterDictionaryConfig as never,
notifyCharacterDictionaryAutoSyncStatus: () => {},
characterDictionaryAutoSyncCompleteDeps: {
hasParserWindow: () => false,
clearParserCaches: () => {},
invalidateTokenizationCache: () => {},
refreshSubtitlePrefetch: () => {},
refreshCurrentSubtitle: () => {},
logInfo: () => {},
},
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<string>(),
sendToActiveOverlayWindow: () => true,
});
return { runtime, state };
}
test('dictionary support runtime wires field grouping resolver and callback', async () => {
const { runtime } = createRuntime();
const choice = {
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
};
const callback = runtime.createFieldGroupingCallback();
const pending = callback({} as never);
const resolver = runtime.getFieldGroupingResolver();
assert.ok(resolver);
resolver(choice as never);
assert.deepEqual(await pending, choice);
assert.equal(typeof runtime.getFieldGroupingResolver(), 'function');
runtime.setFieldGroupingResolver(null);
assert.equal(runtime.getFieldGroupingResolver(), null);
});
test('dictionary support runtime resolves media paths and keeps title in sync', () => {
const { runtime, state } = createRuntime();
runtime.updateCurrentMediaTitle(' Example Title ');
runtime.updateCurrentMediaPath('remote://media' as never);
assert.equal(state.currentMediaTitle, 'Example Title');
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'Example Title');
runtime.updateCurrentMediaPath('local.mp4' as never);
assert.equal(state.currentMediaTitle, null);
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'remote://media');
});
test('dictionary support runtime skips disabled lookup and sync paths', async () => {
const { runtime, state } = createRuntime();
await runtime.ensureJlptDictionaryLookup();
await runtime.ensureFrequencyDictionaryLookup();
runtime.scheduleCharacterDictionarySync();
assert.equal(state.jlptLookupSet, 0);
assert.equal(state.frequencyLookupSet, 0);
});
test('dictionary support runtime syncs immersion media from current state', async () => {
const { runtime, state } = createRuntime();
runtime.updateCurrentMediaTitle(' Example Title ');
runtime.updateCurrentMediaPath('remote://media' as never);
await runtime.seedImmersionMediaFromCurrentMedia();
runtime.syncImmersionMediaState();
assert.deepEqual(state.trackerCalls, [
{ path: 'remote://media', title: 'Example Title' },
{ path: 'remote://media', title: 'Example Title' },
]);
});
test('dictionary support runtime gates character dictionary auto-sync scheduling', () => {
const { runtime, state } = createRuntime();
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let timeoutCalls = 0;
try {
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: never[]) => {
timeoutCalls += 1;
return originalSetTimeout(handler, timeout ?? 0, ...args);
}) as typeof globalThis.setTimeout;
globalThis.clearTimeout = ((handle: number | NodeJS.Timeout | undefined) => {
originalClearTimeout(handle);
}) as typeof globalThis.clearTimeout;
runtime.scheduleCharacterDictionarySync();
assert.equal(timeoutCalls, 0);
state.characterDictionaryConfig = {
enabled: true,
maxLoaded: 1,
profileScope: 'global',
};
runtime.scheduleCharacterDictionarySync();
assert.equal(timeoutCalls, 1);
state.youtubePlaybackActive = true;
runtime.scheduleCharacterDictionarySync();
assert.equal(timeoutCalls, 1);
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});

View File

@@ -0,0 +1,306 @@
import * as path from 'path';
import type {
AnilistCharacterDictionaryProfileScope,
FrequencyDictionaryLookup,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
SubtitlePosition,
ResolvedConfig,
} from '../types';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
createBuildJlptDictionaryRuntimeMainDepsHandler,
} from './runtime/dictionary-runtime-main-deps';
import { createImmersionMediaRuntime } from './runtime/immersion-media';
import {
createFrequencyDictionaryRuntimeService,
getFrequencyDictionarySearchPaths,
} from './frequency-dictionary-runtime';
import {
createJlptDictionaryRuntimeService,
getJlptDictionarySearchPaths,
type JlptLookup,
} from './jlpt-runtime';
import { createMediaRuntimeService } from './media-runtime';
import {
createCharacterDictionaryRuntimeService,
type CharacterDictionaryBuildResult,
} from './character-dictionary-runtime';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
import {
createCharacterDictionaryAutoSyncRuntimeService,
type CharacterDictionaryAutoSyncConfig,
type CharacterDictionaryAutoSyncStatusEvent,
} from './runtime/character-dictionary-auto-sync';
import { handleCharacterDictionaryAutoSyncComplete } from './runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
import { createFieldGroupingOverlayRuntime } from '../core/services/field-grouping-overlay';
type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null;
type BrowserWindowLike = {
isDestroyed: () => boolean;
webContents: {
send: (channel: string, payload?: unknown) => void;
};
};
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
};
type MpvClientLike = {
currentVideoPath?: string | null;
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
};
type CharacterDictionaryAutoSyncCompleteDeps = {
hasParserWindow: () => boolean;
clearParserCaches: () => void;
invalidateTokenizationCache: () => void;
refreshSubtitlePrefetch: () => void;
refreshCurrentSubtitle: () => void;
logInfo: (message: string) => void;
};
export interface DictionarySupportRuntimeInput<TModal extends string = string> {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
subtitlePositionsDir: string;
getResolvedConfig: () => ResolvedConfig;
isJlptEnabled: () => boolean;
isFrequencyDictionaryEnabled: () => boolean;
getFrequencyDictionarySourcePath: () => string | undefined;
setJlptLevelLookup: (lookup: JlptLookup) => void;
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
logInfo: (message: string) => void;
logDebug?: (message: string) => void;
logWarn: (message: string) => void;
isRemoteMediaPath: (mediaPath: string) => boolean;
getCurrentMediaPath: () => string | null;
setCurrentMediaPath: (mediaPath: string | null) => void;
getCurrentMediaTitle: () => string | null;
setCurrentMediaTitle: (title: string | null) => void;
getPendingSubtitlePosition: () => SubtitlePosition | null;
loadSubtitlePosition: () => SubtitlePosition | null;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
getTracker: () => ImmersionTrackerLike | null;
getMpvClient: () => MpvClientLike | null;
defaultImmersionDbPath: string;
guessAnilistMediaInfo: (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
getCollapsibleSectionOpenState: (
section: keyof ResolvedConfig['anilist']['characterDictionary']['collapsibleSections'],
) => boolean;
isCharacterDictionaryEnabled: () => boolean;
isYoutubePlaybackActiveNow: () => boolean;
waitForYomitanMutationReady: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: AnilistCharacterDictionaryProfileScope,
) => Promise<boolean>;
getCharacterDictionaryConfig: () => CharacterDictionaryAutoSyncConfig;
notifyCharacterDictionaryAutoSyncStatus: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
characterDictionaryAutoSyncCompleteDeps: CharacterDictionaryAutoSyncCompleteDeps;
getMainWindow: () => BrowserWindowLike | null;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
) => boolean;
}
export interface DictionarySupportRuntime {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
getFieldGroupingResolver: () => FieldGroupingResolver;
setFieldGroupingResolver: (resolver: FieldGroupingResolver) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getConfiguredDbPath: () => string;
seedImmersionMediaFromCurrentMedia: () => Promise<void>;
syncImmersionMediaState: () => void;
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
updateCurrentMediaPath: (mediaPath: unknown) => void;
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
scheduleCharacterDictionarySync: () => void;
generateCharacterDictionaryForCurrentMedia: (
targetPath?: string,
) => Promise<CharacterDictionaryBuildResult>;
}
export function createDictionarySupportRuntime<TModal extends string>(
input: DictionarySupportRuntimeInput<TModal>,
): DictionarySupportRuntime {
const dictionaryRoots = createBuildDictionaryRootsMainHandler({
platform: input.platform,
dirname: input.dirname,
appPath: input.appPath,
resourcesPath: input.resourcesPath,
userDataPath: input.userDataPath,
appUserDataPath: input.appUserDataPath,
homeDir: input.homeDir,
appDataDir: input.appDataDir,
cwd: input.cwd,
joinPath: (...parts: string[]) => path.join(...parts),
});
const jlptRuntime = createJlptDictionaryRuntimeService(
createBuildJlptDictionaryRuntimeMainDepsHandler({
isJlptEnabled: () => input.isJlptEnabled(),
getDictionaryRoots: () => dictionaryRoots(),
getJlptDictionarySearchPaths,
setJlptLevelLookup: (lookup) => input.setJlptLevelLookup(lookup),
logInfo: (message) => input.logInfo(message),
})(),
);
const frequencyRuntime = createFrequencyDictionaryRuntimeService(
createBuildFrequencyDictionaryRuntimeMainDepsHandler({
isFrequencyDictionaryEnabled: () => input.isFrequencyDictionaryEnabled(),
getDictionaryRoots: () => dictionaryRoots(),
getFrequencyDictionarySearchPaths,
getSourcePath: () => input.getFrequencyDictionarySourcePath(),
setFrequencyRankLookup: (lookup) => input.setFrequencyRankLookup(lookup),
logInfo: (message) => input.logInfo(message),
})(),
);
let fieldGroupingResolver: FieldGroupingResolver = null;
let fieldGroupingResolverSequence = 0;
const getFieldGroupingResolver = (): FieldGroupingResolver => fieldGroupingResolver;
const setFieldGroupingResolver = (resolver: FieldGroupingResolver): void => {
if (!resolver) {
fieldGroupingResolver = null;
return;
}
const sequence = ++fieldGroupingResolverSequence;
fieldGroupingResolver = (choice) => {
if (sequence !== fieldGroupingResolverSequence) {
return;
}
resolver(choice);
};
};
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<TModal>({
getMainWindow: () => input.getMainWindow(),
getVisibleOverlayVisible: () => input.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => input.setVisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => input.getRestoreVisibleOverlayOnModalClose(),
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
input.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
const immersionMediaRuntime = createImmersionMediaRuntime({
getResolvedConfig: () => input.getResolvedConfig(),
defaultImmersionDbPath: input.defaultImmersionDbPath,
getTracker: () => input.getTracker(),
getMpvClient: () => input.getMpvClient(),
getCurrentMediaPath: () => input.getCurrentMediaPath(),
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
logDebug: (message) => (input.logDebug ?? input.logInfo)(message),
logInfo: (message) => input.logInfo(message),
});
const mediaRuntime = createMediaRuntimeService({
isRemoteMediaPath: (mediaPath) => input.isRemoteMediaPath(mediaPath),
loadSubtitlePosition: () => input.loadSubtitlePosition(),
getCurrentMediaPath: () => input.getCurrentMediaPath(),
getPendingSubtitlePosition: () => input.getPendingSubtitlePosition(),
getSubtitlePositionsDir: () => input.subtitlePositionsDir,
setCurrentMediaPath: (mediaPath) => input.setCurrentMediaPath(mediaPath),
clearPendingSubtitlePosition: () => input.clearPendingSubtitlePosition(),
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
broadcastSubtitlePosition: (position) => input.broadcastSubtitlePosition(position),
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
setCurrentMediaTitle: (title) => input.setCurrentMediaTitle(title),
});
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
userDataPath: input.userDataPath,
getCurrentMediaPath: () => input.getCurrentMediaPath(),
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
getCollapsibleSectionOpenState: (section) => input.getCollapsibleSectionOpenState(section),
now: () => Date.now(),
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
});
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: input.userDataPath,
getConfig: () => input.getCharacterDictionaryConfig(),
getOrCreateCurrentSnapshot: (targetPath, progress) =>
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
waitForYomitanMutationReady: () => input.waitForYomitanMutationReady(),
getYomitanDictionaryInfo: () => input.getYomitanDictionaryInfo(),
importYomitanDictionary: (zipPath) => input.importYomitanDictionary(zipPath),
deleteYomitanDictionary: (dictionaryTitle) => input.deleteYomitanDictionary(dictionaryTitle),
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
input.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
now: () => Date.now(),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: (timer) => clearTimeout(timer),
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
onSyncStatus: (event) => input.notifyCharacterDictionaryAutoSyncStatus(event),
onSyncComplete: (result) =>
handleCharacterDictionaryAutoSyncComplete(
result,
input.characterDictionaryAutoSyncCompleteDeps,
),
});
const scheduleCharacterDictionarySync = (): void => {
if (!input.isCharacterDictionaryEnabled() || input.isYoutubePlaybackActiveNow()) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync();
};
return {
ensureJlptDictionaryLookup: () => jlptRuntime.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => frequencyRuntime.ensureFrequencyDictionaryLookup(),
getFieldGroupingResolver,
setFieldGroupingResolver,
createFieldGroupingCallback: () => fieldGroupingOverlayRuntime.createFieldGroupingCallback(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(),
syncImmersionMediaState: () => immersionMediaRuntime.syncFromCurrentMediaState(),
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
updateCurrentMediaPath: (mediaPath) => mediaRuntime.updateCurrentMediaPath(mediaPath),
updateCurrentMediaTitle: (mediaTitle) => mediaRuntime.updateCurrentMediaTitle(mediaTitle),
scheduleCharacterDictionarySync,
generateCharacterDictionaryForCurrentMedia: (targetPath?: string) =>
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
};
}

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDiscordPresenceLifecycleRuntime } from './discord-presence-lifecycle-runtime';
test('discord presence lifecycle runtime starts service and publishes presence when enabled', async () => {
const calls: string[] = [];
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = null;
const runtime = createDiscordPresenceLifecycleRuntime({
getResolvedConfig: () => ({ discordPresence: { enabled: true } }),
getDiscordPresenceService: () => service as never,
setDiscordPresenceService: (next) => {
service = next as typeof service;
},
getMpvClient: () => null,
getCurrentMediaTitle: () => 'Demo',
getCurrentMediaPath: () => '/tmp/demo.mkv',
getCurrentSubtitleText: () => 'subtitle',
getPlaybackPaused: () => false,
getFallbackMediaDurationSec: () => 12,
createDiscordPresenceService: () => ({
start: async () => {
calls.push('start');
},
stop: async () => {
calls.push('stop');
},
publish: () => {
calls.push('publish');
},
}),
createDiscordRuntime: (input) => ({
refreshDiscordPresenceMediaDuration: async () => {},
publishDiscordPresence: () => {
calls.push(input.getCurrentMediaTitle() ?? 'unknown');
input.getDiscordPresenceService()?.publish({
mediaTitle: input.getCurrentMediaTitle(),
mediaPath: input.getCurrentMediaPath(),
subtitleText: input.getCurrentSubtitleText(),
currentTimeSec: null,
mediaDurationSec: input.getFallbackMediaDurationSec(),
paused: input.getPlaybackPaused(),
connected: false,
sessionStartedAtMs: input.getSessionStartedAtMs(),
});
},
}),
now: () => 123,
});
await runtime.initializeDiscordPresenceService();
assert.deepEqual(calls, ['start', 'Demo', 'publish']);
});

View File

@@ -0,0 +1,90 @@
import { createDiscordPresenceRuntime } from './runtime/discord-presence-runtime';
type DiscordPresenceConfigLike = {
enabled?: boolean;
};
type DiscordPresenceServiceLike = {
start: () => Promise<void>;
stop?: () => Promise<void>;
publish: (snapshot: {
mediaTitle: string | null;
mediaPath: string | null;
subtitleText: string;
currentTimeSec: number | null;
mediaDurationSec: number | null;
paused: boolean | null;
connected: boolean;
sessionStartedAtMs: number;
}) => void;
};
export interface DiscordPresenceLifecycleRuntimeInput {
getResolvedConfig: () => { discordPresence: DiscordPresenceConfigLike };
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
setDiscordPresenceService: (service: DiscordPresenceServiceLike | null) => void;
getMpvClient: () => {
connected?: boolean;
currentTimePos?: number | null;
requestProperty: (name: string) => Promise<unknown>;
} | null;
getCurrentMediaTitle: () => string | null;
getCurrentMediaPath: () => string | null;
getCurrentSubtitleText: () => string;
getPlaybackPaused: () => boolean | null;
getFallbackMediaDurationSec: () => number | null;
createDiscordPresenceService: (config: unknown) => DiscordPresenceServiceLike;
createDiscordRuntime?: typeof createDiscordPresenceRuntime;
now?: () => number;
}
export interface DiscordPresenceLifecycleRuntime {
publishDiscordPresence: () => void;
initializeDiscordPresenceService: () => Promise<void>;
stopDiscordPresenceService: () => Promise<void>;
}
export function createDiscordPresenceLifecycleRuntime(
input: DiscordPresenceLifecycleRuntimeInput,
): DiscordPresenceLifecycleRuntime {
let discordPresenceMediaDurationSec: number | null = null;
const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now();
const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({
getDiscordPresenceService: () => input.getDiscordPresenceService(),
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
getMpvClient: () => input.getMpvClient(),
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
getCurrentMediaPath: () => input.getCurrentMediaPath(),
getCurrentSubtitleText: () => input.getCurrentSubtitleText(),
getPlaybackPaused: () => input.getPlaybackPaused(),
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs,
getMediaDurationSec: () => discordPresenceMediaDurationSec,
setMediaDurationSec: (next) => {
discordPresenceMediaDurationSec = next;
},
});
return {
publishDiscordPresence: () => {
discordPresenceRuntime.publishDiscordPresence();
},
initializeDiscordPresenceService: async () => {
if (input.getResolvedConfig().discordPresence.enabled !== true) {
input.setDiscordPresenceService(null);
return;
}
input.setDiscordPresenceService(
input.createDiscordPresenceService(input.getResolvedConfig().discordPresence),
);
await input.getDiscordPresenceService()?.start();
discordPresenceRuntime.publishDiscordPresence();
},
stopDiscordPresenceService: async () => {
await input.getDiscordPresenceService()?.stop?.();
input.setDiscordPresenceService(null);
},
};
}

View File

@@ -0,0 +1,92 @@
import { BrowserWindow } from 'electron';
import { getYomitanDictionaryInfo } from '../core/services';
import type { ResolvedConfig } from '../types';
import { createFirstRunRuntime } from './first-run-runtime';
import type { AppState } from './state';
export interface FirstRunRuntimeCoordinatorInput {
platform: NodeJS.Platform;
configDir: string;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
appPath: string;
resourcesPath: string;
appDataDir: string;
desktopDir: string;
appState: Pick<AppState, 'firstRunSetupWindow' | 'firstRunSetupCompleted' | 'backgroundMode'>;
getResolvedConfig: () => ResolvedConfig;
yomitan: {
ensureYomitanExtensionLoaded: () => Promise<unknown>;
getParserRuntimeDeps: () => Parameters<typeof getYomitanDictionaryInfo>[0];
openYomitanSettings: () => boolean;
};
overlay: {
ensureTray: () => void;
hasTray: () => boolean;
};
actions: {
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
},
) => boolean;
requestAppQuit: () => void;
};
logger: {
error: (message: string, error: unknown) => void;
info: (message: string, ...args: unknown[]) => void;
};
}
export function createFirstRunRuntimeCoordinator(input: FirstRunRuntimeCoordinatorInput) {
return createFirstRunRuntime<BrowserWindow>({
platform: input.platform,
configDir: input.configDir,
homeDir: input.homeDir,
xdgConfigHome: input.xdgConfigHome,
binaryPath: input.binaryPath,
appPath: input.appPath,
resourcesPath: input.resourcesPath,
appDataDir: input.appDataDir,
desktopDir: input.desktopDir,
getYomitanDictionaryCount: async () => {
await input.yomitan.ensureYomitanExtensionLoaded();
const dictionaries = await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
error: (message, ...args) => input.logger.error(message, args[0]),
info: (message, ...args) => input.logger.info(message, ...args),
});
return dictionaries.length;
},
isExternalYomitanConfigured: () =>
input.getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
createBrowserWindow: (options) => {
const window = new BrowserWindow(options);
input.appState.firstRunSetupWindow = window;
window.on('closed', () => {
input.appState.firstRunSetupWindow = null;
});
return window;
},
writeShortcutLink: (shortcutPath, operation, details) =>
input.actions.writeShortcutLink(shortcutPath, operation, details),
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
shouldQuitWhenClosedIncomplete: () => !input.appState.backgroundMode,
quitApp: () => input.actions.requestAppQuit(),
logError: (message, error) => input.logger.error(message, error),
onStateChanged: (state) => {
input.appState.firstRunSetupCompleted = state.status === 'completed';
if (input.overlay.hasTray()) {
input.overlay.ensureTray();
}
},
});
}

View File

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createFirstRunRuntime } from './first-run-runtime';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-runtime-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
function createMockSetupWindow() {
const calls: string[] = [];
let closedHandler: (() => void) | null = null;
let navigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
return {
calls,
window: {
webContents: {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => {
if (event === 'will-navigate') {
navigateHandler = handler;
}
},
},
loadURL: async (url: string) => {
calls.push(`load:${url.slice(0, 24)}`);
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') {
closedHandler = handler;
}
},
isDestroyed: () => false,
close: () => {
calls.push('close');
closedHandler?.();
},
focus: () => {
calls.push('focus');
},
triggerNavigate: (url: string) => {
navigateHandler?.(
{
preventDefault: () => {
calls.push('prevent-default');
},
},
url,
);
},
},
};
}
test('first-run runtime focuses an existing window instead of creating a new one', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let createCount = 0;
const mock = createMockSetupWindow();
const runtime = createFirstRunRuntime({
platform: 'darwin',
configDir,
homeDir: os.homedir(),
binaryPath: process.execPath,
appPath: '/app',
resourcesPath: '/resources',
appDataDir: path.join(root, 'appData'),
desktopDir: path.join(root, 'desktop'),
getYomitanDictionaryCount: async () => 1,
isExternalYomitanConfigured: () => false,
createBrowserWindow: () => {
createCount += 1;
return mock.window;
},
writeShortcutLink: () => true,
openYomitanSettings: () => false,
shouldQuitWhenClosedIncomplete: () => true,
quitApp: () => {
throw new Error('quit should not be called');
},
logError: () => {
throw new Error('logError should not be called');
},
});
runtime.openFirstRunSetupWindow();
runtime.openFirstRunSetupWindow();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(createCount, 1);
assert.equal(mock.calls.filter((call) => call === 'focus').length, 1);
});
});
test('first-run runtime closes the setup window after completion', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const events: string[] = [];
const mock = createMockSetupWindow();
const runtime = createFirstRunRuntime({
platform: 'linux',
configDir,
homeDir: os.homedir(),
binaryPath: process.execPath,
appPath: '/app',
resourcesPath: '/resources',
appDataDir: path.join(root, 'appData'),
desktopDir: path.join(root, 'desktop'),
getYomitanDictionaryCount: async () => 1,
isExternalYomitanConfigured: () => false,
createBrowserWindow: () => mock.window,
writeShortcutLink: () => true,
openYomitanSettings: () => false,
shouldQuitWhenClosedIncomplete: () => true,
quitApp: () => {
events.push('quit');
},
logError: (message, error) => {
events.push(`${message}:${String(error)}`);
},
onStateChanged: (state) => {
events.push(state.status);
},
});
runtime.openFirstRunSetupWindow();
await new Promise((resolve) => setTimeout(resolve, 0));
mock.window.triggerNavigate('subminer://first-run-setup?action=finish');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(runtime.isSetupCompleted(), true);
assert.equal(events[0], 'in_progress');
assert.equal(events.at(-1), 'completed');
assert.equal(mock.calls.includes('close'), true);
assert.equal(events.includes('quit'), false);
});
});

View File

@@ -0,0 +1,235 @@
import {
createFirstRunSetupService,
shouldAutoOpenFirstRunSetup,
type FirstRunSetupService,
type PluginInstallResult,
type SetupStatusSnapshot,
} from './runtime/first-run-setup-service';
import type { SetupState } from '../shared/setup-state';
import {
buildFirstRunSetupHtml,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './runtime/first-run-setup-window';
import { createCreateFirstRunSetupWindowHandler } from './runtime/setup-window-factory';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
syncInstalledFirstRunPluginBinaryPath,
} from './runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './runtime/windows-mpv-shortcuts';
import { resolveDefaultMpvInstallPaths } from '../shared/setup-state';
export interface FirstRunSetupWindowLike {
webContents: {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
loadURL: (url: string) => Promise<void> | void;
on: (event: 'closed', handler: () => void) => void;
isDestroyed: () => boolean;
close: () => void;
focus: () => void;
}
export interface FirstRunRuntimeInput<
TWindow extends FirstRunSetupWindowLike = FirstRunSetupWindowLike,
> {
platform: NodeJS.Platform;
configDir: string;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
appPath: string;
resourcesPath: string;
appDataDir: string;
desktopDir: string;
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured: () => boolean;
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
},
) => boolean;
openYomitanSettings: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
quitApp: () => void;
logError: (message: string, error: unknown) => void;
onStateChanged?: (state: SetupState) => void;
}
export interface FirstRunRuntime {
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
openFirstRunSetupWindow: () => void;
}
export function createFirstRunRuntime<TWindow extends FirstRunSetupWindowLike>(
input: FirstRunRuntimeInput<TWindow>,
): FirstRunRuntime {
syncInstalledFirstRunPluginBinaryPath({
platform: input.platform,
homeDir: input.homeDir,
xdgConfigHome: input.xdgConfigHome,
binaryPath: input.binaryPath,
});
const firstRunSetupService = createFirstRunSetupService({
platform: input.platform,
configDir: input.configDir,
getYomitanDictionaryCount: input.getYomitanDictionaryCount,
isExternalYomitanConfigured: input.isExternalYomitanConfigured,
detectPluginInstalled: () =>
detectInstalledFirstRunPlugin(
resolveDefaultMpvInstallPaths(input.platform, input.homeDir, input.xdgConfigHome),
),
installPlugin: async (): Promise<PluginInstallResult> =>
installFirstRunPluginToDefaultLocation({
platform: input.platform,
homeDir: input.homeDir,
xdgConfigHome: input.xdgConfigHome,
dirname: __dirname,
appPath: input.appPath,
resourcesPath: input.resourcesPath,
binaryPath: input.binaryPath,
}),
detectWindowsMpvShortcuts: async () =>
detectWindowsMpvShortcuts(
resolveWindowsMpvShortcutPaths({
appDataDir: input.appDataDir,
desktopDir: input.desktopDir,
}),
),
applyWindowsMpvShortcuts: async (preferences) =>
applyWindowsMpvShortcuts({
preferences,
paths: resolveWindowsMpvShortcutPaths({
appDataDir: input.appDataDir,
desktopDir: input.desktopDir,
}),
exePath: input.binaryPath,
writeShortcutLink: (shortcutPath, operation, details) =>
input.writeShortcutLink(shortcutPath, operation, details),
}),
onStateChanged: (state) => {
input.onStateChanged?.(state);
},
});
let firstRunSetupWindow: TWindow | null = null;
let firstRunSetupMessage: string | null = null;
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => firstRunSetupWindow,
});
const createSetupWindow = createCreateFirstRunSetupWindowHandler({
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) =>
input.createBrowserWindow(options),
});
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => maybeFocusExistingFirstRunSetupWindow(),
createSetupWindow: () => {
const window = createSetupWindow();
firstRunSetupWindow = window;
return window;
},
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
return {
...snapshot,
message: firstRunSetupMessage,
};
},
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission) => {
if (submission.action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'configure-windows-mpv-shortcuts') {
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
startMenuEnabled: submission.startMenuEnabled === true,
desktopEnabled: submission.desktopEnabled === true,
});
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = input.openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'skip-plugin') {
await firstRunSetupService.skipPluginInstall();
firstRunSetupMessage = 'mpv plugin installation skipped.';
return;
}
const snapshot = await firstRunSetupService.markSetupCompleted();
if (snapshot.state.status === 'completed') {
firstRunSetupMessage = null;
return { closeWindow: true };
}
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
return undefined;
},
markSetupInProgress: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupInProgress();
},
markSetupCancelled: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupCancelled();
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => input.shouldQuitWhenClosedIncomplete(),
quitApp: () => input.quitApp(),
clearSetupWindow: () => {
firstRunSetupWindow = null;
},
setSetupWindow: (window) => {
firstRunSetupWindow = window;
},
encodeURIComponent: (value) => encodeURIComponent(value),
logError: (message, error) => input.logError(message, error),
});
return {
ensureSetupStateInitialized: () => firstRunSetupService.ensureSetupStateInitialized(),
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => {
if (firstRunSetupService.isSetupCompleted()) {
return;
}
openFirstRunSetupWindowHandler();
},
};
}
export { shouldAutoOpenFirstRunSetup };

View File

@@ -1,6 +1,6 @@
import * as path from 'path';
import type { FrequencyDictionaryLookup } from '../types';
import { createFrequencyDictionaryLookup } from '../core/services';
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
export interface FrequencyDictionarySearchPathDeps {
getDictionaryRoots: () => string[];

View File

@@ -0,0 +1,55 @@
import path from 'node:path';
import { mergeAiConfig } from '../ai/config';
import { AnkiIntegration } from '../anki-integration';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import type { ResolvedConfig } from '../types';
import type { AnkiConnectConfig } from '../types/anki';
export async function runHeadlessKnownWordRefresh(input: {
resolvedConfig: ResolvedConfig;
runtimeOptionsManager: {
getEffectiveAnkiConnectConfig: (config: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
userDataPath: string;
logger: {
error: (message: string, error?: unknown) => void;
};
requestAppQuit: () => void;
}): Promise<void> {
if (input.resolvedConfig.ankiConnect.enabled !== true) {
input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
process.exitCode = 1;
input.requestAppQuit();
return;
}
const effectiveAnkiConfig =
input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ??
input.resolvedConfig.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig,
new SubtitleTimingTracker(),
{ send: () => undefined } as never,
undefined,
undefined,
async () => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: false,
cancelled: true,
}),
path.join(input.userDataPath, 'known-words-cache.json'),
mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai),
);
try {
await integration.refreshKnownWordCache();
} catch (error) {
input.logger.error('Headless known-word refresh failed:', error);
process.exitCode = 1;
} finally {
integration.stop();
input.requestAppQuit();
}
}

View File

@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../cli/args';
import type { LogLevelSource } from '../logger';
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
test('headless startup runtime returns callable handlers and applies startup state', () => {
const calls: string[] = [];
const runtime = createHeadlessStartupRuntime<
{ mode: string },
{ startAppLifecycle: (args: CliArgs) => void }
>({
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
bootstrap: {
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: (_level: string, _source: LogLevelSource) => {},
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: CliArgs) => {
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
},
},
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
},
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: (deps) => {
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
return { mode: 'started' };
},
applyStartupState: (state: { mode: string }) => {
calls.push(`apply:${state.mode}`);
},
});
assert.equal(typeof runtime.appLifecycleRuntimeRunner, 'function');
assert.equal(typeof runtime.runAndApplyStartupState, 'function');
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
});
test('headless startup runtime accepts grouped app lifecycle input', () => {
const calls: string[] = [];
const runtime = createHeadlessStartupRuntime<
{ mode: string },
{ startAppLifecycle: (args: CliArgs) => void }
>({
appLifecycle: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
bootstrap: {
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: (_level: string, _source: LogLevelSource) => {},
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: CliArgs) => {
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
},
},
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
},
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: (deps) => {
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
return { mode: 'started' };
},
applyStartupState: (state: { mode: string }) => {
calls.push(`apply:${state.mode}`);
},
});
runtime.appLifecycleRuntimeRunner({ command: 'start' } as unknown as CliArgs);
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']);
});

View File

@@ -0,0 +1,106 @@
import type { CliArgs } from '../cli/args';
import type { LogLevelSource } from '../logger';
import type { ResolvedConfig } from '../types';
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
import { createAppLifecycleDepsRuntime, startAppLifecycle } from '../core/services/app-lifecycle';
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
import type { AppLifecycleRuntimeRunnerParams } from './startup-lifecycle';
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
import { createStartupBootstrapRuntimeDeps } from './startup';
import { composeHeadlessStartupHandlers } from './runtime/composers/headless-startup-composer';
import { createAppLifecycleRuntimeDeps } from './app-lifecycle';
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './runtime/startup-lifecycle-main-deps';
export interface HeadlessStartupBootstrapInput {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
shouldStartApp: (args: CliArgs) => boolean;
getDefaultSocketPath: () => string;
defaultTexthookerPort: number;
configDir: string;
defaultConfig: ResolvedConfig;
generateConfigTemplate: (config: ResolvedConfig) => string;
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => Promise<number>;
setExitCode: (code: number) => void;
quitApp: () => void;
logGenerateConfigError: (message: string) => void;
startAppLifecycle: (args: CliArgs) => void;
}
export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams;
export interface HeadlessStartupRuntimeInput<
TStartupState,
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
> {
appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions;
appLifecycle?: HeadlessStartupAppLifecycleInput;
bootstrap: HeadlessStartupBootstrapInput;
createAppLifecycleRuntimeRunner?: (
params: AppLifecycleDepsRuntimeOptions,
) => (args: CliArgs) => void;
createStartupBootstrapRuntimeDeps?: (
deps: StartupBootstrapRuntimeFactoryDeps,
) => TStartupBootstrapRuntimeDeps;
runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState;
applyStartupState: (startupState: TStartupState) => void;
}
export interface HeadlessStartupRuntime<TStartupState> {
appLifecycleRuntimeRunner: (args: CliArgs) => void;
runAndApplyStartupState: () => TStartupState;
}
export function createHeadlessStartupRuntime<
TStartupState,
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
>(
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>,
): HeadlessStartupRuntime<TStartupState> {
const appLifecycleRuntimeRunnerMainDeps =
input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle;
if (!appLifecycleRuntimeRunnerMainDeps) {
throw new Error('Headless startup runtime needs app lifecycle runtime runner deps');
}
const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
appLifecycleRuntimeRunnerMainDeps,
)(),
createAppLifecycleRuntimeRunner:
input.createAppLifecycleRuntimeRunner ??
((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) =>
startAppLifecycle(
args,
createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)),
)),
buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({
...input.bootstrap,
startAppLifecycle,
}),
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
input.createStartupBootstrapRuntimeDeps
? input.createStartupBootstrapRuntimeDeps(deps)
: (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps),
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
applyStartupState: input.applyStartupState,
},
});
return {
appLifecycleRuntimeRunner,
runAndApplyStartupState,
};
}

View File

@@ -0,0 +1,253 @@
import * as path from 'node:path';
import type { BrowserWindow } from 'electron';
import type { AnkiIntegration } from '../anki-integration';
import type {
JimakuApiResponse,
JimakuLanguagePreference,
KikuFieldGroupingChoice,
ResolvedConfig,
SubsyncManualRunRequest,
SubsyncResult,
} from '../types';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from '../jimaku/utils';
import { applyRuntimeOptionResultRuntime } from '../core/services/runtime-options-ipc';
import {
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
sendMpvCommandRuntime,
} from '../core/services';
import type { ConfigService } from '../config';
import { applyControllerConfigUpdate } from './controller-config-update.js';
import type { AnilistRuntime } from './anilist-runtime';
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
import { createIpcRuntimeFromMainState, type IpcRuntime } from './ipc-runtime';
import type { MiningRuntime } from './mining-runtime';
import type { MpvRuntime } from './mpv-runtime';
import type { OverlayModalRuntime } from './overlay-runtime';
import type { OverlayUiRuntime } from './overlay-ui-runtime';
import type { AppState } from './state';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { YoutubeRuntime } from './youtube-runtime';
import { resolveSubtitleStyleForRenderer } from './runtime/domains/overlay';
import type { ShortcutsRuntime } from './shortcuts-runtime';
type OverlayManagerLike = {
getMainWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
};
type OverlayUiLike = Pick<
OverlayUiRuntime<BrowserWindow>,
| 'broadcastRuntimeOptionsChanged'
| 'handleOverlayModalClosed'
| 'openRuntimeOptionsPalette'
| 'toggleVisibleOverlay'
>;
type OverlayContentMeasurementStoreLike = {
report: (payload: unknown) => void;
};
type ConfigDerivedRuntimeLike = {
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>;
};
type SubsyncRuntimeLike = {
triggerFromConfig: () => Promise<void>;
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
};
export interface IpcRuntimeBootstrapInput {
appState: AppState;
userDataPath: string;
getResolvedConfig: () => ResolvedConfig;
configService: Pick<ConfigService, 'getRawConfig' | 'patchRawConfig'>;
overlay: {
manager: OverlayManagerLike;
getOverlayUi: () => OverlayUiLike | undefined;
modalRuntime: Pick<OverlayModalRuntime, 'notifyOverlayModalOpened'>;
contentMeasurementStore: OverlayContentMeasurementStoreLike;
};
subtitle: SubtitleRuntime;
mpvRuntime: Pick<MpvRuntime, 'shiftSubtitleDelayToAdjacentCue' | 'showMpvOsd'>;
shortcuts: Pick<ShortcutsRuntime, 'getConfiguredShortcuts'>;
actions: {
requestAppQuit: () => void;
openYomitanSettings: () => boolean;
showDesktopNotification: (title: string, options: { body?: string }) => void;
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
};
runtimes: {
youtube: Pick<YoutubeRuntime, 'openYoutubeTrackPickerFromPlayback' | 'resolveActivePicker'>;
anilist: Pick<
AnilistRuntime,
| 'getStatusSnapshot'
| 'clearTokenState'
| 'openAnilistSetupWindow'
| 'getQueueStatusSnapshot'
| 'processNextAnilistRetryUpdate'
>;
mining: Pick<MiningRuntime, 'appendClipboardVideoToQueue'>;
dictionarySupport: Pick<
DictionarySupportRuntime,
| 'createFieldGroupingCallback'
| 'getFieldGroupingResolver'
| 'setFieldGroupingResolver'
| 'resolveMediaPathForJimaku'
>;
configDerived: ConfigDerivedRuntimeLike;
subsync: SubsyncRuntimeLike;
};
}
export function createIpcRuntimeBootstrap(input: IpcRuntimeBootstrapInput): IpcRuntime {
return createIpcRuntimeFromMainState({
mpv: {
mainDeps: {
triggerSubsyncFromConfig: () => input.runtimes.subsync.triggerFromConfig(),
openRuntimeOptionsPalette: () => input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => input.runtimes.youtube.openYoutubeTrackPickerFromPlayback(),
cycleRuntimeOption: (id, direction) => {
if (!input.appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
return applyRuntimeOptionResultRuntime(
input.appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => input.mpvRuntime.showMpvOsd(text),
);
},
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(input.appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(input.appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) =>
input.mpvRuntime.shiftSubtitleDelayToAdjacentCue(direction),
sendMpvCommand: (rawCommand) => sendMpvCommandRuntime(input.appState.mpvClient, rawCommand),
getMpvClient: () => input.appState.mpvClient,
isMpvConnected: () =>
Boolean(input.appState.mpvClient && input.appState.mpvClient.connected),
hasRuntimeOptionsManager: () => input.appState.runtimeOptionsManager !== null,
},
runSubsyncManualFromIpc: (request) => input.runtimes.subsync.runManualFromIpc(request),
},
runtimeOptions: {
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
},
main: {
window: {
getMainWindow: () => input.overlay.manager.getMainWindow(),
getVisibleOverlayVisibility: () => input.overlay.manager.getVisibleOverlayVisible(),
focusMainWindow: () => {
const mainWindow = input.overlay.manager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
},
onOverlayModalClosed: (modal) =>
input.overlay.getOverlayUi()?.handleOverlayModalClosed(modal),
onOverlayModalOpened: (modal) => {
input.overlay.modalRuntime.notifyOverlayModalOpened(modal);
},
onYoutubePickerResolve: (request) => input.runtimes.youtube.resolveActivePicker(request),
openYomitanSettings: () => input.actions.openYomitanSettings(),
quitApp: () => input.actions.requestAppQuit(),
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
},
subtitle: {
tokenizeCurrentSubtitle: async () => await input.subtitle.tokenizeCurrentSubtitle(),
getCurrentSubtitleRaw: () => input.appState.currentSubText,
getCurrentSubtitleAss: () => input.appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => await input.subtitle.getSubtitleSidebarSnapshot(),
getPlaybackPaused: () => input.appState.playbackPaused,
getSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
getSubtitleStyle: () => resolveSubtitleStyleForRenderer(input.getResolvedConfig()),
saveSubtitlePosition: (position) => input.subtitle.saveSubtitlePosition(position),
getMecabTokenizer: () => input.appState.mecabTokenizer,
getKeybindings: () => input.appState.keybindings,
getConfiguredShortcuts: () => input.shortcuts.getConfiguredShortcuts(),
getStatsToggleKey: () => input.getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => input.getResolvedConfig().stats.markWatchedKey,
getSecondarySubMode: () => input.appState.secondarySubMode,
},
controller: {
getControllerConfig: () => input.getResolvedConfig().controller,
saveControllerConfig: (update) => {
const currentRawConfig = input.configService.getRawConfig();
input.configService.patchRawConfig({
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
});
},
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
input.configService.patchRawConfig({
controller: {
preferredGamepadId,
preferredGamepadLabel,
},
});
},
},
runtime: {
getMpvClient: () => input.appState.mpvClient,
getAnkiConnectStatus: () => input.appState.ankiIntegration !== null,
getRuntimeOptions: () => input.appState.runtimeOptionsManager?.listOptions() ?? [],
reportOverlayContentBounds: (payload) => {
input.overlay.contentMeasurementStore.report(payload);
},
getImmersionTracker: () => input.appState.immersionTracker,
},
anilist: {
getStatus: () => input.runtimes.anilist.getStatusSnapshot(),
clearToken: () => input.runtimes.anilist.clearTokenState(),
openSetup: () => input.runtimes.anilist.openAnilistSetupWindow(),
getQueueStatus: () => input.runtimes.anilist.getQueueStatusSnapshot(),
retryQueueNow: () => input.runtimes.anilist.processNextAnilistRetryUpdate(),
},
mining: {
appendClipboardVideoToQueue: () => input.runtimes.mining.appendClipboardVideoToQueue(),
},
},
ankiJimaku: {
patchAnkiConnectEnabled: (enabled) => {
input.configService.patchRawConfig({ ankiConnect: { enabled } });
},
getResolvedConfig: () => input.getResolvedConfig(),
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
getMpvClient: () => input.appState.mpvClient,
getAnkiIntegration: () => input.appState.ankiIntegration,
setAnkiIntegration: (integration) => input.actions.setAnkiIntegration(integration),
getKnownWordCacheStatePath: () => path.join(input.userDataPath, 'known-words-cache.json'),
showDesktopNotification: input.actions.showDesktopNotification,
createFieldGroupingCallback: () =>
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () =>
input.overlay.getOverlayUi()?.broadcastRuntimeOptionsChanged(),
getFieldGroupingResolver: () => input.runtimes.dictionarySupport.getFieldGroupingResolver(),
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
input.runtimes.dictionarySupport.setFieldGroupingResolver(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(input.runtimes.dictionarySupport.resolveMediaPathForJimaku(mediaPath)),
getCurrentMediaPath: () => input.appState.currentMediaPath,
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
): Promise<JimakuApiResponse<T>> =>
input.runtimes.configDerived.jimakuFetchJson<T>(endpoint, query),
getJimakuMaxEntryResults: () => input.runtimes.configDerived.getJimakuMaxEntryResults(),
getJimakuLanguagePreference: () => input.runtimes.configDerived.getJimakuLanguagePreference(),
resolveJimakuApiKey: () => input.runtimes.configDerived.resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) =>
downloadToFile(url, destPath, headers),
},
});
}

View File

@@ -0,0 +1,43 @@
import {
createIpcDepsRuntime,
registerAnkiJimakuIpcRuntime,
registerIpcHandlers,
} from '../core/services';
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
import {
createAnkiJimakuIpcRuntimeServiceDeps,
createMainIpcRuntimeServiceDeps,
createRuntimeOptionsIpcDeps,
} from './dependencies';
import type {
AnkiJimakuIpcRuntimeServiceDepsParams,
MainIpcRuntimeServiceDepsParams,
} from './dependencies';
import type { RegisterIpcRuntimeServicesParams } from './ipc-runtime';
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
}
export function registerAnkiJimakuIpcRuntimeServices(
params: AnkiJimakuIpcRuntimeServiceDepsParams,
): void {
registerAnkiJimakuIpcRuntime(
createAnkiJimakuIpcRuntimeServiceDeps(params),
registerAnkiJimakuIpcHandlers,
);
}
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
showMpvOsd: params.runtimeOptions.showMpvOsd,
});
registerMainIpcRuntimeServices({
...params.mainDeps,
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
});
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
}

View File

@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createIpcRuntime, createIpcRuntimeFromMainState } from './ipc-runtime';
function createBaseRuntimeInput(capturedRegistration: { value: unknown | null }) {
const manualResult = { ok: true, summary: 'done' };
const main = {
window: {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
onOverlayModalOpened: () => {},
onYoutubePickerResolve: async () => ({ ok: true }) as never,
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
},
subtitle: {
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => null as never,
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
getKeybindings: () => [],
getConfiguredShortcuts: () => null,
getStatsToggleKey: () => '',
getMarkWatchedKey: () => '',
getSecondarySubMode: () => 'hover',
},
controller: {
getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {},
saveControllerPreference: () => {},
},
runtime: {
getMpvClient: () => null,
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
getImmersionTracker: () => null,
},
anilist: {
getStatus: () => null,
clearToken: () => {},
openSetup: () => {},
getQueueStatus: () => null,
retryQueueNow: async () => ({ ok: true, message: 'ok' }) as never,
},
mining: {
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
},
};
const ankiJimaku = {
patchAnkiConnectEnabled: () => {},
getResolvedConfig: () => ({}),
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getAnkiIntegration: () => null,
setAnkiIntegration: () => {},
getKnownWordCacheStatePath: () => '/tmp/known-words.json',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({}) as never,
broadcastRuntimeOptionsChanged: () => {},
getFieldGroupingResolver: () => null,
setFieldGroupingResolver: () => {},
parseMediaInfo: () => ({}) as never,
getCurrentMediaPath: () => null,
jimakuFetchJson: async () => ({ data: null, error: null }) as never,
getJimakuMaxEntryResults: () => 5,
getJimakuLanguagePreference: () => 'ja' as const,
resolveJimakuApiKey: async () => null,
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }) as never,
};
return {
mpv: {
mainDeps: {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
shiftSubDelayToAdjacentSubtitle: async () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true,
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async () => manualResult as never,
},
main,
ankiJimaku,
registration: {
runtimeOptions: {
getRuntimeOptionsManager: () => null,
showMpvOsd: () => {},
},
main,
ankiJimaku,
registerIpcRuntimeServices: (params: unknown) => {
capturedRegistration.value = params;
},
},
registerIpcRuntimeServices: (params: unknown) => {
capturedRegistration.value = params;
},
manualResult,
};
}
test('ipc runtime registers composed IPC handlers from explicit registration input', async () => {
const capturedRegistration = { value: null as unknown | null };
const input = createBaseRuntimeInput(capturedRegistration);
const runtime = createIpcRuntime({
mpv: input.mpv,
registration: input.registration,
});
runtime.registerIpcRuntimeHandlers();
assert.ok(capturedRegistration.value);
const registration = capturedRegistration.value as {
runtimeOptions: { showMpvOsd: unknown };
mainDeps: {
handleMpvCommand: unknown;
runSubsyncManual: (payload: unknown) => Promise<unknown>;
};
};
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
assert.deepEqual(
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
input.manualResult,
);
});
test('ipc runtime builds grouped registration input from main state', async () => {
const capturedRegistration = { value: null as unknown | null };
const input = createBaseRuntimeInput(capturedRegistration);
const runtime = createIpcRuntimeFromMainState({
mpv: input.mpv,
runtimeOptions: input.registration.runtimeOptions,
main: input.main,
ankiJimaku: input.ankiJimaku,
});
runtime.registerIpcRuntimeHandlers();
assert.ok(capturedRegistration.value);
const registration = capturedRegistration.value as {
runtimeOptions: { showMpvOsd: unknown };
mainDeps: {
handleMpvCommand: unknown;
runSubsyncManual: (payload: unknown) => Promise<unknown>;
};
};
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
assert.deepEqual(
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
input.manualResult,
);
});

View File

@@ -1,17 +1,15 @@
import {
createIpcDepsRuntime,
registerAnkiJimakuIpcRuntime,
registerIpcHandlers,
} from '../core/services';
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
import {
createAnkiJimakuIpcRuntimeServiceDeps,
import type {
AnkiJimakuIpcRuntimeServiceDepsParams,
createMainIpcRuntimeServiceDeps,
MainIpcRuntimeServiceDepsParams,
createRuntimeOptionsIpcDeps,
RuntimeOptionsIpcDepsParams,
} from './dependencies';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './dependencies';
import {
handleMpvCommandFromIpcRuntime,
type MpvCommandFromIpcRuntimeDeps,
} from './ipc-mpv-command';
import { registerIpcRuntimeServices } from './ipc-runtime-services';
import { composeIpcRuntimeHandlers } from './runtime/composers/ipc-runtime-composer';
export interface RegisterIpcRuntimeServicesParams {
runtimeOptions: RuntimeOptionsIpcDepsParams;
@@ -19,28 +17,131 @@ export interface RegisterIpcRuntimeServicesParams {
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
}
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
export interface IpcRuntimeMainInput {
window: Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
| 'getMainWindow'
| 'getVisibleOverlayVisibility'
| 'focusMainWindow'
| 'onOverlayModalClosed'
| 'onOverlayModalOpened'
| 'onYoutubePickerResolve'
| 'openYomitanSettings'
| 'quitApp'
| 'toggleVisibleOverlay'
>;
subtitle: Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
| 'tokenizeCurrentSubtitle'
| 'getCurrentSubtitleRaw'
| 'getCurrentSubtitleAss'
| 'getSubtitleSidebarSnapshot'
| 'getPlaybackPaused'
| 'getSubtitlePosition'
| 'getSubtitleStyle'
| 'saveSubtitlePosition'
| 'getMecabTokenizer'
| 'getKeybindings'
| 'getConfiguredShortcuts'
| 'getStatsToggleKey'
| 'getMarkWatchedKey'
| 'getSecondarySubMode'
>;
controller: Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
'getControllerConfig' | 'saveControllerConfig' | 'saveControllerPreference'
>;
runtime: Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
'getMpvClient' | 'getAnkiConnectStatus' | 'getRuntimeOptions' | 'reportOverlayContentBounds'
> &
Partial<Pick<RegisterIpcRuntimeServicesParams['mainDeps'], 'getImmersionTracker'>>;
anilist: {
getStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistStatus'];
clearToken: RegisterIpcRuntimeServicesParams['mainDeps']['clearAnilistToken'];
openSetup: RegisterIpcRuntimeServicesParams['mainDeps']['openAnilistSetup'];
getQueueStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistQueueStatus'];
retryQueueNow: RegisterIpcRuntimeServicesParams['mainDeps']['retryAnilistQueueNow'];
};
mining: {
appendClipboardVideoToQueue: RegisterIpcRuntimeServicesParams['mainDeps']['appendClipboardVideoToQueue'];
};
}
export function registerAnkiJimakuIpcRuntimeServices(
params: AnkiJimakuIpcRuntimeServiceDepsParams,
): void {
registerAnkiJimakuIpcRuntime(
createAnkiJimakuIpcRuntimeServiceDeps(params),
registerAnkiJimakuIpcHandlers,
);
export interface IpcRuntimeRegistrationInput {
runtimeOptions: RuntimeOptionsIpcDepsParams;
main: IpcRuntimeMainInput;
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
}
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
showMpvOsd: params.runtimeOptions.showMpvOsd,
});
registerMainIpcRuntimeServices({
...params.mainDeps,
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
});
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
export interface IpcRuntimeInput {
mpv: {
mainDeps: MpvCommandFromIpcRuntimeDeps;
handleMpvCommandFromIpcRuntime: (
command: (string | number)[],
deps: MpvCommandFromIpcRuntimeDeps,
) => void;
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
};
registration: IpcRuntimeRegistrationInput;
}
export interface IpcRuntime {
registerIpcRuntimeHandlers: () => void;
}
export interface IpcRuntimeFromMainStateInput {
mpv: {
mainDeps: MpvCommandFromIpcRuntimeDeps;
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
};
runtimeOptions: RuntimeOptionsIpcDepsParams;
main: IpcRuntimeMainInput;
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
}
export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime {
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: input.mpv.mainDeps,
handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime,
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
registration: {
runtimeOptions: input.registration.runtimeOptions,
mainDeps: {
...input.registration.main.window,
...input.registration.main.subtitle,
...input.registration.main.controller,
...input.registration.main.runtime,
getAnilistStatus: input.registration.main.anilist.getStatus,
clearAnilistToken: input.registration.main.anilist.clearToken,
openAnilistSetup: input.registration.main.anilist.openSetup,
getAnilistQueueStatus: input.registration.main.anilist.getQueueStatus,
retryAnilistQueueNow: input.registration.main.anilist.retryQueueNow,
appendClipboardVideoToQueue: input.registration.main.mining.appendClipboardVideoToQueue,
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps(input.registration.ankiJimaku),
registerIpcRuntimeServices: (params) => input.registration.registerIpcRuntimeServices(params),
},
});
return {
registerIpcRuntimeHandlers,
};
}
export function createIpcRuntimeFromMainState(input: IpcRuntimeFromMainStateInput): IpcRuntime {
return createIpcRuntime({
mpv: {
mainDeps: input.mpv.mainDeps,
handleMpvCommandFromIpcRuntime,
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
},
registration: {
runtimeOptions: input.runtimeOptions,
main: input.main,
ankiJimaku: input.ankiJimaku,
registerIpcRuntimeServices,
},
});
}

View File

@@ -0,0 +1,160 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow } from 'electron';
import { DEFAULT_CONFIG } from '../config';
import {
JellyfinRemoteSessionService,
authenticateWithPasswordRuntime,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime,
resolveJellyfinPlaybackPlanRuntime,
sendMpvCommandRuntime,
} from '../core/services';
import type { MpvIpcClient } from '../core/services/mpv';
import type { JellyfinSetupWindowLike } from './jellyfin-runtime';
import { createJellyfinRuntime } from './jellyfin-runtime';
export interface JellyfinRuntimeCoordinatorInput {
getResolvedConfig: Parameters<typeof createJellyfinRuntime>[0]['getResolvedConfig'];
configService: {
patchRawConfig: Parameters<typeof createJellyfinRuntime>[0]['patchRawConfig'];
};
tokenStore: Parameters<typeof createJellyfinRuntime>[0]['tokenStore'];
platform: NodeJS.Platform;
execPath: string;
defaultMpvLogPath: string;
defaultMpvArgs: readonly string[];
connectTimeoutMs: number;
autoLaunchTimeoutMs: number;
langPref: string;
progressIntervalMs: number;
ticksPerSecond: number;
appState: {
mpvSocketPath: string;
mpvClient: MpvIpcClient | null;
jellyfinSetupWindow: BrowserWindow | null;
};
actions: {
createMpvClient: () => MpvIpcClient;
applyJellyfinMpvDefaults: (client: MpvIpcClient) => void;
showMpvOsd: (message: string) => void;
};
logger: {
info: (message: string) => void;
warn: (message: string, details?: unknown) => void;
debug: (message: string, details?: unknown) => void;
error: (message: string, error?: unknown) => void;
};
}
export function createJellyfinRuntimeCoordinator(input: JellyfinRuntimeCoordinatorInput) {
return createJellyfinRuntime<JellyfinSetupWindowLike>({
getResolvedConfig: () => input.getResolvedConfig(),
getEnv: (name) => process.env[name],
patchRawConfig: (patch) => {
input.configService.patchRawConfig(patch);
},
defaultJellyfinConfig: DEFAULT_CONFIG.jellyfin,
tokenStore: input.tokenStore,
platform: input.platform,
execPath: input.execPath,
defaultMpvLogPath: input.defaultMpvLogPath,
defaultMpvArgs: [...input.defaultMpvArgs],
connectTimeoutMs: input.connectTimeoutMs,
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
langPref: input.langPref,
progressIntervalMs: input.progressIntervalMs,
ticksPerSecond: input.ticksPerSecond,
getMpvSocketPath: () => input.appState.mpvSocketPath,
getMpvClient: () => input.appState.mpvClient,
setMpvClient: (client) => {
input.appState.mpvClient = client as MpvIpcClient | null;
},
createMpvClient: () => input.actions.createMpvClient(),
sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command),
applyJellyfinMpvDefaults: (client) =>
input.actions.applyJellyfinMpvDefaults(client as MpvIpcClient),
showMpvOsd: (message) => input.actions.showMpvOsd(message),
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: (args) =>
spawn('mpv', args, {
detached: true,
stdio: 'ignore',
}),
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
authenticateWithPasswordRuntime(
serverUrl,
username,
password,
clientInfo as Parameters<typeof authenticateWithPasswordRuntime>[3],
),
listJellyfinLibraries: (session, clientInfo) =>
listJellyfinLibrariesRuntime(
session as Parameters<typeof listJellyfinLibrariesRuntime>[0],
clientInfo as Parameters<typeof listJellyfinLibrariesRuntime>[1],
),
listJellyfinItems: (session, clientInfo, params) =>
listJellyfinItemsRuntime(
session as Parameters<typeof listJellyfinItemsRuntime>[0],
clientInfo as Parameters<typeof listJellyfinItemsRuntime>[1],
params as Parameters<typeof listJellyfinItemsRuntime>[2],
),
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
listJellyfinSubtitleTracksRuntime(
session as Parameters<typeof listJellyfinSubtitleTracksRuntime>[0],
clientInfo as Parameters<typeof listJellyfinSubtitleTracksRuntime>[1],
itemId,
),
writeJellyfinPreviewAuth: (responsePath, payload) => {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
},
resolvePlaybackPlan: (params) =>
resolveJellyfinPlaybackPlanRuntime(
(params as { session: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[0] }).session,
(params as { clientInfo: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[1] })
.clientInfo,
(
params as {
jellyfinConfig: ReturnType<
JellyfinRuntimeCoordinatorInput['getResolvedConfig']
>['jellyfin'];
}
).jellyfinConfig,
{
itemId: (params as { itemId: string }).itemId,
audioStreamIndex:
(params as { audioStreamIndex?: number | null }).audioStreamIndex ?? undefined,
subtitleStreamIndex:
(params as { subtitleStreamIndex?: number | null }).subtitleStreamIndex ?? undefined,
},
),
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options as never),
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
createBrowserWindow: (options) => {
const window = new BrowserWindow(options);
input.appState.jellyfinSetupWindow = window;
window.on('closed', () => {
input.appState.jellyfinSetupWindow = null;
});
return window as unknown as JellyfinSetupWindowLike;
},
encodeURIComponent: (value) => encodeURIComponent(value),
logInfo: (message) => input.logger.info(message),
logWarn: (message, details) => input.logger.warn(message, details),
logDebug: (message, details) => input.logger.debug(message, details),
logError: (message, error) => input.logger.error(message, error),
now: () => Date.now(),
});
}

View File

@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JellyfinRuntimeInput } from './jellyfin-runtime';
import { createJellyfinRuntime } from './jellyfin-runtime';
test('jellyfin runtime reuses existing setup window', () => {
const calls: string[] = [];
let windowCount = 0;
const runtime = createJellyfinRuntime({
getResolvedConfig: () =>
({
jellyfin: {
enabled: true,
serverUrl: 'https://media.example',
username: 'demo',
},
}) as ReturnType<JellyfinRuntimeInput['getResolvedConfig']>,
getEnv: () => undefined,
patchRawConfig: () => {},
defaultJellyfinConfig: {
enabled: false,
serverUrl: '',
username: '',
} as JellyfinRuntimeInput['defaultJellyfinConfig'],
tokenStore: {
loadSession: () => null,
saveSession: () => {},
clearSession: () => {},
},
platform: 'linux',
execPath: '/usr/bin/electron',
defaultMpvLogPath: '/tmp/mpv.log',
defaultMpvArgs: ['--idle=yes'],
connectTimeoutMs: 1000,
autoLaunchTimeoutMs: 1000,
langPref: 'ja,en',
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
getMpvSocketPath: () => '/tmp/mpv.sock',
getMpvClient: () => null,
setMpvClient: () => {},
createMpvClient: () => ({}),
sendMpvCommand: () => {},
applyJellyfinMpvDefaults: () => {},
showMpvOsd: () => {},
removeSocketPath: () => {},
spawnMpv: () => ({}),
wait: async () => {},
authenticateWithPassword: async () => {
throw new Error('not used');
},
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
resolvePlaybackPlan: async () => ({}),
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
createRemoteSessionService: () => ({}),
defaultDeviceId: 'device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
createBrowserWindow: () => {
windowCount += 1;
return {
webContents: {
on: () => {},
},
loadURL: () => {
calls.push('loadURL');
},
on: () => {},
focus: () => {
calls.push('focus');
},
close: () => {},
isDestroyed: () => false,
};
},
encodeURIComponent: (value) => encodeURIComponent(value),
logInfo: () => {},
logWarn: () => {},
logDebug: () => {},
logError: () => {},
});
runtime.openJellyfinSetupWindow();
runtime.openJellyfinSetupWindow();
assert.equal(windowCount, 1);
assert.deepEqual(calls, ['loadURL', 'focus']);
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
assert.ok(runtime.getSetupWindow());
});

View File

@@ -0,0 +1,423 @@
import type { CliArgs } from '../cli/args';
import {
buildJellyfinSetupFormHtml,
getConfiguredJellyfinSession,
parseJellyfinSetupSubmissionUrl,
} from './runtime/domains/jellyfin';
import {
composeJellyfinRuntimeHandlers,
type JellyfinRuntimeComposerOptions,
} from './runtime/composers/jellyfin-runtime-composer';
import { createCreateJellyfinSetupWindowHandler } from './runtime/setup-window-factory';
// ---------------------------------------------------------------------------
// Helper: extract each dep-block's type from the composer options.
// ---------------------------------------------------------------------------
type Deps<K extends keyof JellyfinRuntimeComposerOptions> = JellyfinRuntimeComposerOptions[K];
// ---------------------------------------------------------------------------
// Resolved-config shape (extracted from composer).
// ---------------------------------------------------------------------------
type ResolvedConfigShape =
Deps<'getResolvedJellyfinConfigMainDeps'> extends {
getResolvedConfig: () => infer R;
}
? R
: never;
type JellyfinConfigShape = ResolvedConfigShape extends { jellyfin: infer J } ? J : never;
/** Stored-session shape (what the token store persists). */
type StoredSessionShape = { accessToken: string; userId: string };
// ---------------------------------------------------------------------------
// Public interfaces
// ---------------------------------------------------------------------------
export interface JellyfinSessionStoreLike {
loadSession: () => StoredSessionShape | null | undefined;
saveSession: (session: StoredSessionShape) => void;
clearSession: () => void;
}
export interface JellyfinSetupWindowLike {
webContents: {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
loadURL: (url: string) => Promise<void> | void;
on: (event: 'closed', handler: () => void) => void;
focus: () => void;
close: () => void;
isDestroyed: () => boolean;
}
/**
* Input for createJellyfinRuntime.
*
* Fields whose types vary across handler files (MpvClient, Session, ClientInfo,
* RemoteSessionService, etc.) are typed as `unknown`. The factory body bridges
* these to the handler-specific structural types via per-dep-block type
* annotations (`Deps<K>`) with targeted `as` casts on the individual
* function references. This keeps the public-facing input surface simple and
* avoids 7+ generic type parameters that previously required `as never` casts.
*/
export interface JellyfinRuntimeInput<
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
> {
getResolvedConfig: () => ResolvedConfigShape;
getEnv: (name: string) => string | undefined;
patchRawConfig: (patch: unknown) => void;
defaultJellyfinConfig: JellyfinConfigShape;
tokenStore: JellyfinSessionStoreLike;
platform: NodeJS.Platform;
execPath: string;
defaultMpvLogPath: string;
defaultMpvArgs: readonly string[];
connectTimeoutMs: number;
autoLaunchTimeoutMs: number;
langPref: string;
progressIntervalMs: number;
ticksPerSecond: number;
getMpvSocketPath: () => string;
getMpvClient: () => unknown;
setMpvClient: (client: unknown) => void;
createMpvClient: () => unknown;
sendMpvCommand: (client: unknown, command: Array<string | number>) => void;
applyJellyfinMpvDefaults: (client: unknown) => void;
showMpvOsd: (message: string) => void;
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => unknown;
wait: (delayMs: number) => Promise<void>;
authenticateWithPassword: (
serverUrl: string,
username: string,
password: string,
clientInfo: unknown,
) => Promise<unknown>;
listJellyfinLibraries: (session: unknown, clientInfo: unknown) => Promise<unknown>;
listJellyfinItems: (session: unknown, clientInfo: unknown, params: unknown) => Promise<unknown>;
listJellyfinSubtitleTracks: (
session: unknown,
clientInfo: unknown,
itemId: string,
) => Promise<unknown>;
writeJellyfinPreviewAuth: (responsePath: string, payload: unknown) => void;
resolvePlaybackPlan: (params: unknown) => Promise<unknown>;
convertTicksToSeconds: (ticks: number) => number;
createRemoteSessionService: (options: unknown) => unknown;
defaultDeviceId: string;
defaultClientName: string;
defaultClientVersion: string;
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TSetupWindow;
encodeURIComponent: (value: string) => string;
logInfo: (message: string) => void;
logWarn: (message: string, details?: unknown) => void;
logDebug: (message: string, details?: unknown) => void;
logError: (message: string, error: unknown) => void;
now?: () => number;
schedule?: (callback: () => void, delayMs: number) => void;
}
export interface JellyfinRuntime<
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
> {
getResolvedJellyfinConfig: () => JellyfinConfigShape;
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
reportJellyfinRemoteStopped: () => Promise<void>;
startJellyfinRemoteSession: () => Promise<void>;
stopJellyfinRemoteSession: () => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openJellyfinSetupWindow: () => void;
getQuitOnDisconnectArmed: () => boolean;
clearQuitOnDisconnectArm: () => void;
getRemoteSession: () => unknown;
getSetupWindow: () => TSetupWindow | null;
}
export function createJellyfinRuntime<TSetupWindow extends JellyfinSetupWindowLike>(
input: JellyfinRuntimeInput<TSetupWindow>,
): JellyfinRuntime<TSetupWindow> {
const now = input.now ?? Date.now;
const schedule =
input.schedule ??
((callback: () => void, delayMs: number) => {
setTimeout(callback, delayMs);
});
let playQuitOnDisconnectArmed = false;
let activePlayback: unknown = null;
let lastProgressAtMs = 0;
let mpvAutoLaunchInFlight: Promise<boolean> | null = null;
let remoteSession: unknown = null;
let setupWindow: TSetupWindow | null = null;
// Each dep block is typed with Deps<K> so TypeScript verifies structural
// compatibility with the composer. The `as Deps<K>[field]` casts on
// function references bridge `unknown`-typed input methods to the
// handler-specific structural types. This replaces 23 `as never` casts
// with targeted, auditable type assertions.
const getResolvedJellyfinConfigMainDeps: Deps<'getResolvedJellyfinConfigMainDeps'> = {
getResolvedConfig: () => input.getResolvedConfig(),
loadStoredSession: () => input.tokenStore.loadSession(),
getEnv: (name) => input.getEnv(name),
};
const getJellyfinClientInfoMainDeps: Deps<'getJellyfinClientInfoMainDeps'> = {
getResolvedJellyfinConfig: () => input.getResolvedConfig().jellyfin,
getDefaultJellyfinConfig: () => input.defaultJellyfinConfig,
};
const waitForMpvConnectedMainDeps: Deps<'waitForMpvConnectedMainDeps'> = {
getMpvClient: input.getMpvClient as Deps<'waitForMpvConnectedMainDeps'>['getMpvClient'],
now,
sleep: (delayMs) => input.wait(delayMs),
};
const launchMpvIdleForJellyfinPlaybackMainDeps: Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'> =
{
getSocketPath: () => input.getMpvSocketPath(),
platform: input.platform,
execPath: input.execPath,
defaultMpvLogPath: input.defaultMpvLogPath,
defaultMpvArgs: input.defaultMpvArgs,
removeSocketPath: (socketPath) => input.removeSocketPath(socketPath),
spawnMpv: input.spawnMpv as Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'>['spawnMpv'],
logWarn: (message, error) => input.logWarn(message, error),
logInfo: (message) => input.logInfo(message),
};
const ensureMpvConnectedForJellyfinPlaybackMainDeps: Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'> =
{
getMpvClient:
input.getMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['getMpvClient'],
setMpvClient:
input.setMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['setMpvClient'],
createMpvClient:
input.createMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['createMpvClient'],
getAutoLaunchInFlight: () => mpvAutoLaunchInFlight,
setAutoLaunchInFlight: (promise) => {
mpvAutoLaunchInFlight = promise;
},
connectTimeoutMs: input.connectTimeoutMs,
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
};
const preloadJellyfinExternalSubtitlesMainDeps: Deps<'preloadJellyfinExternalSubtitlesMainDeps'> =
{
listJellyfinSubtitleTracks:
input.listJellyfinSubtitleTracks as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['listJellyfinSubtitleTracks'],
getMpvClient:
input.getMpvClient as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['getMpvClient'],
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
wait: (delayMs) => input.wait(delayMs),
logDebug: (message, error) => input.logDebug(message, error),
};
const playJellyfinItemInMpvMainDeps: Deps<'playJellyfinItemInMpvMainDeps'> = {
getMpvClient: input.getMpvClient as Deps<'playJellyfinItemInMpvMainDeps'>['getMpvClient'],
resolvePlaybackPlan:
input.resolvePlaybackPlan as Deps<'playJellyfinItemInMpvMainDeps'>['resolvePlaybackPlan'],
applyJellyfinMpvDefaults:
input.applyJellyfinMpvDefaults as Deps<'playJellyfinItemInMpvMainDeps'>['applyJellyfinMpvDefaults'],
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
armQuitOnDisconnect: () => {
playQuitOnDisconnectArmed = false;
schedule(() => {
playQuitOnDisconnectArmed = true;
}, 3000);
},
schedule: (callback, delayMs) => {
schedule(callback, delayMs);
},
convertTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
setActivePlayback: (state) => {
activePlayback = state;
},
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportPlaying: (payload) => {
const session = remoteSession as { reportPlaying?: (payload: unknown) => unknown } | null;
if (typeof session?.reportPlaying === 'function') {
void session.reportPlaying(payload);
}
},
showMpvOsd: (message) => input.showMpvOsd(message),
};
const remoteComposerBase: Omit<Deps<'remoteComposerOptions'>, 'getConfiguredSession'> = {
logWarn: (message) => input.logWarn(message),
getMpvClient: input.getMpvClient as Deps<'remoteComposerOptions'>['getMpvClient'],
sendMpvCommand: input.sendMpvCommand as Deps<'remoteComposerOptions'>['sendMpvCommand'],
jellyfinTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
getActivePlayback: () =>
activePlayback as ReturnType<Deps<'remoteComposerOptions'>['getActivePlayback']>,
clearActivePlayback: () => {
activePlayback = null;
},
getSession: () => remoteSession as ReturnType<Deps<'remoteComposerOptions'>['getSession']>,
getNow: now,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: input.progressIntervalMs,
ticksPerSecond: input.ticksPerSecond,
logDebug: (message, error) => input.logDebug(message, error),
};
const handleJellyfinAuthCommandsMainDeps: Deps<'handleJellyfinAuthCommandsMainDeps'> = {
patchRawConfig: (patch) => input.patchRawConfig(patch),
authenticateWithPassword:
input.authenticateWithPassword as Deps<'handleJellyfinAuthCommandsMainDeps'>['authenticateWithPassword'],
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
clearStoredSession: () => input.tokenStore.clearSession(),
logInfo: (message) => input.logInfo(message),
};
const handleJellyfinListCommandsMainDeps: Deps<'handleJellyfinListCommandsMainDeps'> = {
listJellyfinLibraries:
input.listJellyfinLibraries as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinLibraries'],
listJellyfinItems:
input.listJellyfinItems as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinItems'],
listJellyfinSubtitleTracks:
input.listJellyfinSubtitleTracks as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinSubtitleTracks'],
writeJellyfinPreviewAuth: (responsePath, payload) =>
input.writeJellyfinPreviewAuth(responsePath, payload),
logInfo: (message) => input.logInfo(message),
};
const handleJellyfinPlayCommandMainDeps: Deps<'handleJellyfinPlayCommandMainDeps'> = {
logWarn: (message) => input.logWarn(message),
};
const handleJellyfinRemoteAnnounceCommandMainDeps: Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'> =
{
getRemoteSession: () =>
remoteSession as ReturnType<
Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'>['getRemoteSession']
>,
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
};
const startJellyfinRemoteSessionMainDeps: Deps<'startJellyfinRemoteSessionMainDeps'> = {
getCurrentSession: () =>
remoteSession as ReturnType<Deps<'startJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
setCurrentSession: (session) => {
remoteSession = session;
},
createRemoteSessionService:
input.createRemoteSessionService as Deps<'startJellyfinRemoteSessionMainDeps'>['createRemoteSessionService'],
defaultDeviceId: input.defaultDeviceId,
defaultClientName: input.defaultClientName,
defaultClientVersion: input.defaultClientVersion,
logInfo: (message) => input.logInfo(message),
logWarn: (message, details) => input.logWarn(message, details),
};
const stopJellyfinRemoteSessionMainDeps: Deps<'stopJellyfinRemoteSessionMainDeps'> = {
getCurrentSession: () =>
remoteSession as ReturnType<Deps<'stopJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
setCurrentSession: (session) => {
remoteSession = session;
},
clearActivePlayback: () => {
activePlayback = null;
},
};
const runJellyfinCommandMainDeps: Deps<'runJellyfinCommandMainDeps'> = {
defaultServerUrl: input.defaultJellyfinConfig.serverUrl,
};
const maybeFocusExistingJellyfinSetupWindowMainDeps: Deps<'maybeFocusExistingJellyfinSetupWindowMainDeps'> =
{
getSetupWindow: () => setupWindow,
};
const openJellyfinSetupWindowMainDeps: Deps<'openJellyfinSetupWindowMainDeps'> = {
createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => input.createBrowserWindow(options),
}),
buildSetupFormHtml: (defaultServer, defaultUser) =>
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword:
input.authenticateWithPassword as Deps<'openJellyfinSetupWindowMainDeps'>['authenticateWithPassword'],
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
patchJellyfinConfig: (session) => {
const jellyfinSession = session as { serverUrl?: string; username?: string };
input.patchRawConfig({
jellyfin: {
enabled: true,
serverUrl: jellyfinSession.serverUrl,
username: jellyfinSession.username,
},
});
},
logInfo: (message) => input.logInfo(message),
logError: (message, error) => input.logError(message, error),
showMpvOsd: (message) => input.showMpvOsd(message),
clearSetupWindow: () => {
setupWindow = null;
},
setSetupWindow: (window) => {
setupWindow = window as TSetupWindow | null;
},
encodeURIComponent: (value) => input.encodeURIComponent(value),
};
const runtime = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps,
getJellyfinClientInfoMainDeps,
waitForMpvConnectedMainDeps,
launchMpvIdleForJellyfinPlaybackMainDeps,
ensureMpvConnectedForJellyfinPlaybackMainDeps,
preloadJellyfinExternalSubtitlesMainDeps,
playJellyfinItemInMpvMainDeps,
remoteComposerOptions: {
...remoteComposerBase,
getConfiguredSession: () => getConfiguredJellyfinSession(runtime.getResolvedJellyfinConfig()),
},
handleJellyfinAuthCommandsMainDeps,
handleJellyfinListCommandsMainDeps,
handleJellyfinPlayCommandMainDeps,
handleJellyfinRemoteAnnounceCommandMainDeps,
startJellyfinRemoteSessionMainDeps,
stopJellyfinRemoteSessionMainDeps,
runJellyfinCommandMainDeps,
maybeFocusExistingJellyfinSetupWindowMainDeps,
openJellyfinSetupWindowMainDeps,
});
return {
getResolvedJellyfinConfig: () => runtime.getResolvedJellyfinConfig(),
reportJellyfinRemoteProgress: async (forceImmediate) => {
await runtime.reportJellyfinRemoteProgress(forceImmediate);
},
reportJellyfinRemoteStopped: async () => {
await runtime.reportJellyfinRemoteStopped();
},
startJellyfinRemoteSession: async () => {
await runtime.startJellyfinRemoteSession();
},
stopJellyfinRemoteSession: async () => {
await runtime.stopJellyfinRemoteSession();
},
runJellyfinCommand: async (args) => {
await runtime.runJellyfinCommand(args);
},
openJellyfinSetupWindow: () => {
runtime.openJellyfinSetupWindow();
},
getQuitOnDisconnectArmed: () => playQuitOnDisconnectArmed,
clearQuitOnDisconnectArm: () => {
playQuitOnDisconnectArmed = false;
},
getRemoteSession: () => remoteSession,
getSetupWindow: () => setupWindow,
};
}

View File

@@ -1,7 +1,7 @@
import * as path from 'path';
import type { JlptLevel } from '../types';
import { createJlptVocabularyLookup } from '../core/services';
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
export interface JlptDictionarySearchPathDeps {
getDictionaryRoots: () => string[];

View File

@@ -0,0 +1,169 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { BrowserWindow } from 'electron';
import { createAnilistTokenStore } from '../core/services/anilist/anilist-token-store';
import { createJellyfinTokenStore } from '../core/services/jellyfin-token-store';
import { createAnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
import {
SubtitleWebSocket,
createOverlayContentMeasurementStore,
createOverlayManager,
} from '../core/services';
import { ConfigService } from '../config';
import { resolveConfigDir } from '../config/path-resolution';
import { createAppState } from './state';
import {
createMainBootServices,
type AppLifecycleShape,
type MainBootServicesResult,
} from './boot/services';
import { createLogger } from '../logger';
import { createMainRuntimeRegistry } from './runtime/registry';
import { createOverlayModalInputState } from './runtime/overlay-modal-input-state';
import { createOverlayModalRuntimeService } from './overlay-runtime';
import { buildConfigParseErrorDetails, failStartupFromConfig } from './config-validation';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
shouldBypassSingleInstanceLockForArgv,
} from './early-single-instance';
import {
createBuildOverlayContentMeasurementStoreMainDepsHandler,
createBuildOverlayModalRuntimeMainDepsHandler,
} from './runtime/domains/overlay';
import type { WindowGeometry } from '../types';
export type MainBootRuntime = MainBootServicesResult<
ConfigService,
ReturnType<typeof createAnilistTokenStore>,
ReturnType<typeof createJellyfinTokenStore>,
ReturnType<typeof createAnilistUpdateQueue>,
SubtitleWebSocket,
ReturnType<typeof createLogger>,
ReturnType<typeof createMainRuntimeRegistry>,
ReturnType<typeof createOverlayManager>,
ReturnType<typeof createOverlayModalInputState>,
ReturnType<typeof createOverlayContentMeasurementStore>,
ReturnType<typeof createOverlayModalRuntimeService>,
ReturnType<typeof createAppState>,
AppLifecycleShape
>;
export interface MainBootRuntimeInput {
platform: NodeJS.Platform;
argv: string[];
appDataDir: string | undefined;
xdgConfigHome: string | undefined;
homeDir: string;
defaultMpvLogFile: string;
envMpvLog: string | undefined;
defaultTexthookerPort: number;
getDefaultSocketPath: () => string;
app: {
setPath: (name: string, value: string) => void;
quit: () => void;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function;
whenReady: () => Promise<void>;
};
dialog: {
showErrorBox: (title: string, details: string) => void;
};
overlay: {
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
getSyncOverlayVisibilityForModal: () => () => void;
createModalWindow: () => BrowserWindow;
getOverlayGeometry: () => WindowGeometry;
};
notifications: {
notifyAnilistTokenStoreWarning: (message: string) => void;
requestAppQuit: () => void;
};
}
export function createMainBootRuntime(input: MainBootRuntimeInput): MainBootRuntime {
return createMainBootServices({
platform: input.platform,
argv: input.argv,
appDataDir: input.appDataDir,
xdgConfigHome: input.xdgConfigHome,
homeDir: input.homeDir,
defaultMpvLogFile: input.defaultMpvLogFile,
envMpvLog: input.envMpvLog,
defaultTexthookerPort: input.defaultTexthookerPort,
getDefaultSocketPath: () => input.getDefaultSocketPath(),
resolveConfigDir,
existsSync: (targetPath) => fs.existsSync(targetPath),
mkdirSync: (targetPath, options) => {
fs.mkdirSync(targetPath, options);
},
joinPath: (...parts) => path.join(...parts),
app: input.app,
shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(input.argv),
requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(input.app as never),
registerSecondInstanceHandlerEarly: (listener) => {
registerSecondInstanceHandlerEarly(input.app as never, listener);
},
onConfigStartupParseError: (error) => {
failStartupFromConfig(
'SubMiner config parse error',
buildConfigParseErrorDetails(error.path, error.parseError),
{
logError: (details) => console.error(details),
showErrorBox: (title, details) => input.dialog.showErrorBox(title, details),
quit: () => input.notifications.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) => input.notifications.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 }) =>
createOverlayContentMeasurementStore(
createBuildOverlayContentMeasurementStoreMainDepsHandler({
now: () => Date.now(),
warn: (message: string) => logger.warn(message),
})(),
),
getSyncOverlayShortcutsForModal: () => input.overlay.getSyncOverlayShortcutsForModal(),
getSyncOverlayVisibilityForModal: () => input.overlay.getSyncOverlayVisibilityForModal(),
createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) =>
createOverlayModalRuntimeService(
createBuildOverlayModalRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalWindow: () => overlayManager.getModalWindow(),
createModalWindow: () => input.overlay.createModalWindow(),
getModalGeometry: () => input.overlay.getOverlayGeometry(),
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
})(),
{
onModalStateChange: (isActive: boolean) =>
overlayModalInputState.handleModalInputStateChange(isActive),
},
),
createAppState,
}) as MainBootRuntime;
}

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { createMainBootServicesBootstrap } from './main-boot-services-bootstrap';
test('main boot services bootstrap composes grouped inputs into boot services', () => {
const calls: string[] = [];
const modalWindow = {} as never;
const overlayManager = {
getModalWindow: () => modalWindow,
};
type AppStateStub = {
kind: 'app-state';
input: {
mpvSocketPath: string;
texthookerPort: number;
};
};
type OverlayModalRuntimeStub = {
kind: 'overlay-modal-runtime';
};
const overlayModalInputState = {
kind: 'overlay-modal-input-state',
handleModalInputStateChange: (isActive: boolean) => {
calls.push(`modal-state:${String(isActive)}`);
},
};
const overlayModalInputStateParams: {
getModalWindow: () => unknown;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
}[] = [];
const createOverlayModalInputState = (params: (typeof overlayModalInputStateParams)[number]) => {
overlayModalInputStateParams.push(params);
return overlayModalInputState as never;
};
const createOverlayModalRuntime = (params: {
onModalStateChange: (isActive: boolean) => void;
}) => {
calls.push(`modal:${String(params.onModalStateChange(true))}`);
return { kind: 'overlay-modal-runtime' } as OverlayModalRuntimeStub;
};
const boot = createMainBootServicesBootstrap({
system: {
platform: 'darwin',
argv: ['node', 'main.js'],
appDataDir: '/tmp/app-data',
xdgConfigHome: '/tmp/xdg',
homeDir: '/Users/test',
defaultMpvLogFile: '/tmp/mpv.log',
envMpvLog: '',
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/mpv.sock',
resolveConfigDir: () => '/tmp/config',
existsSync: () => true,
mkdirSync: () => undefined,
joinPath: (...parts: string[]) => path.posix.join(...parts),
app: {
setPath: () => undefined,
quit: () => undefined,
on: () => undefined,
whenReady: async () => undefined,
},
},
singleInstance: {
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => undefined,
onConfigStartupParseError: () => undefined,
},
factories: {
createConfigService: () => ({ kind: 'config-service' }) as never,
createAnilistTokenStore: () => ({ kind: 'anilist-token-store' }) as never,
createJellyfinTokenStore: () => ({ kind: 'jellyfin-token-store' }) as never,
createAnilistUpdateQueue: () => ({ kind: 'anilist-update-queue' }) as never,
createSubtitleWebSocket: () => ({ kind: 'subtitle-websocket' }) as never,
createLogger: () =>
({
warn: () => undefined,
info: () => undefined,
error: () => undefined,
}) as never,
createMainRuntimeRegistry: () => ({ kind: 'runtime-registry' }) as never,
createOverlayManager: () => overlayManager as never,
createOverlayModalInputState,
createOverlayContentMeasurementStore: () => ({ kind: 'overlay-content-store' }) as never,
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => {
calls.push(`shortcuts:${String(isActive)}`);
},
getSyncOverlayVisibilityForModal: () => () => {
calls.push('visibility');
},
createOverlayModalRuntime,
createAppState: (input) => ({ kind: 'app-state', input }) satisfies AppStateStub,
},
});
assert.equal(boot.configDir, '/tmp/config');
assert.equal(boot.userDataPath, '/tmp/config');
assert.equal(boot.defaultImmersionDbPath, '/tmp/config/immersion.sqlite');
assert.equal(boot.appState.input.mpvSocketPath, '/tmp/mpv.sock');
assert.equal(boot.appState.input.texthookerPort, 5174);
assert.equal(overlayModalInputStateParams.length, 1);
assert.equal(overlayModalInputStateParams[0]?.getModalWindow(), modalWindow);
overlayModalInputStateParams[0]?.syncOverlayShortcutsForModal(true);
overlayModalInputStateParams[0]?.syncOverlayVisibilityForModal();
assert.deepEqual(calls, ['modal-state:true', 'modal:undefined', 'shortcuts:true', 'visibility']);
assert.equal(boot.overlayManager, overlayManager);
assert.equal(boot.overlayModalRuntime.kind, 'overlay-modal-runtime');
});

View File

@@ -0,0 +1,173 @@
import type { BrowserWindow } from 'electron';
import type { ConfigStartupParseError } from '../config';
import {
createMainBootServices,
type MainBootServicesResult,
type OverlayModalInputStateShape,
type AppLifecycleShape,
} from './boot/services';
export interface MainBootServicesBootstrapInput<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager,
TOverlayModalInputState,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp,
> {
system: {
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;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function;
whenReady: () => Promise<void>;
};
};
singleInstance: {
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
};
factories: {
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: {
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
}) => 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 function createMainBootServicesBootstrap<
TConfigService,
TAnilistTokenStore,
TJellyfinTokenStore,
TAnilistUpdateQueue,
TSubtitleWebSocket,
TLogger,
TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayContentMeasurementStore,
TOverlayModalRuntime,
TAppState,
TAppLifecycleApp extends AppLifecycleShape,
>(
input: MainBootServicesBootstrapInput<
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
> {
return createMainBootServices({
platform: input.system.platform,
argv: input.system.argv,
appDataDir: input.system.appDataDir,
xdgConfigHome: input.system.xdgConfigHome,
homeDir: input.system.homeDir,
defaultMpvLogFile: input.system.defaultMpvLogFile,
envMpvLog: input.system.envMpvLog,
defaultTexthookerPort: input.system.defaultTexthookerPort,
getDefaultSocketPath: input.system.getDefaultSocketPath,
resolveConfigDir: input.system.resolveConfigDir,
existsSync: input.system.existsSync,
mkdirSync: input.system.mkdirSync,
joinPath: input.system.joinPath,
app: input.system.app,
shouldBypassSingleInstanceLock: input.singleInstance.shouldBypassSingleInstanceLock,
requestSingleInstanceLockEarly: input.singleInstance.requestSingleInstanceLockEarly,
registerSecondInstanceHandlerEarly: input.singleInstance.registerSecondInstanceHandlerEarly,
onConfigStartupParseError: input.singleInstance.onConfigStartupParseError,
createConfigService: input.factories.createConfigService,
createAnilistTokenStore: input.factories.createAnilistTokenStore,
createJellyfinTokenStore: input.factories.createJellyfinTokenStore,
createAnilistUpdateQueue: input.factories.createAnilistUpdateQueue,
createSubtitleWebSocket: input.factories.createSubtitleWebSocket,
createLogger: input.factories.createLogger,
createMainRuntimeRegistry: input.factories.createMainRuntimeRegistry,
createOverlayManager: input.factories.createOverlayManager,
createOverlayModalInputState: input.factories.createOverlayModalInputState,
createOverlayContentMeasurementStore: input.factories.createOverlayContentMeasurementStore,
getSyncOverlayShortcutsForModal: input.factories.getSyncOverlayShortcutsForModal,
getSyncOverlayVisibilityForModal: input.factories.getSyncOverlayVisibilityForModal,
createOverlayModalRuntime: input.factories.createOverlayModalRuntime,
createAppState: input.factories.createAppState,
});
}

View File

@@ -0,0 +1,253 @@
import type { BrowserWindow } from 'electron';
import type { ConfigService } from '../config';
import type { ResolvedConfig } from '../types';
import type { AppState } from './state';
import { createFirstRunRuntimeCoordinator } from './first-run-runtime-coordinator';
import { createStartupSupportFromMainState } from './startup-support-coordinator';
import { createYoutubeRuntimeFromMainState } from './youtube-runtime-coordinator';
import { createOverlayMpvSubtitleSuppressionRuntime } from './runtime/overlay-mpv-sub-visibility';
import { createDiscordPresenceRuntimeFromMainState } from './runtime/discord-presence-runtime';
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { OverlayUiRuntime } from './overlay-ui-runtime';
export interface MainEarlyRuntimeInput {
platform: NodeJS.Platform;
configDir: string;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
appPath: string;
resourcesPath: string;
appDataDir: string;
desktopDir: string;
defaultImmersionDbPath: string;
defaultJimakuLanguagePreference: ResolvedConfig['jimaku']['languagePreference'];
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
jellyfinLangPref: string;
youtube: {
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
logPath: string;
};
discordPresenceAppId: string;
appState: AppState;
getResolvedConfig: () => ResolvedConfig;
getFallbackDiscordMediaDurationSec: () => number | null;
configService: Pick<ConfigService, 'reloadConfigStrict'>;
overlay: {
overlayManager: {
getVisibleOverlayVisible: () => boolean;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getMainWindow: () => BrowserWindow | null;
};
overlayModalRuntime: {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => void;
};
getOverlayUi: () => OverlayUiRuntime<BrowserWindow> | null;
getOverlayGeometry: () => OverlayGeometryRuntime<BrowserWindow>;
ensureTray: () => void;
hasTray: () => boolean;
};
yomitan: {
ensureYomitanExtensionLoaded: () => Promise<unknown>;
getParserRuntimeDeps: () => Parameters<
typeof import('../core/services').getYomitanDictionaryInfo
>[0];
openYomitanSettings: () => boolean;
};
subtitle: {
getSubtitle: () => SubtitleRuntime;
};
tokenization: {
startTokenizationWarmups: () => Promise<void>;
getGate: Parameters<typeof createYoutubeRuntimeFromMainState>[0]['tokenization']['getGate'];
};
appReady: {
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
};
shortcuts: {
refreshGlobalAndOverlayShortcuts: () => void;
};
notifications: {
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, content: string) => void;
};
mpv: {
sendMpvCommandRuntime: (client: AppState['mpvClient'], command: (string | number)[]) => void;
setSubVisibility: (visible: boolean) => void;
showMpvOsd: (text: string) => void;
};
actions: {
requestAppQuit: () => void;
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
},
) => boolean;
};
logger: {
error: (message: string, error?: unknown) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, error?: unknown) => void;
debug: (message: string, meta?: unknown) => void;
};
}
export function createMainEarlyRuntime(input: MainEarlyRuntimeInput) {
const firstRun = createFirstRunRuntimeCoordinator({
platform: input.platform,
configDir: input.configDir,
homeDir: input.homeDir,
xdgConfigHome: input.xdgConfigHome,
binaryPath: input.binaryPath,
appPath: input.appPath,
resourcesPath: input.resourcesPath,
appDataDir: input.appDataDir,
desktopDir: input.desktopDir,
appState: input.appState,
getResolvedConfig: () => input.getResolvedConfig(),
yomitan: input.yomitan,
overlay: {
ensureTray: () => input.overlay.ensureTray(),
hasTray: () => input.overlay.hasTray(),
},
actions: {
writeShortcutLink: (shortcutPath, operation, details) =>
input.actions.writeShortcutLink(shortcutPath, operation, details),
requestAppQuit: () => input.actions.requestAppQuit(),
},
logger: {
error: (message, error) => input.logger.error(message, error),
info: (message, ...args) => input.logger.info(message, ...args),
},
});
const { discordPresenceRuntime, initializeDiscordPresenceService } =
createDiscordPresenceRuntimeFromMainState({
appId: input.discordPresenceAppId,
appState: input.appState,
getResolvedConfig: () => input.getResolvedConfig(),
getFallbackMediaDurationSec: () => input.getFallbackDiscordMediaDurationSec(),
logger: {
debug: (message, meta) => input.logger.debug(message, meta),
},
});
const overlaySubtitleSuppression = createOverlayMpvSubtitleSuppressionRuntime({
appState: input.appState,
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
setMpvSubVisibility: (visible) => input.mpv.setSubVisibility(visible),
logWarn: (message, error) => input.logger.warn(message, error),
});
const startupSupport = createStartupSupportFromMainState({
platform: input.platform,
defaultImmersionDbPath: input.defaultImmersionDbPath,
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
jellyfinLangPref: input.jellyfinLangPref,
getResolvedConfig: () => input.getResolvedConfig(),
appState: input.appState,
configService: input.configService,
overlay: {
broadcastToOverlayWindows: (channel, payload) =>
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow(
channel,
payload,
runtimeOptions,
),
},
shortcuts: {
refreshGlobalAndOverlayShortcuts: () => input.shortcuts.refreshGlobalAndOverlayShortcuts(),
},
notifications: {
showDesktopNotification: (title, options) =>
input.notifications.showDesktopNotification(title, options),
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
},
logger: {
debug: (message) => input.logger.debug(message),
info: (message) => input.logger.info(message),
warn: (message, error) => input.logger.warn(message, error),
},
mpv: {
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
},
});
const youtube = createYoutubeRuntimeFromMainState({
platform: input.platform,
directPlaybackFormat: input.youtube.directPlaybackFormat,
mpvYtdlFormat: input.youtube.mpvYtdlFormat,
autoLaunchTimeoutMs: input.youtube.autoLaunchTimeoutMs,
connectTimeoutMs: input.youtube.connectTimeoutMs,
logPath: input.youtube.logPath,
appState: input.appState,
overlay: {
getOverlayUi: () => input.overlay.getOverlayUi(),
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
broadcastToOverlayWindows: (channel, payload) =>
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
},
subtitle: {
getSubtitle: () => input.subtitle.getSubtitle(),
},
tokenization: {
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
getGate: () => input.tokenization.getGate(),
},
appReady: {
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
},
getResolvedConfig: () => input.getResolvedConfig(),
notifications: {
showDesktopNotification: (title, options) =>
input.notifications.showDesktopNotification(title, options),
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
},
mpv: {
sendMpvCommand: (command) =>
input.mpv.sendMpvCommandRuntime(input.appState.mpvClient, command),
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
},
logger: {
info: (message) => input.logger.info(message),
warn: (message, error) => input.logger.warn(message, error),
debug: (message) => input.logger.debug(message),
},
});
return {
firstRun,
discordPresenceRuntime,
initializeDiscordPresenceService,
overlaySubtitleSuppression,
startupSupport,
youtube,
};
}

View File

@@ -0,0 +1,129 @@
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import type { MpvSubtitleRenderMetrics } from '../types';
import type { MpvIpcClient } from '../core/services/mpv';
import { sendMpvCommandRuntime } from '../core/services';
import type { AnilistRuntime } from './anilist-runtime';
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
import type { JellyfinRuntime } from './jellyfin-runtime';
import { createMiningRuntime } from './mining-runtime';
import type { MiningRuntimeInput } from './mining-runtime';
import { createMpvRuntimeFromMainState } from './mpv-runtime-bootstrap';
import type { MpvRuntime } from './mpv-runtime';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { YoutubeRuntime } from './youtube-runtime';
import type { AppState } from './state';
export interface MainPlaybackRuntimeInput {
appState: AppState;
logPath: string;
logger: Parameters<typeof createMpvRuntimeFromMainState>[0]['logger'] & {
error: (message: string, error: unknown) => void;
};
getResolvedConfig: Parameters<typeof createMpvRuntimeFromMainState>[0]['getResolvedConfig'];
getRuntimeBooleanOption: Parameters<
typeof createMpvRuntimeFromMainState
>[0]['getRuntimeBooleanOption'];
subtitle: SubtitleRuntime;
yomitan: {
ensureYomitanExtensionLoaded: () => Promise<unknown>;
isCharacterDictionaryEnabled: () => boolean;
};
currentMediaTokenizationGate: Parameters<
typeof createMpvRuntimeFromMainState
>[0]['currentMediaTokenizationGate'];
startupOsdSequencer: Parameters<typeof createMpvRuntimeFromMainState>[0]['startupOsdSequencer'];
dictionarySupport: DictionarySupportRuntime;
overlay: {
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getVisibleOverlayVisible: () => boolean;
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
};
lifecycle: {
requestAppQuit: () => void;
restoreOverlayMpvSubtitles: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
publishDiscordPresence: () => void;
};
stats: {
ensureImmersionTrackerStarted: () => void;
};
anilist: AnilistRuntime;
jellyfin: JellyfinRuntime;
youtube: YoutubeRuntime;
mining: Omit<
MiningRuntimeInput<any, any>,
'showMpvOsd' | 'sendMpvCommand' | 'logError' | 'recordCardsMined'
> & {
readClipboardText: () => string;
writeClipboardText: (text: string) => void;
recordCardsMined: (count: number, noteIds?: number[]) => void;
};
}
export interface MainPlaybackRuntime {
mpvRuntime: MpvRuntime;
mining: ReturnType<typeof createMiningRuntime>;
}
export function createMainPlaybackRuntime(input: MainPlaybackRuntimeInput): MainPlaybackRuntime {
let mpvRuntime!: MpvRuntime;
const showMpvOsd = (text: string): void => {
mpvRuntime.showMpvOsd(text);
};
const mining = createMiningRuntime({
...input.mining,
showMpvOsd: (text) => showMpvOsd(text),
sendMpvCommand: (command) => {
sendMpvCommandRuntime(input.appState.mpvClient, command);
},
logError: (message, err) => {
input.logger.error(message, err);
},
recordCardsMined: (count, noteIds) => input.mining.recordCardsMined(count, noteIds),
});
mpvRuntime = createMpvRuntimeFromMainState({
appState: input.appState,
logPath: input.logPath,
logger: input.logger,
getResolvedConfig: input.getResolvedConfig,
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
subtitle: input.subtitle,
yomitan: {
ensureYomitanExtensionLoaded: async () => {
await input.yomitan.ensureYomitanExtensionLoaded();
},
},
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
startupOsdSequencer: input.startupOsdSequencer,
dictionarySupport: input.dictionarySupport,
overlay: {
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.broadcastToOverlayWindows(channel, payload);
},
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
getOverlayUi: () => input.overlay.getOverlayUi(),
},
lifecycle: {
requestAppQuit: () => input.lifecycle.requestAppQuit(),
setQuitCheckTimer: (callback, timeoutMs) => {
setTimeout(callback, timeoutMs);
},
restoreOverlayMpvSubtitles: input.lifecycle.restoreOverlayMpvSubtitles,
syncOverlayMpvSubtitleSuppression: input.lifecycle.syncOverlayMpvSubtitleSuppression,
publishDiscordPresence: () => input.lifecycle.publishDiscordPresence(),
},
stats: input.stats,
anilist: input.anilist,
jellyfin: input.jellyfin,
youtube: input.youtube,
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
}).mpvRuntime;
return {
mpvRuntime,
mining,
};
}

View File

@@ -0,0 +1,54 @@
import type { CliArgs } from '../cli/args';
import type { ResolvedConfig, SecondarySubMode, SubtitleData } from '../types';
import { RuntimeOptionsManager } from '../runtime-options';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
export type StartupBootstrapMpvClientLike = {
connected: boolean;
connect: () => void;
setSocketPath: (socketPath: string) => void;
currentSubStart?: number | null;
currentSubEnd?: number | null;
};
export type StartupBootstrapAppStateLike = {
subtitlePosition: unknown | null;
keybindings: unknown[];
mpvSocketPath: string;
texthookerPort: number;
mpvClient: StartupBootstrapMpvClientLike | null;
runtimeOptionsManager: RuntimeOptionsManager | null;
subtitleTimingTracker: SubtitleTimingTracker | null;
currentSubtitleData: SubtitleData | null;
currentSubText: string | null;
initialArgs: CliArgs | null | undefined;
backgroundMode: boolean;
texthookerOnlyMode: boolean;
overlayRuntimeInitialized: boolean;
firstRunSetupCompleted: boolean;
secondarySubMode: SecondarySubMode;
ankiIntegration: unknown | null;
immersionTracker: unknown | null;
};
export type StartupBootstrapSubtitleWebsocketLike = {
start: (
port: number,
getPayload: () => SubtitleData | null,
getFrequencyOptions: () => {
enabled: boolean;
topX: number;
mode: ResolvedConfig['subtitleStyle']['frequencyDictionary']['mode'];
},
) => void;
};
export type StartupBootstrapOverlayUiLike = {
broadcastRuntimeOptionsChanged: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
ensureTray: () => void;
initializeOverlayRuntime: () => void;
openRuntimeOptionsPalette: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
toggleVisibleOverlay: () => void;
};

View File

@@ -0,0 +1,505 @@
import type { CliArgs, CliCommandSource } from '../cli/args';
import type { LogLevelSource } from '../logger';
import type { ConfigValidationWarning, ResolvedConfig, SubtitleData } from '../types';
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
import { resolveKeybindings } from '../core/utils';
import { RuntimeOptionsManager } from '../runtime-options';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import type { AppReadyRuntimeInput } from './app-ready-runtime';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
} from './cli-runtime';
import type {
StartupBootstrapAppStateLike,
StartupBootstrapMpvClientLike,
StartupBootstrapOverlayUiLike,
StartupBootstrapSubtitleWebsocketLike,
} from './main-startup-bootstrap-types';
import type { MainStartupRuntime } from './main-startup-runtime';
import { createMainStartupRuntime } from './main-startup-runtime';
export interface MainStartupBootstrapInput<TStartupState> {
appState: StartupBootstrapAppStateLike;
appLifecycle: {
app: unknown;
argv: string[];
platform: NodeJS.Platform;
};
config: {
configService: {
reloadConfigStrict: AppReadyRuntimeInput['reload']['reloadConfigStrict'];
getConfigPath: () => string;
getWarnings: () => ConfigValidationWarning[];
getConfig: () => ResolvedConfig;
};
configHotReloadRuntime: {
start: () => void;
};
configDerivedRuntime: {
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
};
ensureDefaultConfigBootstrap: (options: {
configDir: string;
configFilePaths: unknown;
generateTemplate: () => string;
}) => void;
getDefaultConfigFilePaths: (configDir: string) => unknown;
generateConfigTemplate: (config: ResolvedConfig) => string;
defaultConfig: ResolvedConfig;
defaultKeybindings: unknown;
configDir: string;
};
logging: {
appLogger: {
logInfo: (message: string) => void;
logWarning: (message: string) => void;
logConfigWarning: (warning: ConfigValidationWarning) => void;
logNoRunningInstance: () => void;
};
logger: {
info: (message: string) => void;
warn: (message: string, error?: unknown) => void;
error: (message: string, error?: unknown) => void;
debug: (message: string) => void;
};
setLogLevel: (level: string, source: LogLevelSource) => void;
};
shell: {
dialog: {
showErrorBox: (title: string, message: string) => void;
};
shell: {
openExternal: (url: string) => Promise<void>;
};
showDesktopNotification: (title: string, options: { body: string }) => void;
};
runtime: {
subtitle: {
loadSubtitlePosition: () => void;
invalidateTokenizationCache: () => void;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
};
overlayUi: {
get: () => StartupBootstrapOverlayUiLike | undefined;
};
overlayManager: {
getMainWindow: () => unknown | null;
};
firstRun: {
ensureSetupStateInitialized: () => Promise<{ state: { status: string } }>;
openFirstRunSetupWindow: () => void;
};
anilist: {
refreshAnilistClientSecretStateIfEnabled: (options: {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
openAnilistSetupWindow: () => void;
getStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearTokenState: () => void;
getQueueStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
};
jellyfin: {
startJellyfinRemoteSession: () => Promise<void>;
openJellyfinSetupWindow: () => void;
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
};
stats: {
ensureImmersionTrackerStarted: () => void;
runStatsCliCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
};
mining: {
copyCurrentSubtitle: () => void;
mineSentenceCard: () => Promise<void>;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
};
yomitan: {
loadYomitanExtension: () => Promise<unknown>;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
openYomitanSettings: () => void;
};
subsyncRuntime: {
triggerFromConfig: () => Promise<void>;
};
dictionarySupport: {
generateCharacterDictionaryForCurrentMedia: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
};
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
subtitleWsService: StartupBootstrapSubtitleWebsocketLike;
annotationSubtitleWsService: StartupBootstrapSubtitleWebsocketLike;
immersion: AppReadyRuntimeInput['immersion'];
};
commands: {
createMpvClientRuntimeService: () => StartupBootstrapMpvClientLike;
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
startBackgroundWarmupsIfAllowed: () => void;
startBackgroundWarmups: () => void;
runHeadlessInitialCommand: () => Promise<void>;
startPendingMultiCopy: (timeoutMs: number) => void;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
cycleSecondarySubMode: () => void;
refreshOverlayShortcuts: () => void;
hasMpvWebsocketPlugin: () => boolean;
startTexthooker: (port: number, websocketUrl?: string) => void;
showMpvOsd: (text: string) => void;
shouldAutoOpenFirstRunSetup: (args: CliArgs) => boolean;
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
getMultiCopyTimeoutMs: () => number;
shouldEnsureTrayOnStartupForInitialArgs: (
platform: NodeJS.Platform,
initialArgs: CliArgs | null | undefined,
) => boolean;
isHeadlessInitialCommand: (args: CliArgs) => boolean;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
handleCliCommandRuntimeServiceWithContext: (
args: CliArgs,
source: CliCommandSource,
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
) => void;
shouldStartApp: (args: CliArgs) => boolean;
parseArgs: (argv: string[]) => CliArgs;
printHelp: (defaultTexthookerPort: number) => void;
onWillQuitCleanupHandler: () => void;
shouldRestoreWindowsOnActivateHandler: () => boolean;
restoreWindowsOnActivateHandler: () => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPathHandler: () => string;
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => Promise<number>;
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
applyStartupState: (startupState: TStartupState) => void;
getStartupModeFlags: (initialArgs: CliArgs | null | undefined) => {
shouldUseMinimalStartup: boolean;
shouldSkipHeavyStartup: boolean;
};
requestAppQuit: () => void;
};
constants: {
defaultTexthookerPort: number;
};
}
export function createMainStartupBootstrap<TStartupState>(
input: MainStartupBootstrapInput<TStartupState>,
): MainStartupRuntime<TStartupState> {
let startup: MainStartupRuntime<TStartupState> | null = null;
const getStartup = (): MainStartupRuntime<TStartupState> => {
if (!startup) {
throw new Error('Main startup runtime not initialized');
}
return startup;
};
const getOverlayUi = (): StartupBootstrapOverlayUiLike | undefined =>
input.runtime.overlayUi.get();
const getSubtitlePayload = (): SubtitleData | null =>
input.appState.currentSubtitleData ??
(input.appState.currentSubText
? {
text: input.appState.currentSubText,
tokens: null,
startTime: input.appState.mpvClient?.currentSubStart ?? null,
endTime: input.appState.mpvClient?.currentSubEnd ?? null,
}
: null);
const getSubtitleFrequencyOptions = () => ({
enabled: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
topX: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.topX,
mode: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.mode,
});
startup = createMainStartupRuntime<TStartupState>({
appReady: {
reload: {
reloadConfigStrict: () => input.config.configService.reloadConfigStrict(),
logInfo: (message) => input.logging.appLogger.logInfo(message),
logWarning: (message) => input.logging.appLogger.logWarning(message),
showDesktopNotification: (title, options) =>
input.shell.showDesktopNotification(title, options),
startConfigHotReload: () => input.config.configHotReloadRuntime.start(),
refreshAnilistClientSecretState: (options) =>
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
failHandlers: {
logError: (details) => input.logging.logger.error(details),
showErrorBox: (title, details) => input.shell.dialog.showErrorBox(title, details),
quit: () => input.commands.requestAppQuit(),
},
},
criticalConfig: {
getConfigPath: () => input.config.configService.getConfigPath(),
failHandlers: {
logError: (message) => input.logging.logger.error(message),
showErrorBox: (title, message) => input.shell.dialog.showErrorBox(title, message),
quit: () => input.commands.requestAppQuit(),
},
},
runner: {
ensureDefaultConfigBootstrap: () => {
input.config.ensureDefaultConfigBootstrap({
configDir: input.config.configDir,
configFilePaths: input.config.getDefaultConfigFilePaths(input.config.configDir),
generateTemplate: () => input.config.generateConfigTemplate(input.config.defaultConfig),
});
},
getSubtitlePosition: () => input.appState.subtitlePosition,
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
getKeybindingsCount: () => input.appState.keybindings.length,
resolveKeybindings: () => {
input.appState.keybindings = resolveKeybindings(
input.config.configService.getConfig(),
input.config.defaultKeybindings as never,
);
},
hasMpvClient: () => Boolean(input.appState.mpvClient),
createMpvClient: () => {
input.appState.mpvClient = input.commands.createMpvClientRuntimeService();
},
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
getResolvedConfig: () => input.config.configService.getConfig(),
getConfigWarnings: () => input.config.configService.getWarnings(),
logConfigWarning: (warning) => input.logging.appLogger.logConfigWarning(warning),
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
initRuntimeOptionsManager: () => {
input.appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => input.config.configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch: unknown) => {
(
input.appState.ankiIntegration as {
applyRuntimeConfigPatch?: (patch: unknown) => void;
} | null
)?.applyRuntimeConfigPatch?.(patch);
},
getSubtitleStyleConfig: () => input.config.configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
input.runtime.subtitle.invalidateTokenizationCache();
void input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack();
getOverlayUi()?.broadcastRuntimeOptionsChanged();
input.commands.refreshOverlayShortcuts();
},
},
);
},
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
createSubtitleTimingTracker: () => {
input.appState.subtitleTimingTracker = new SubtitleTimingTracker();
},
setSecondarySubMode: (mode) => {
input.appState.secondarySubMode = mode;
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
defaultTexthookerPort: input.constants.defaultTexthookerPort,
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port) => {
input.runtime.subtitleWsService.start(
port,
getSubtitlePayload,
getSubtitleFrequencyOptions,
);
},
startAnnotationWebsocket: (port) => {
input.runtime.annotationSubtitleWsService.start(
port,
getSubtitlePayload,
getSubtitleFrequencyOptions,
);
},
startTexthooker: (port, websocketUrl) => input.commands.startTexthooker(port, websocketUrl),
log: (message) => input.logging.appLogger.logInfo(message),
createMecabTokenizerAndCheck: () => input.commands.createMecabTokenizerAndCheck(),
createImmersionTracker: () => {
input.runtime.stats.ensureImmersionTrackerStarted();
},
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
loadYomitanExtension: async () => {
await input.runtime.yomitan.loadYomitanExtension();
},
ensureYomitanExtensionLoaded: async () => {
await input.runtime.yomitan.ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await input.runtime.firstRun.ensureSetupStateInitialized();
input.appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
if (
input.appState.initialArgs &&
input.commands.shouldAutoOpenFirstRunSetup(input.appState.initialArgs) &&
snapshot.state.status !== 'completed'
) {
input.runtime.firstRun.openFirstRunSetupWindow();
}
},
prewarmSubtitleDictionaries: () => input.commands.prewarmSubtitleDictionaries(),
startBackgroundWarmups: () => input.commands.startBackgroundWarmupsIfAllowed(),
texthookerOnlyMode: input.appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
input.appState.backgroundMode
? false
: input.config.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
getOverlayUi()?.ensureOverlayWindowsReadyForVisibilityActions(),
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
handleInitialArgs: () => getStartup().handleInitialArgs(),
shouldRunHeadlessInitialCommand: () =>
Boolean(
input.appState.initialArgs &&
input.commands.isHeadlessInitialCommand(input.appState.initialArgs),
),
shouldUseMinimalStartup: () =>
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () =>
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldSkipHeavyStartup,
logDebug: (message) => input.logging.logger.debug(message),
now: () => Date.now(),
},
immersion: input.runtime.immersion,
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
},
cli: {
appState: {
appState: input.appState,
getInitialArgs: () => input.appState.initialArgs,
isBackgroundMode: () => input.appState.backgroundMode,
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
setTexthookerOnlyMode: (enabled) => {
input.appState.texthookerOnlyMode = enabled;
},
hasImmersionTracker: () => Boolean(input.appState.immersionTracker),
getMpvClient: () => input.appState.mpvClient,
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
},
config: {
defaultConfig: input.config.defaultConfig,
getResolvedConfig: () => input.config.configService.getConfig(),
setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'),
hasMpvWebsocketPlugin: () => true,
},
io: {
texthookerService: input.runtime.texthookerService,
openExternal: (url) => input.shell.shell.openExternal(url),
logBrowserOpenError: (url, error) =>
input.logging.logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
showMpvOsd: (text) => input.commands.showMpvOsd(text),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
logInfo: (message) => input.logging.logger.info(message),
logWarn: (message) => input.logging.logger.warn(message),
logError: (message, err) => input.logging.logger.error(message, err),
},
commands: {
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
toggleVisibleOverlay: () => getOverlayUi()?.toggleVisibleOverlay(),
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs) => input.commands.startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs) =>
input.commands.startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => input.runtime.subsyncRuntime.triggerFromConfig(),
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
getAnilistStatus: () => input.runtime.anilist.getStatusSnapshot(),
clearAnilistToken: () => input.runtime.anilist.clearTokenState(),
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => input.runtime.anilist.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
input.commands.generateCharacterDictionary(targetPath),
runJellyfinCommand: (argsFromCommand) =>
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand, source) =>
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => getOverlayUi()?.openRuntimeOptionsPalette(),
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
stopApp: () => input.commands.requestAppQuit(),
hasMainWindow: () => Boolean(input.runtime.overlayManager.getMainWindow()),
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
},
startup: {
shouldEnsureTrayOnStartup: () =>
input.commands.shouldEnsureTrayOnStartupForInitialArgs(
input.appLifecycle.platform,
input.appState.initialArgs,
),
shouldRunHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
ensureTray: () => getOverlayUi()?.ensureTray(),
commandNeedsOverlayStartupPrereqs: (args) =>
input.commands.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => getStartup().appReady.ensureOverlayStartupPrereqs(),
startBackgroundWarmups: () => input.commands.startBackgroundWarmups(),
},
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, context),
},
headless: {
appLifecycleRuntimeRunnerMainDeps: {
app: input.appLifecycle.app as never,
platform: input.appLifecycle.platform,
shouldStartApp: (nextArgs) => input.commands.shouldStartApp(nextArgs),
parseArgs: (argv) => input.commands.parseArgs(argv),
handleCliCommand: (nextArgs, source) => getStartup().handleCliCommand(nextArgs, source),
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
logNoRunningInstance: () => input.logging.appLogger.logNoRunningInstance(),
onReady: (): Promise<void> => getStartup().appReady.runAppReady(),
onWillQuitCleanup: () => input.commands.onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () =>
input.commands.shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => input.commands.restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !input.appState.backgroundMode,
},
bootstrap: {
argv: input.appLifecycle.argv,
parseArgs: (argv) => input.commands.parseArgs(argv),
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
forceX11Backend: (args) => input.commands.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
shouldStartApp: (args) => input.commands.shouldStartApp(args),
getDefaultSocketPath: () => input.commands.getDefaultSocketPathHandler(),
defaultTexthookerPort: input.constants.defaultTexthookerPort,
configDir: input.config.configDir,
defaultConfig: input.config.defaultConfig,
generateConfigTemplate: (config) => input.config.generateConfigTemplate(config),
generateDefaultConfigFile: (args, options) =>
input.commands.generateDefaultConfigFile(args, options),
setExitCode: (exitCode) => {
process.exitCode = exitCode;
},
quitApp: () => input.commands.requestAppQuit(),
logGenerateConfigError: (message) => input.logging.logger.error(message),
startAppLifecycle: () => {},
},
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
},
});
return startup;
}

View File

@@ -0,0 +1,262 @@
import type { MainStartupBootstrapInput } from './main-startup-bootstrap';
import type { MainStartupRuntime } from './main-startup-runtime';
import type { FirstRunRuntime } from './first-run-runtime';
import { createMainStartupBootstrap } from './main-startup-bootstrap';
type StartupBootstrapYomitanRuntime<TStartupState> = {
loadYomitanExtension: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['loadYomitanExtension'];
ensureYomitanExtensionLoaded: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['ensureYomitanExtensionLoaded'];
openYomitanSettings: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['openYomitanSettings'];
};
export interface MainStartupRuntimeBootstrapInput<TStartupState> {
appState: MainStartupBootstrapInput<TStartupState>['appState'];
appLifecycle: {
app: MainStartupBootstrapInput<TStartupState>['appLifecycle']['app'];
argv: string[];
platform: NodeJS.Platform;
};
config: MainStartupBootstrapInput<TStartupState>['config'];
logging: MainStartupBootstrapInput<TStartupState>['logging'];
shell: MainStartupBootstrapInput<TStartupState>['shell'];
runtime: Omit<MainStartupBootstrapInput<TStartupState>['runtime'], 'overlayUi' | 'yomitan'> & {
texthookerService: {
isRunning: () => boolean;
start: (port: number, websocketUrl?: string) => void;
};
getOverlayUi: MainStartupBootstrapInput<TStartupState>['runtime']['overlayUi']['get'];
getYomitanRuntime: () => StartupBootstrapYomitanRuntime<TStartupState>;
getCharacterDictionaryDisabledReason: () => string | null;
};
commands: Omit<
MainStartupBootstrapInput<TStartupState>['commands'],
| 'startTexthooker'
| 'generateCharacterDictionary'
| 'runYoutubePlaybackFlow'
| 'getMultiCopyTimeoutMs'
> & {
getConfiguredShortcuts: () => { multiCopyTimeoutMs: number };
runYoutubePlaybackFlow: MainStartupBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
};
constants: MainStartupBootstrapInput<TStartupState>['constants'];
}
export interface MainStartupRuntimeBootstrap<TStartupState> {
startupRuntime: MainStartupRuntime<TStartupState>;
}
export interface MainStartupRuntimeFromMainStateInput<TStartupState> {
appState: MainStartupRuntimeBootstrapInput<TStartupState>['appState'];
appLifecycle: MainStartupRuntimeBootstrapInput<TStartupState>['appLifecycle'];
config: MainStartupRuntimeBootstrapInput<TStartupState>['config'];
logging: MainStartupRuntimeBootstrapInput<TStartupState>['logging'];
shell: MainStartupRuntimeBootstrapInput<TStartupState>['shell'];
runtime: {
subtitle: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitle'];
getOverlayUi: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['getOverlayUi'];
overlayManager: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['overlayManager'];
firstRun: {
ensureSetupStateInitialized: FirstRunRuntime['ensureSetupStateInitialized'];
openFirstRunSetupWindow: () => void;
};
anilist: {
refreshAnilistClientSecretStateIfEnabled: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['refreshAnilistClientSecretStateIfEnabled'];
openAnilistSetupWindow: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['openAnilistSetupWindow'];
getStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getStatusSnapshot'];
clearTokenState: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['clearTokenState'];
getQueueStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getQueueStatusSnapshot'];
processNextAnilistRetryUpdate: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['processNextAnilistRetryUpdate'];
};
jellyfin: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['jellyfin'];
stats: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['stats'];
mining: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['mining'];
texthookerService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['texthookerService'];
yomitan: StartupBootstrapYomitanRuntime<TStartupState>;
getCharacterDictionaryDisabledReason: () => string | null;
subsyncRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subsyncRuntime'];
dictionarySupport: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['dictionarySupport'];
subtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitleWsService'];
annotationSubtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['annotationSubtitleWsService'];
immersion: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['immersion'];
};
commands: {
mpvRuntime: {
createMpvClientRuntimeService: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMpvClientRuntimeService'];
createMecabTokenizerAndCheck: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMecabTokenizerAndCheck'];
prewarmSubtitleDictionaries: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['prewarmSubtitleDictionaries'];
startBackgroundWarmups: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startBackgroundWarmups'];
};
runHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runHeadlessInitialCommand'];
shortcuts: {
startPendingMultiCopy: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMultiCopy'];
startPendingMineSentenceMultiple: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMineSentenceMultiple'];
refreshOverlayShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['refreshOverlayShortcuts'];
getConfiguredShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getConfiguredShortcuts'];
};
cycleSecondarySubMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['cycleSecondarySubMode'];
hasMpvWebsocketPlugin: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
showMpvOsd: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['showMpvOsd'];
shouldAutoOpenFirstRunSetup: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
youtube: {
runYoutubePlaybackFlow: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
};
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
isHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['isHeadlessInitialCommand'];
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
commandNeedsOverlayRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
shouldStartApp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldStartApp'];
parseArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['parseArgs'];
printHelp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['printHelp'];
onWillQuitCleanupHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
restoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
forceX11Backend: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['forceX11Backend'];
enforceUnsupportedWaylandMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
getDefaultSocketPath: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getDefaultSocketPathHandler'];
generateDefaultConfigFile: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['generateDefaultConfigFile'];
runStartupBootstrapRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
applyStartupState: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['applyStartupState'];
getStartupModeFlags: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getStartupModeFlags'];
requestAppQuit: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['requestAppQuit'];
};
constants: MainStartupRuntimeBootstrapInput<TStartupState>['constants'];
}
export function createMainStartupRuntimeBootstrap<TStartupState>(
input: MainStartupRuntimeBootstrapInput<TStartupState>,
): MainStartupRuntimeBootstrap<TStartupState> {
const startupRuntime = createMainStartupBootstrap<TStartupState>({
appState: input.appState,
appLifecycle: {
app: input.appLifecycle.app,
argv: input.appLifecycle.argv,
platform: input.appLifecycle.platform,
},
config: input.config,
logging: input.logging,
shell: input.shell,
runtime: {
...input.runtime,
overlayUi: {
get: () => input.runtime.getOverlayUi(),
},
yomitan: {
loadYomitanExtension: () => input.runtime.getYomitanRuntime().loadYomitanExtension(),
ensureYomitanExtensionLoaded: () =>
input.runtime.getYomitanRuntime().ensureYomitanExtensionLoaded(),
openYomitanSettings: () => input.runtime.getYomitanRuntime().openYomitanSettings(),
},
},
commands: {
...input.commands,
startTexthooker: (port, websocketUrl) => {
if (!input.runtime.texthookerService.isRunning()) {
input.runtime.texthookerService.start(port, websocketUrl);
}
},
generateCharacterDictionary: async (targetPath?: string) => {
const disabledReason = input.runtime.getCharacterDictionaryDisabledReason();
if (disabledReason) {
throw new Error(disabledReason);
}
return await input.runtime.dictionarySupport.generateCharacterDictionaryForCurrentMedia(
targetPath,
);
},
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
getMultiCopyTimeoutMs: () => input.commands.getConfiguredShortcuts().multiCopyTimeoutMs,
},
constants: input.constants,
});
return {
startupRuntime,
};
}
export function createMainStartupRuntimeFromMainState<TStartupState>(
input: MainStartupRuntimeFromMainStateInput<TStartupState>,
): MainStartupRuntimeBootstrap<TStartupState> {
return createMainStartupRuntimeBootstrap<TStartupState>({
appState: input.appState,
appLifecycle: input.appLifecycle,
config: input.config,
logging: input.logging,
shell: input.shell,
runtime: {
subtitle: input.runtime.subtitle,
getOverlayUi: () => input.runtime.getOverlayUi(),
overlayManager: input.runtime.overlayManager,
firstRun: {
ensureSetupStateInitialized: () => input.runtime.firstRun.ensureSetupStateInitialized(),
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
},
anilist: {
refreshAnilistClientSecretStateIfEnabled: (options) =>
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
getStatusSnapshot: () => input.runtime.anilist.getStatusSnapshot(),
clearTokenState: () => input.runtime.anilist.clearTokenState(),
getQueueStatusSnapshot: () => input.runtime.anilist.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
},
jellyfin: input.runtime.jellyfin,
stats: input.runtime.stats,
mining: input.runtime.mining,
texthookerService: input.runtime.texthookerService,
getYomitanRuntime: () => input.runtime.yomitan,
getCharacterDictionaryDisabledReason: () =>
input.runtime.getCharacterDictionaryDisabledReason(),
subsyncRuntime: input.runtime.subsyncRuntime,
dictionarySupport: input.runtime.dictionarySupport,
subtitleWsService: input.runtime.subtitleWsService,
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
immersion: input.runtime.immersion,
},
commands: {
createMpvClientRuntimeService: () =>
input.commands.mpvRuntime.createMpvClientRuntimeService(),
createMecabTokenizerAndCheck: () => input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
startBackgroundWarmupsIfAllowed: () => input.commands.mpvRuntime.startBackgroundWarmups(),
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
startPendingMultiCopy: (timeoutMs) =>
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
startPendingMineSentenceMultiple: (timeoutMs) =>
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
showMpvOsd: (text) => input.commands.showMpvOsd(text),
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs),
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
commandNeedsOverlayStartupPrereqs: (args) =>
input.commands.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
shouldStartApp: (args) => input.commands.shouldStartApp(args),
parseArgs: (argv) => input.commands.parseArgs(argv),
printHelp: input.commands.printHelp,
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivateHandler: () =>
input.commands.shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
forceX11Backend: (args) => input.commands.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
getDefaultSocketPathHandler: () => input.commands.getDefaultSocketPath(),
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
getStartupModeFlags: input.commands.getStartupModeFlags,
requestAppQuit: input.commands.requestAppQuit,
},
constants: input.constants,
});
}

View File

@@ -0,0 +1,370 @@
import type { AnilistRuntime } from './anilist-runtime';
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
import type { FirstRunRuntime } from './first-run-runtime';
import type { JellyfinRuntime } from './jellyfin-runtime';
import {
createMainStartupRuntimeFromMainState,
type MainStartupRuntimeBootstrap,
type MainStartupRuntimeFromMainStateInput,
} from './main-startup-runtime-bootstrap';
import type { MiningRuntime } from './mining-runtime';
import type { MpvRuntime } from './mpv-runtime';
import type { ShortcutsRuntime } from './shortcuts-runtime';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { YoutubeRuntime } from './youtube-runtime';
export interface MainStartupRuntimeCoordinatorInput<TStartupState> {
appState: MainStartupRuntimeFromMainStateInput<TStartupState>['appState'];
appLifecycle: MainStartupRuntimeFromMainStateInput<TStartupState>['appLifecycle'];
config: MainStartupRuntimeFromMainStateInput<TStartupState>['config'];
logging: MainStartupRuntimeFromMainStateInput<TStartupState>['logging'];
shell: MainStartupRuntimeFromMainStateInput<TStartupState>['shell'];
runtime: {
subtitle: SubtitleRuntime;
getOverlayUi: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['getOverlayUi'];
overlayManager: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['overlayManager'];
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
anilist: AnilistRuntime;
jellyfin: JellyfinRuntime;
stats: {
ensureImmersionTrackerStarted: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
runStatsCliCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
immersion: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['immersion'];
};
mining: {
copyCurrentSubtitle: Pick<MiningRuntime, 'copyCurrentSubtitle'>['copyCurrentSubtitle'];
markLastCardAsAudioCard: Pick<
MiningRuntime,
'markLastCardAsAudioCard'
>['markLastCardAsAudioCard'];
mineSentenceCard: Pick<MiningRuntime, 'mineSentenceCard'>['mineSentenceCard'];
refreshKnownWordCache: Pick<MiningRuntime, 'refreshKnownWordCache'>['refreshKnownWordCache'];
triggerFieldGrouping: Pick<MiningRuntime, 'triggerFieldGrouping'>['triggerFieldGrouping'];
updateLastCardFromClipboard: Pick<
MiningRuntime,
'updateLastCardFromClipboard'
>['updateLastCardFromClipboard'];
};
texthookerService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['texthookerService'];
yomitan: {
loadYomitanExtension: () => Promise<unknown>;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
openYomitanSettings: () => boolean;
getCharacterDictionaryDisabledReason: () => string | null;
};
subsyncRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subsyncRuntime'];
dictionarySupport: DictionarySupportRuntime;
subtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subtitleWsService'];
annotationSubtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['annotationSubtitleWsService'];
};
commands: {
mpvRuntime: {
createMpvClientRuntimeService: Pick<
MpvRuntime,
'createMpvClientRuntimeService'
>['createMpvClientRuntimeService'];
createMecabTokenizerAndCheck: Pick<
MpvRuntime,
'createMecabTokenizerAndCheck'
>['createMecabTokenizerAndCheck'];
prewarmSubtitleDictionaries: Pick<
MpvRuntime,
'prewarmSubtitleDictionaries'
>['prewarmSubtitleDictionaries'];
startBackgroundWarmups: Pick<MpvRuntime, 'startBackgroundWarmups'>['startBackgroundWarmups'];
};
runHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runHeadlessInitialCommand'];
shortcuts: Pick<
ShortcutsRuntime,
| 'getConfiguredShortcuts'
| 'refreshOverlayShortcuts'
| 'startPendingMineSentenceMultiple'
| 'startPendingMultiCopy'
>;
cycleSecondarySubMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['cycleSecondarySubMode'];
hasMpvWebsocketPlugin: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
showMpvOsd: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['showMpvOsd'];
shouldAutoOpenFirstRunSetup: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
youtube: Pick<YoutubeRuntime, 'runYoutubePlaybackFlow'>;
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
isHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['isHeadlessInitialCommand'];
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
commandNeedsOverlayRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
shouldStartApp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldStartApp'];
parseArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['parseArgs'];
printHelp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['printHelp'];
onWillQuitCleanupHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
restoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
forceX11Backend: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['forceX11Backend'];
enforceUnsupportedWaylandMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
getDefaultSocketPath: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getDefaultSocketPath'];
generateDefaultConfigFile: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['generateDefaultConfigFile'];
runStartupBootstrapRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
applyStartupState: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['applyStartupState'];
getStartupModeFlags: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getStartupModeFlags'];
requestAppQuit: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['requestAppQuit'];
};
constants: MainStartupRuntimeFromMainStateInput<TStartupState>['constants'];
}
export interface MainStartupRuntimeFromProcessStateInput<TStartupState> {
appState: MainStartupRuntimeCoordinatorInput<TStartupState>['appState'];
appLifecycle: MainStartupRuntimeCoordinatorInput<TStartupState>['appLifecycle'];
config: MainStartupRuntimeCoordinatorInput<TStartupState>['config'];
logging: MainStartupRuntimeCoordinatorInput<TStartupState>['logging'];
shell: MainStartupRuntimeCoordinatorInput<TStartupState>['shell'];
runtime: {
subtitle: SubtitleRuntime;
startupOverlayUiAdapter: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['getOverlayUi'] extends () => infer T
? T
: never;
overlayManager: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['overlayManager'];
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
anilist: AnilistRuntime;
jellyfin: JellyfinRuntime;
stats: {
ensureImmersionTrackerStarted: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
runStatsCliCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
immersion: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['immersion'];
};
mining: MiningRuntime;
texthookerService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['texthookerService'];
yomitan: {
loadYomitanExtension: () => Promise<unknown>;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
openYomitanSettings: () => boolean;
getCharacterDictionaryDisabledReason: () => string | null;
};
subsyncRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subsyncRuntime'];
dictionarySupport: DictionarySupportRuntime;
subtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subtitleWsService'];
annotationSubtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['annotationSubtitleWsService'];
};
commands: {
mpvRuntime: MpvRuntime;
runHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runHeadlessInitialCommand'];
shortcuts: Pick<
ShortcutsRuntime,
| 'getConfiguredShortcuts'
| 'refreshOverlayShortcuts'
| 'startPendingMineSentenceMultiple'
| 'startPendingMultiCopy'
>;
cycleSecondarySubMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['cycleSecondarySubMode'];
hasMpvWebsocketPlugin: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
showMpvOsd: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['showMpvOsd'];
shouldAutoOpenFirstRunSetup: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
youtube: YoutubeRuntime;
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
isHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['isHeadlessInitialCommand'];
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
commandNeedsOverlayRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
shouldStartApp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldStartApp'];
parseArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['parseArgs'];
printHelp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['printHelp'];
onWillQuitCleanupHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
restoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
forceX11Backend: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['forceX11Backend'];
enforceUnsupportedWaylandMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
getDefaultSocketPath: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getDefaultSocketPath'];
generateDefaultConfigFile: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['generateDefaultConfigFile'];
runStartupBootstrapRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
applyStartupState: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['applyStartupState'];
getStartupModeFlags: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getStartupModeFlags'];
requestAppQuit: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['requestAppQuit'];
};
constants: MainStartupRuntimeCoordinatorInput<TStartupState>['constants'];
}
export function createMainStartupRuntimeCoordinator<TStartupState>(
input: MainStartupRuntimeCoordinatorInput<TStartupState>,
): MainStartupRuntimeBootstrap<TStartupState> {
return createMainStartupRuntimeFromMainState<TStartupState>({
appState: input.appState,
appLifecycle: input.appLifecycle,
config: input.config,
logging: input.logging,
shell: input.shell,
runtime: {
subtitle: {
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
invalidateTokenizationCache: () => {
input.runtime.subtitle.invalidateTokenizationCache();
},
refreshSubtitlePrefetchFromActiveTrack: () =>
input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
},
getOverlayUi: () => input.runtime.getOverlayUi(),
overlayManager: input.runtime.overlayManager,
firstRun: input.runtime.firstRun,
anilist: input.runtime.anilist,
jellyfin: {
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
runJellyfinCommand: (argsFromCommand) =>
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
},
stats: {
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
runStatsCliCommand: (argsFromCommand, source) =>
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
},
mining: {
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
},
texthookerService: input.runtime.texthookerService,
yomitan: {
loadYomitanExtension: () => input.runtime.yomitan.loadYomitanExtension(),
ensureYomitanExtensionLoaded: () => input.runtime.yomitan.ensureYomitanExtensionLoaded(),
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
},
getCharacterDictionaryDisabledReason: () =>
input.runtime.yomitan.getCharacterDictionaryDisabledReason(),
subsyncRuntime: input.runtime.subsyncRuntime,
dictionarySupport: input.runtime.dictionarySupport,
subtitleWsService: input.runtime.subtitleWsService,
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
immersion: input.runtime.stats.immersion,
},
commands: {
mpvRuntime: {
createMpvClientRuntimeService: () =>
input.commands.mpvRuntime.createMpvClientRuntimeService(),
createMecabTokenizerAndCheck: () =>
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
},
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
shortcuts: {
startPendingMultiCopy: (timeoutMs) =>
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
startPendingMineSentenceMultiple: (timeoutMs) =>
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
},
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
showMpvOsd: (text) => input.commands.showMpvOsd(text),
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
youtube: {
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
},
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
commandNeedsOverlayStartupPrereqs: (args) =>
input.commands.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
shouldStartApp: (args) => input.commands.shouldStartApp(args),
parseArgs: (argv) => input.commands.parseArgs(argv),
printHelp: input.commands.printHelp,
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivateHandler: () =>
input.commands.shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
forceX11Backend: (args) => input.commands.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
getStartupModeFlags: input.commands.getStartupModeFlags,
requestAppQuit: input.commands.requestAppQuit,
},
constants: input.constants,
});
}
export function createMainStartupRuntimeFromProcessState<TStartupState>(
input: MainStartupRuntimeFromProcessStateInput<TStartupState>,
) {
return createMainStartupRuntimeCoordinator<TStartupState>({
appState: input.appState,
appLifecycle: input.appLifecycle,
config: input.config,
logging: input.logging,
shell: input.shell,
runtime: {
subtitle: input.runtime.subtitle,
getOverlayUi: () => input.runtime.startupOverlayUiAdapter,
overlayManager: input.runtime.overlayManager,
firstRun: input.runtime.firstRun,
anilist: input.runtime.anilist,
jellyfin: input.runtime.jellyfin,
stats: {
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
runStatsCliCommand: (argsFromCommand, source) =>
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
immersion: input.runtime.stats.immersion,
},
mining: {
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
},
texthookerService: input.runtime.texthookerService,
yomitan: input.runtime.yomitan,
subsyncRuntime: input.runtime.subsyncRuntime,
dictionarySupport: input.runtime.dictionarySupport,
subtitleWsService: input.runtime.subtitleWsService,
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
},
commands: {
mpvRuntime: {
createMpvClientRuntimeService: () =>
input.commands.mpvRuntime.createMpvClientRuntimeService(),
createMecabTokenizerAndCheck: () =>
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
},
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
shortcuts: input.commands.shortcuts,
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
showMpvOsd: (text) => input.commands.showMpvOsd(text),
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
youtube: input.commands.youtube,
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
commandNeedsOverlayStartupPrereqs: (args) =>
input.commands.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
shouldStartApp: (args) => input.commands.shouldStartApp(args),
parseArgs: (argv) => input.commands.parseArgs(argv),
printHelp: input.commands.printHelp,
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivateHandler: () =>
input.commands.shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
forceX11Backend: (args) => input.commands.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
getStartupModeFlags: input.commands.getStartupModeFlags,
requestAppQuit: input.commands.requestAppQuit,
},
constants: input.constants,
}).startupRuntime;
}

View File

@@ -0,0 +1,260 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainStartupRuntime } from './main-startup-runtime';
test('main startup runtime composes app-ready, cli, and headless runtimes', async () => {
const calls: string[] = [];
const runtime = createMainStartupRuntime<{ mode: string }>({
appReady: {
reload: {
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => undefined,
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfig: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
immersion: {
getResolvedConfig: () => ({ immersionTracking: { enabled: false } }) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () => ({}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
runner: {
ensureDefaultConfigBootstrap: () => {
calls.push('ensureDefaultConfigBootstrap');
},
getSubtitlePosition: () => null,
loadSubtitlePosition: () => {
calls.push('loadSubtitlePosition');
},
getKeybindingsCount: () => 0,
resolveKeybindings: () => {
calls.push('resolveKeybindings');
},
hasMpvClient: () => false,
createMpvClient: () => {
calls.push('createMpvClient');
},
getRuntimeOptionsManager: () => null,
initRuntimeOptionsManager: () => {
calls.push('initRuntimeOptionsManager');
},
getSubtitleTimingTracker: () => null,
createSubtitleTimingTracker: () => {
calls.push('createSubtitleTimingTracker');
},
getResolvedConfig: () => ({ ankiConnect: { enabled: false } }) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
loadYomitanExtension: async () => {},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensureYomitanExtensionLoaded');
},
handleFirstRunSetup: async () => {},
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {
calls.push('initializeOverlayRuntime');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
},
isOverlayRuntimeInitialized: () => false,
},
cli: {
appState: {
appState: {} as never,
getInitialArgs: () => null,
isBackgroundMode: () => false,
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => {},
hasImmersionTracker: () => false,
getMpvClient: () => null,
isOverlayRuntimeInitialized: () => false,
},
config: {
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
getResolvedConfig: () => ({}) as never,
setCliLogLevel: () => {},
hasMpvWebsocketPlugin: () => false,
},
io: {
texthookerService: {} as never,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: () => {},
schedule: () => 0 as never,
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
commands: {
initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {},
openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {
calls.push('stopApp');
},
hasMainWindow: () => false,
getMultiCopyTimeoutMs: () => 0,
},
startup: {
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
calls.push('ensureOverlayStartupPrereqs');
},
startBackgroundWarmups: () => {
calls.push('startupStartBackgroundWarmups');
},
},
handleCliCommandRuntimeServiceWithContext: (args) => {
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
},
},
headless: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => (args) => {
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
},
bootstrap: {
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: () => {},
},
runStartupBootstrapRuntime: (deps) => {
deps.startAppLifecycle({ command: 'start' } as never);
return { mode: 'started' };
},
applyStartupState: (state: { mode: string }) => {
calls.push(`apply:${state.mode}`);
},
},
});
assert.equal(typeof runtime.appReady.runAppReady, 'function');
assert.equal(typeof runtime.cliStartup.handleCliCommand, 'function');
assert.equal(typeof runtime.headlessStartup.runAndApplyStartupState, 'function');
assert.equal(runtime.handleCliCommand, runtime.cliStartup.handleCliCommand);
assert.equal(runtime.handleInitialArgs, runtime.cliStartup.handleInitialArgs);
assert.equal(
runtime.appLifecycleRuntimeRunner,
runtime.headlessStartup.appLifecycleRuntimeRunner,
);
assert.equal(runtime.runAndApplyStartupState, runtime.headlessStartup.runAndApplyStartupState);
runtime.appReady.ensureOverlayStartupPrereqs();
runtime.handleCliCommand({ command: 'start' } as never);
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, [
'loadSubtitlePosition',
'resolveKeybindings',
'createMpvClient',
'initRuntimeOptionsManager',
'createSubtitleTimingTracker',
'handle:start',
'lifecycle:start',
'apply:started',
]);
});

View File

@@ -0,0 +1,43 @@
import type { AppReadyRuntimeInput, AppReadyRuntime } from './app-ready-runtime';
import type { CliStartupRuntimeInput, CliStartupRuntime } from './cli-startup-runtime';
import type {
HeadlessStartupRuntimeInput,
HeadlessStartupRuntime,
} from './headless-startup-runtime';
import { createAppReadyRuntime } from './app-ready-runtime';
import { createCliStartupRuntime } from './cli-startup-runtime';
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
export interface MainStartupRuntimeInput<TStartupState> {
appReady: AppReadyRuntimeInput;
cli: CliStartupRuntimeInput;
headless: HeadlessStartupRuntimeInput<TStartupState>;
}
export interface MainStartupRuntime<TStartupState> {
appReady: AppReadyRuntime;
cliStartup: CliStartupRuntime;
headlessStartup: HeadlessStartupRuntime<TStartupState>;
handleCliCommand: CliStartupRuntime['handleCliCommand'];
handleInitialArgs: CliStartupRuntime['handleInitialArgs'];
appLifecycleRuntimeRunner: HeadlessStartupRuntime<TStartupState>['appLifecycleRuntimeRunner'];
runAndApplyStartupState: HeadlessStartupRuntime<TStartupState>['runAndApplyStartupState'];
}
export function createMainStartupRuntime<TStartupState>(
input: MainStartupRuntimeInput<TStartupState>,
): MainStartupRuntime<TStartupState> {
const appReady = createAppReadyRuntime(input.appReady);
const cliStartup = createCliStartupRuntime(input.cli);
const headlessStartup = createHeadlessStartupRuntime<TStartupState>(input.headless);
return {
appReady,
cliStartup,
headlessStartup,
handleCliCommand: cliStartup.handleCliCommand,
handleInitialArgs: cliStartup.handleInitialArgs,
appLifecycleRuntimeRunner: headlessStartup.appLifecycleRuntimeRunner,
runAndApplyStartupState: headlessStartup.runAndApplyStartupState,
};
}

View File

@@ -1,4 +1,4 @@
import { updateCurrentMediaPath } from '../core/services';
import { updateCurrentMediaPath } from '../core/services/subtitle-position';
import type { SubtitlePosition } from '../types';

163
src/main/mining-runtime.ts Normal file
View File

@@ -0,0 +1,163 @@
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { appendClipboardVideoToQueueRuntime } from './runtime/clipboard-queue';
import {
createUpdateLastCardFromClipboardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
} from './runtime/anki-actions';
import {
createHandleMultiCopyDigitHandler,
createCopyCurrentSubtitleHandler,
createHandleMineSentenceDigitHandler,
} from './runtime/mining-actions';
export interface MiningRuntimeInput<TAnkiIntegration = unknown, TMpvClient = unknown> {
getSubtitleTimingTracker: () => SubtitleTimingTracker;
getAnkiIntegration: () => TAnkiIntegration;
getMpvClient: () => TMpvClient;
readClipboardText: () => string;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
sendMpvCommand: (command: (string | number)[]) => void;
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnkiIntegration;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnkiIntegration;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnkiIntegration;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
mineSentenceCardCore: (options: {
ankiIntegration: TAnkiIntegration;
mpvClient: TMpvClient;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
handleMultiCopyDigitCore: (
count: number,
options: {
subtitleTimingTracker: SubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
},
) => void;
copyCurrentSubtitleCore: (options: {
subtitleTimingTracker: SubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
}) => void;
handleMineSentenceDigitCore: (
count: number,
options: {
subtitleTimingTracker: SubtitleTimingTracker;
ankiIntegration: TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
},
) => void;
getCurrentSecondarySubText: () => string | undefined;
logError: (message: string, err: unknown) => void;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}
export interface MiningRuntime {
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
handleMultiCopyDigit: (count: number) => void;
copyCurrentSubtitle: () => void;
handleMineSentenceDigit: (count: number) => void;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
}
export function createMiningRuntime<TAnkiIntegration, TMpvClient>(
input: MiningRuntimeInput<TAnkiIntegration, TMpvClient>,
): MiningRuntime {
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => input.getAnkiIntegration(),
readClipboardText: () => input.readClipboardText(),
showMpvOsd: (text) => input.showMpvOsd(text),
updateLastCardFromClipboardCore: (options) => input.updateLastCardFromClipboardCore(options),
});
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () =>
input.getAnkiIntegration() as { refreshKnownWordCache: () => Promise<void> } | null,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => input.getAnkiIntegration(),
showMpvOsd: (text) => input.showMpvOsd(text),
triggerFieldGroupingCore: (options) => input.triggerFieldGroupingCore(options),
});
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => input.getAnkiIntegration(),
showMpvOsd: (text) => input.showMpvOsd(text),
markLastCardAsAudioCardCore: (options) => input.markLastCardAsAudioCardCore(options),
});
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => input.getAnkiIntegration(),
getMpvClient: () => input.getMpvClient(),
showMpvOsd: (text) => input.showMpvOsd(text),
mineSentenceCardCore: (options) => input.mineSentenceCardCore(options),
recordCardsMined: (count, noteIds) => input.recordCardsMined(count, noteIds),
});
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
writeClipboardText: (text) => input.writeClipboardText(text),
showMpvOsd: (text) => input.showMpvOsd(text),
handleMultiCopyDigitCore: (count, options) => input.handleMultiCopyDigitCore(count, options),
});
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
writeClipboardText: (text) => input.writeClipboardText(text),
showMpvOsd: (text) => input.showMpvOsd(text),
copyCurrentSubtitleCore: (options) => input.copyCurrentSubtitleCore(options),
});
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
getAnkiIntegration: () => input.getAnkiIntegration(),
getCurrentSecondarySubText: () => input.getCurrentSecondarySubText(),
showMpvOsd: (text) => input.showMpvOsd(text),
logError: (message, err) => input.logError(message, err),
onCardsMined: (count) => input.recordCardsMined(count),
handleMineSentenceDigitCore: (count, options) =>
input.handleMineSentenceDigitCore(count, options),
});
const appendClipboardVideoToQueue = (): { ok: boolean; message: string } =>
appendClipboardVideoToQueueRuntime({
getMpvClient: () => input.getMpvClient() as { connected: boolean } | null,
readClipboardText: () => input.readClipboardText(),
showMpvOsd: (text) => input.showMpvOsd(text),
sendMpvCommand: (command) => input.sendMpvCommand(command),
});
return {
updateLastCardFromClipboard,
refreshKnownWordCache,
triggerFieldGrouping,
markLastCardAsAudioCard,
mineSentenceCard,
handleMultiCopyDigit,
copyCurrentSubtitle,
handleMineSentenceDigit,
appendClipboardVideoToQueue,
};
}

View File

@@ -0,0 +1,285 @@
import type { MpvRuntime, MpvRuntimeInput } from './mpv-runtime';
import { createMpvRuntime } from './mpv-runtime';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
import type { AnilistRuntime } from './anilist-runtime';
import type { JellyfinRuntime } from './jellyfin-runtime';
import type { YoutubeRuntime } from './youtube-runtime';
export interface MpvRuntimeBootstrapInput {
appState: MpvRuntimeInput['appState'];
logPath: string;
logger: MpvRuntimeInput['logger'];
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
subtitle: MpvRuntimeInput['subtitle'];
ensureYomitanExtensionLoaded: MpvRuntimeInput['ensureYomitanExtensionLoaded'];
currentMediaTokenizationGate: MpvRuntimeInput['currentMediaTokenizationGate'];
startupOsdSequencer: MpvRuntimeInput['startupOsdSequencer'];
dictionarySupport: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
syncImmersionMediaState: () => void;
updateCurrentMediaPath: (mediaPath: unknown) => void;
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
scheduleCharacterDictionarySync: () => void;
};
overlay: {
overlayManager: {
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getVisibleOverlayVisible: () => boolean;
};
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
};
lifecycle: {
requestAppQuit: () => void;
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
restoreOverlayMpvSubtitles: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
publishDiscordPresence: () => void;
};
stats: MpvRuntimeInput['stats'];
anilist: MpvRuntimeInput['anilist'];
jellyfin: MpvRuntimeInput['jellyfin'];
youtube: MpvRuntimeInput['youtube'];
isCharacterDictionaryEnabled: MpvRuntimeInput['isCharacterDictionaryEnabled'];
}
export interface MpvRuntimeBootstrap {
mpvRuntime: MpvRuntime;
}
export interface MpvRuntimeFromMainStateInput {
appState: MpvRuntimeInput['appState'];
logPath: string;
logger: MpvRuntimeInput['logger'];
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
subtitle: SubtitleRuntime;
yomitan: {
ensureYomitanExtensionLoaded: () => Promise<void>;
};
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
dictionarySupport: DictionarySupportRuntime;
overlay: {
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getVisibleOverlayVisible: () => boolean;
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
};
lifecycle: {
requestAppQuit: () => void;
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
restoreOverlayMpvSubtitles: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
publishDiscordPresence: () => void;
};
stats: {
ensureImmersionTrackerStarted: () => void;
};
anilist: AnilistRuntime;
jellyfin: JellyfinRuntime;
youtube: YoutubeRuntime;
isCharacterDictionaryEnabled: () => boolean;
}
export function createMpvRuntimeBootstrap(input: MpvRuntimeBootstrapInput): MpvRuntimeBootstrap {
const mpvRuntime = createMpvRuntime({
appState: input.appState,
logPath: input.logPath,
logger: input.logger,
getResolvedConfig: input.getResolvedConfig,
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
subtitle: input.subtitle,
ensureYomitanExtensionLoaded: input.ensureYomitanExtensionLoaded,
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
startupOsdSequencer: input.startupOsdSequencer,
dictionaries: {
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () =>
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
},
mediaRuntime: {
syncImmersionMediaState: () => input.dictionarySupport.syncImmersionMediaState(),
updateCurrentMediaPath: (mediaPath) => {
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
},
updateCurrentMediaTitle: (mediaTitle) => {
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
},
},
characterDictionaryAutoSyncRuntime: {
scheduleSync: () => {
input.dictionarySupport.scheduleCharacterDictionarySync();
},
},
overlay: {
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload);
},
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
setOverlayVisible: (visible) => {
input.overlay.getOverlayUi()?.setOverlayVisible(visible);
},
},
lifecycle: {
requestAppQuit: () => input.lifecycle.requestAppQuit(),
scheduleQuitCheck: (callback) => {
input.lifecycle.setQuitCheckTimer(callback, 500);
},
restoreOverlayMpvSubtitles: () => {
input.lifecycle.restoreOverlayMpvSubtitles();
},
syncOverlayMpvSubtitleSuppression: () => {
input.lifecycle.syncOverlayMpvSubtitleSuppression();
},
refreshDiscordPresence: () => {
input.lifecycle.publishDiscordPresence();
},
},
stats: input.stats,
anilist: input.anilist,
jellyfin: input.jellyfin,
youtube: input.youtube,
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
});
return {
mpvRuntime,
};
}
export function createMpvRuntimeFromMainState(
input: MpvRuntimeFromMainStateInput,
): MpvRuntimeBootstrap {
return createMpvRuntimeBootstrap({
appState: input.appState,
logPath: input.logPath,
logger: input.logger,
getResolvedConfig: input.getResolvedConfig,
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
subtitle: {
consumeCachedSubtitle: (text) => input.subtitle.consumeCachedSubtitle(text),
emitSubtitlePayload: (payload) => input.subtitle.emitSubtitlePayload(payload),
onSubtitleChange: (text) => {
input.subtitle.onSubtitleChange(text);
},
onCurrentMediaPathChange: (path) => {
input.subtitle.onCurrentMediaPathChange(path);
},
onTimePosUpdate: (time) => {
input.subtitle.onTimePosUpdate(time);
},
scheduleSubtitlePrefetchRefresh: (delayMs) =>
input.subtitle.scheduleSubtitlePrefetchRefresh(delayMs),
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
setTokenizeSubtitleDeferred: (tokenize) => {
input.subtitle.setTokenizeSubtitleDeferred(tokenize);
},
},
ensureYomitanExtensionLoaded: async () => {
await input.yomitan.ensureYomitanExtensionLoaded();
},
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
startupOsdSequencer: input.startupOsdSequencer,
dictionarySupport: {
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () =>
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
syncImmersionMediaState: () => {
input.dictionarySupport.syncImmersionMediaState();
},
updateCurrentMediaPath: (mediaPath) => {
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
},
updateCurrentMediaTitle: (mediaTitle) => {
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
},
scheduleCharacterDictionarySync: () => {
input.dictionarySupport.scheduleCharacterDictionarySync();
},
},
overlay: {
overlayManager: {
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.broadcastToOverlayWindows(channel, payload);
},
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
},
getOverlayUi: () => input.overlay.getOverlayUi(),
},
lifecycle: {
requestAppQuit: () => input.lifecycle.requestAppQuit(),
setQuitCheckTimer: (callback, timeoutMs) => {
input.lifecycle.setQuitCheckTimer(callback, timeoutMs);
},
restoreOverlayMpvSubtitles: () => {
input.lifecycle.restoreOverlayMpvSubtitles();
},
syncOverlayMpvSubtitleSuppression: () => {
input.lifecycle.syncOverlayMpvSubtitleSuppression();
},
publishDiscordPresence: () => {
input.lifecycle.publishDiscordPresence();
},
},
stats: {
ensureImmersionTrackerStarted: () => input.stats.ensureImmersionTrackerStarted(),
},
anilist: {
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
input.anilist.resetAnilistMediaTracking(mediaKey);
},
maybeProbeAnilistDuration: (mediaKey) => {
if (mediaKey) {
void input.anilist.maybeProbeAnilistDuration(mediaKey);
}
},
ensureAnilistMediaGuess: (mediaKey) => {
if (mediaKey) {
void input.anilist.ensureAnilistMediaGuess(mediaKey);
}
},
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
resetAnilistMediaGuessState: () => {
input.anilist.resetAnilistMediaGuessState();
},
},
jellyfin: {
getQuitOnDisconnectArmed: () => input.jellyfin.getQuitOnDisconnectArmed(),
reportJellyfinRemoteStopped: () => input.jellyfin.reportJellyfinRemoteStopped(),
reportJellyfinRemoteProgress: (forceImmediate) =>
input.jellyfin.reportJellyfinRemoteProgress(forceImmediate),
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
},
youtube: {
getQuitOnDisconnectArmed: () => input.youtube.getQuitOnDisconnectArmed(),
handleMpvConnectionChange: (connected) => {
input.youtube.handleMpvConnectionChange(connected);
},
handleMediaPathChange: (path) => {
input.youtube.invalidatePendingAutoplayReadyFallbacks();
input.currentMediaTokenizationGate.updateCurrentMediaPath(path);
input.startupOsdSequencer.reset();
input.youtube.handleMediaPathChange(path);
if (path) {
input.stats.ensureImmersionTrackerStarted();
}
},
handleSubtitleTrackChange: (sid) => {
input.youtube.handleSubtitleTrackChange(sid);
},
handleSubtitleTrackListChange: (trackList) => {
input.youtube.handleSubtitleTrackListChange(trackList);
},
invalidatePendingAutoplayReadyFallbacks: () =>
input.youtube.invalidatePendingAutoplayReadyFallbacks(),
maybeSignalPluginAutoplayReady: (subtitle, options) =>
input.youtube.maybeSignalPluginAutoplayReady(subtitle, options),
},
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
});
}

504
src/main/mpv-runtime.ts Normal file
View File

@@ -0,0 +1,504 @@
import * as fs from 'fs';
import * as path from 'path';
import { MecabTokenizer } from '../mecab-tokenizer';
import {
MpvIpcClient,
applyMpvSubtitleRenderMetricsPatch,
createShiftSubtitleDelayToAdjacentCueHandler,
createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore,
sendMpvCommandRuntime,
showMpvOsdRuntime,
tokenizeSubtitle as tokenizeSubtitleCore,
} from '../core/services';
import type {
MpvSubtitleRenderMetrics,
ResolvedConfig,
SecondarySubMode,
SubtitleData,
} from '../types';
import type { AppState } from './state';
import type { SubtitleRuntime } from './subtitle-runtime';
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
import { createMpvOsdRuntimeHandlers } from './runtime/mpv-osd-runtime-handlers';
import { createCycleSecondarySubModeRuntimeHandler } from './runtime/secondary-sub-mode-runtime-handler';
import { composeMpvRuntimeHandlers } from './runtime/composers';
type RuntimeOptionId =
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency';
interface MpvRuntimeLogger {
debug: (message: string, meta?: unknown) => void;
info: (message: string, meta?: unknown) => void;
warn: (message: string, meta?: unknown) => void;
error: (message: string, error?: unknown) => void;
}
export interface MpvRuntimeInput {
appState: AppState;
logPath: string;
logger: MpvRuntimeLogger;
getResolvedConfig: () => ResolvedConfig;
getRuntimeBooleanOption: (id: RuntimeOptionId, fallback: boolean) => boolean;
subtitle: Pick<
SubtitleRuntime,
| 'consumeCachedSubtitle'
| 'emitSubtitlePayload'
| 'onSubtitleChange'
| 'onCurrentMediaPathChange'
| 'onTimePosUpdate'
| 'scheduleSubtitlePrefetchRefresh'
| 'loadSubtitleSourceText'
| 'setTokenizeSubtitleDeferred'
>;
ensureYomitanExtensionLoaded: () => Promise<void>;
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
dictionaries: {
ensureJlptDictionaryLookup: () => Promise<void>;
ensureFrequencyDictionaryLookup: () => Promise<void>;
};
mediaRuntime: {
syncImmersionMediaState: () => void;
updateCurrentMediaPath: (mediaPath: unknown) => void;
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
};
characterDictionaryAutoSyncRuntime: {
scheduleSync: () => void;
};
overlay: {
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getVisibleOverlayVisible: () => boolean;
setOverlayVisible: (visible: boolean) => void;
};
lifecycle: {
requestAppQuit: () => void;
scheduleQuitCheck: (callback: () => void) => void;
restoreOverlayMpvSubtitles: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
refreshDiscordPresence: () => void;
};
stats: {
ensureImmersionTrackerStarted: () => void;
};
anilist: {
getCurrentAnilistMediaKey: () => string | null;
resetAnilistMediaTracking: (mediaKey: string | null) => void;
maybeProbeAnilistDuration: (mediaKey: string | null) => void;
ensureAnilistMediaGuess: (mediaKey: string | null) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
resetAnilistMediaGuessState: () => void;
};
jellyfin: {
getQuitOnDisconnectArmed: () => boolean;
reportJellyfinRemoteStopped: () => Promise<void>;
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
startJellyfinRemoteSession: () => Promise<void>;
};
youtube: {
getQuitOnDisconnectArmed: () => boolean;
handleMpvConnectionChange: (connected: boolean) => void;
handleMediaPathChange: (path: string | null) => void;
handleSubtitleTrackChange: (sid: number | null) => void;
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
invalidatePendingAutoplayReadyFallbacks: () => void;
maybeSignalPluginAutoplayReady: (
subtitle: { text: string; tokens: null },
options?: { forceWhilePaused?: boolean },
) => void;
};
isCharacterDictionaryEnabled: () => boolean;
}
export interface MpvRuntime {
createMpvClientRuntimeService: () => MpvIpcClient;
updateMpvSubtitleRenderMetrics: (patch: Partial<MpvSubtitleRenderMetrics>) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
isTokenizationWarmupReady: () => boolean;
startBackgroundWarmups: () => void;
showMpvOsd: (text: string) => void;
flushMpvLog: () => Promise<void>;
cycleSecondarySubMode: () => void;
shiftSubtitleDelayToAdjacentCue: (direction: 'next' | 'previous') => Promise<void>;
}
function getActiveMediaPath(appState: AppState): string | null {
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
}
export function createMpvRuntime(input: MpvRuntimeInput): MpvRuntime {
let backgroundWarmupsStarted = false;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: input.logPath,
dirname: (targetPath) => path.dirname(targetPath),
mkdir: async (targetPath, options) => {
await fs.promises.mkdir(targetPath, options);
},
appendFile: async (targetPath, data, options) => {
await fs.promises.appendFile(targetPath, data, options);
},
now: () => new Date(),
},
buildShowMpvOsdMainDeps: (appendToMpvLog) => ({
appendToMpvLog,
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
showMpvOsdRuntime(mpvClient, text, fallbackLog),
getMpvClient: () => input.appState.mpvClient,
logInfo: (line) => input.logger.info(line),
}),
});
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => input.appState.secondarySubMode,
setSecondarySubMode: (mode: SecondarySubMode) => {
input.appState.secondarySubMode = mode;
},
getLastSecondarySubToggleAtMs: () => input.appState.lastSecondarySubToggleAtMs,
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
input.appState.lastSecondarySubToggleAtMs = timestampMs;
},
broadcastToOverlayWindows: (channel, mode) => {
input.overlay.broadcastToOverlayWindows(channel, mode);
},
showMpvOsd: (text: string) => showMpvOsd(text),
},
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
});
const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
tokenizeSubtitle,
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
startTokenizationWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>,
SubtitleData
>({
bindMpvMainEventHandlersMainDeps: {
appState: input.appState,
getQuitOnDisconnectArmed: () =>
input.jellyfin.getQuitOnDisconnectArmed() || input.youtube.getQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback) => {
input.lifecycle.scheduleQuitCheck(callback);
},
quitApp: () => input.lifecycle.requestAppQuit(),
reportJellyfinRemoteStopped: () => {
void input.jellyfin.reportJellyfinRemoteStopped();
},
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
logSubtitleTimingError: (message, error) => input.logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.broadcastToOverlayWindows(channel, payload);
},
getImmediateSubtitlePayload: (text) => input.subtitle.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
input.subtitle.emitSubtitlePayload(payload);
},
onSubtitleChange: (text) => {
input.subtitle.onSubtitleChange(text);
},
refreshDiscordPresence: () => {
input.lifecycle.refreshDiscordPresence();
},
ensureImmersionTrackerInitialized: () => {
input.stats.ensureImmersionTrackerStarted();
},
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (mediaPath) => {
input.youtube.invalidatePendingAutoplayReadyFallbacks();
input.currentMediaTokenizationGate.updateCurrentMediaPath(mediaPath);
input.startupOsdSequencer.reset();
input.subtitle.onCurrentMediaPathChange(mediaPath);
input.youtube.handleMediaPathChange(mediaPath);
if (mediaPath) {
input.stats.ensureImmersionTrackerStarted();
}
input.mediaRuntime.updateCurrentMediaPath(mediaPath);
},
restoreMpvSubVisibility: () => {
input.lifecycle.restoreOverlayMpvSubtitles();
},
resetSubtitleSidebarEmbeddedLayout: () => {
sendMpvCommandRuntime(input.appState.mpvClient, [
'set_property',
'video-margin-ratio-right',
0,
]);
sendMpvCommandRuntime(input.appState.mpvClient, ['set_property', 'video-pan-x', 0]);
},
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
input.anilist.resetAnilistMediaTracking(mediaKey);
},
maybeProbeAnilistDuration: (mediaKey) => {
if (mediaKey) {
input.anilist.maybeProbeAnilistDuration(mediaKey);
}
},
ensureAnilistMediaGuess: (mediaKey) => {
if (mediaKey) {
input.anilist.ensureAnilistMediaGuess(mediaKey);
}
},
syncImmersionMediaState: () => {
input.mediaRuntime.syncImmersionMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (!isTokenizationWarmupReady()) {
return;
}
input.youtube.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
scheduleCharacterDictionarySync: () => {
if (!input.isCharacterDictionaryEnabled()) {
return;
}
input.characterDictionaryAutoSyncRuntime.scheduleSync();
},
updateCurrentMediaTitle: (title) => {
input.mediaRuntime.updateCurrentMediaTitle(title);
},
resetAnilistMediaGuessState: () => {
input.anilist.resetAnilistMediaGuessState();
},
reportJellyfinRemoteProgress: (forceImmediate) => {
void input.jellyfin.reportJellyfinRemoteProgress(forceImmediate);
},
onTimePosUpdate: (time) => {
input.subtitle.onTimePosUpdate(time);
},
onSubtitleTrackChange: (sid) => {
input.subtitle.scheduleSubtitlePrefetchRefresh();
input.youtube.handleSubtitleTrackChange(sid);
},
onSubtitleTrackListChange: (trackList) => {
input.subtitle.scheduleSubtitlePrefetchRefresh();
input.youtube.handleSubtitleTrackListChange(trackList);
},
updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetricsHandler(patch as Partial<MpvSubtitleRenderMetrics>);
},
syncOverlayMpvSubtitleSuppression: () => {
input.lifecycle.syncOverlayMpvSubtitleSuppression();
},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient,
getSocketPath: () => input.appState.mpvSocketPath,
getResolvedConfig: () => input.getResolvedConfig(),
isAutoStartOverlayEnabled: () => input.appState.autoStartOverlay,
setOverlayVisible: (visible: boolean) => {
input.overlay.setOverlayVisible(visible);
},
isVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
getReconnectTimer: () => input.appState.reconnectTimer,
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
input.appState.reconnectTimer = timer;
},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => input.appState.mpvSubtitleRenderMetrics,
setCurrentMetrics: (metrics) => {
input.appState.mpvSubtitleRenderMetrics = metrics;
},
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => input.appState.yomitanExt,
getYomitanSession: () => input.appState.yomitanSession,
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
input.appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
input.appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
input.appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text) => Boolean(input.appState.ankiIntegration?.isKnownWord(text)),
recordLookup: (hit) => {
input.stats.ensureImmersionTrackerStarted();
input.appState.immersionTracker?.recordLookup(hit);
},
getKnownWordMatchMode: () =>
input.appState.ankiIntegration?.getKnownWordMatchMode() ??
input.getResolvedConfig().ankiConnect.knownWords.matchMode,
getNPlusOneEnabled: () =>
input.getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
),
getMinSentenceWordsForNPlusOne: () =>
input.getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => input.appState.jlptLevelLookup(text),
getJlptEnabled: () =>
input.getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
input.getResolvedConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () => input.isCharacterDictionaryEnabled(),
getNameMatchEnabled: () => input.getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () =>
input.getRuntimeBooleanOption(
'subtitle.annotation.frequency',
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
),
getFrequencyDictionaryMatchMode: () =>
input.getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode,
getFrequencyRank: (text) => input.appState.frequencyRankLookup(text),
getYomitanGroupDebugEnabled: () => input.appState.overlayDebugVisualizationEnabled,
getMecabTokenizer: () => input.appState.mecabTokenizer,
onTokenizationReady: (text) => {
input.currentMediaTokenizationGate.markReady(getActiveMediaPath(input.appState));
input.startupOsdSequencer.markTokenizationReady();
input.youtube.maybeSignalPluginAutoplayReady(
{ text, tokens: null },
{ forceWhilePaused: true },
);
},
},
createTokenizerRuntimeDeps: (deps) =>
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => input.appState.mecabTokenizer,
setMecabTokenizer: (tokenizer) => {
input.appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
},
createMecabTokenizer: () => new MecabTokenizer(),
checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: () => input.dictionaries.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => input.dictionaries.ensureFrequencyDictionaryLookup(),
showMpvOsd: (message: string) => showMpvOsd(message),
showLoadingOsd: (message: string) =>
input.startupOsdSequencer.showAnnotationLoading(message),
showLoadedOsd: (message: string) =>
input.startupOsdSequencer.markAnnotationLoadingComplete(message),
shouldShowOsdNotification: () => {
const type = input.getResolvedConfig().ankiConnect.behavior.notificationType;
return type === 'osd' || type === 'both';
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => Date.now(),
logDebug: (message) => input.logger.debug(message),
logWarn: (message) => input.logger.warn(message),
},
startBackgroundWarmupsMainDeps: {
getStarted: () => backgroundWarmupsStarted,
setStarted: (started) => {
backgroundWarmupsStarted = started;
},
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
ensureYomitanExtensionLoaded: () => input.ensureYomitanExtensionLoaded().then(() => {}),
shouldWarmupMecab: () => {
const startupWarmups = input.getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
if (!startupWarmups.mecab) {
return false;
}
return (
input.getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
) ||
input.getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
input.getResolvedConfig().subtitleStyle.enableJlpt,
) ||
input.getRuntimeBooleanOption(
'subtitle.annotation.frequency',
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
)
);
},
shouldWarmupYomitanExtension: () =>
input.getResolvedConfig().startupWarmups.yomitanExtension,
shouldWarmupSubtitleDictionaries: () => {
const startupWarmups = input.getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.subtitleDictionaries;
},
shouldWarmupJellyfinRemoteSession: () => {
const startupWarmups = input.getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.jellyfinRemoteSession;
},
shouldAutoConnectJellyfinRemote: () => {
const jellyfin = input.getResolvedConfig().jellyfin;
return (
jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect
);
},
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
logDebug: (message) => input.logger.debug(message),
},
},
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
input.subtitle.setTokenizeSubtitleDeferred(tokenizeSubtitle);
const createMpvClientRuntimeService = (): MpvIpcClient => {
const client = createMpvClientRuntimeServiceHandler();
client.on('connection-change', ({ connected }) => {
input.youtube.handleMpvConnectionChange(connected);
});
return client;
};
const shiftSubtitleDelayToAdjacentCue = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => input.appState.mpvClient,
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
sendMpvCommand: (command) => sendMpvCommandRuntime(input.appState.mpvClient, command),
showMpvOsd: (text) => showMpvOsd(text),
});
return {
createMpvClientRuntimeService,
updateMpvSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetricsHandler(patch);
},
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startTokenizationWarmups,
isTokenizationWarmupReady,
startBackgroundWarmups,
showMpvOsd,
flushMpvLog,
cycleSecondarySubMode,
shiftSubtitleDelayToAdjacentCue,
};
}

View File

@@ -0,0 +1,63 @@
import type { WindowGeometry } from '../types';
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
export function createOverlayGeometryAccessors(deps: {
getOverlayGeometryRuntime: () => OverlayGeometryRuntime<any> | null;
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
screen: {
getCursorScreenPoint: () => { x: number; y: number };
getDisplayNearestPoint: (point: { x: number; y: number }) => {
workArea: { x: number; y: number; width: number; height: number };
};
};
}) {
const getOverlayGeometryFallback = (): WindowGeometry => {
const runtime = deps.getOverlayGeometryRuntime();
if (runtime) {
return runtime.getOverlayGeometryFallback();
}
const cursorPoint = deps.screen.getCursorScreenPoint();
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
const bounds = display.workArea;
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
};
};
const getCurrentOverlayGeometry = (): WindowGeometry => {
const runtime = deps.getOverlayGeometryRuntime();
if (runtime) {
return runtime.getCurrentOverlayGeometry();
}
const trackerGeometry = deps.getWindowTracker()?.getGeometry?.() ?? null;
if (trackerGeometry) {
return trackerGeometry;
}
return getOverlayGeometryFallback();
};
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
const runtime = deps.getOverlayGeometryRuntime();
if (runtime) {
return runtime.geometryMatches(a, b);
}
if (!a || !b) {
return false;
}
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
};
return {
getOverlayGeometryFallback,
getCurrentOverlayGeometry,
geometryMatches,
};
}

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayGeometryRuntime } from './overlay-geometry-runtime';
test('overlay geometry runtime prefers tracker geometry before fallback', () => {
const overlayBounds: unknown[] = [];
const modalBounds: unknown[] = [];
const layerCalls: Array<[unknown, unknown]> = [];
const levelCalls: unknown[] = [];
const runtime = createOverlayGeometryRuntime({
screen: {
getCursorScreenPoint: () => ({ x: 1, y: 2 }),
getDisplayNearestPoint: () => ({
workArea: { x: 10, y: 20, width: 30, height: 40 },
}),
},
windowState: {
getMainWindow: () =>
({
isDestroyed: () => false,
}) as never,
setOverlayWindowBounds: (geometry) => overlayBounds.push(geometry),
setModalWindowBounds: (geometry) => modalBounds.push(geometry),
getVisibleOverlayVisible: () => true,
},
getWindowTracker: () => ({
getGeometry: () => ({ x: 100, y: 200, width: 300, height: 400 }),
}),
ensureOverlayWindowLevelCore: (window) => {
levelCalls.push(window);
},
syncOverlayWindowLayer: (window, layer) => {
layerCalls.push([window, layer]);
},
enforceOverlayLayerOrderCore: ({
visibleOverlayVisible,
mainWindow,
ensureOverlayWindowLevel,
}) => {
if (visibleOverlayVisible && mainWindow) {
ensureOverlayWindowLevel(mainWindow);
}
},
});
assert.deepEqual(runtime.getCurrentOverlayGeometry(), {
x: 100,
y: 200,
width: 300,
height: 400,
});
assert.equal(
runtime.geometryMatches(
{ x: 1, y: 2, width: 3, height: 4 },
{ x: 1, y: 2, width: 3, height: 4 },
),
true,
);
assert.equal(runtime.geometryMatches({ x: 1, y: 2, width: 3, height: 4 }, null), false);
runtime.applyOverlayRegions({ x: 7, y: 8, width: 9, height: 10 });
assert.deepEqual(overlayBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
assert.deepEqual(modalBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
runtime.syncPrimaryOverlayWindowLayer('visible');
runtime.ensureOverlayWindowLevel({
isDestroyed: () => false,
} as never);
runtime.enforceOverlayLayerOrder();
assert.equal(layerCalls.length >= 1, true);
assert.equal(levelCalls.length >= 2, true);
});

View File

@@ -0,0 +1,135 @@
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './runtime/overlay-window-layout';
import {
createBuildEnforceOverlayLayerOrderMainDepsHandler,
createBuildEnsureOverlayWindowLevelMainDepsHandler,
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
} from './runtime/overlay-window-layout-main-deps';
import type { WindowGeometry } from '../types';
type BrowserWindowLike = {
isDestroyed: () => boolean;
};
type ScreenLike = {
getCursorScreenPoint: () => { x: number; y: number };
getDisplayNearestPoint: (point: { x: number; y: number }) => {
workArea: { x: number; y: number; width: number; height: number };
};
};
export interface OverlayGeometryWindowState<TWindow extends BrowserWindowLike = BrowserWindowLike> {
getMainWindow: () => TWindow | null;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
}
export interface OverlayGeometryInput<TWindow extends BrowserWindowLike = BrowserWindowLike> {
screen: ScreenLike;
windowState: OverlayGeometryWindowState<TWindow>;
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
ensureOverlayWindowLevelCore: (window: TWindow) => void;
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
enforceOverlayLayerOrderCore: (params: {
visibleOverlayVisible: boolean;
mainWindow: TWindow | null;
ensureOverlayWindowLevel: (window: TWindow) => void;
}) => void;
}
export interface OverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike> {
getLastOverlayWindowGeometry: () => WindowGeometry | null;
getOverlayGeometryFallback: () => WindowGeometry;
getCurrentOverlayGeometry: () => WindowGeometry;
geometryMatches: (a: WindowGeometry | null, b: WindowGeometry | null) => boolean;
applyOverlayRegions: (geometry: WindowGeometry) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: TWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
}
export function createOverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike>(
input: OverlayGeometryInput<TWindow>,
): OverlayGeometryRuntime<TWindow> {
let lastOverlayWindowGeometry: WindowGeometry | null = null;
const getOverlayGeometryFallback = (): WindowGeometry => {
const cursorPoint = input.screen.getCursorScreenPoint();
const display = input.screen.getDisplayNearestPoint(cursorPoint);
const bounds = display.workArea;
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
};
};
const getCurrentOverlayGeometry = (): WindowGeometry => {
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
const trackerGeometry = input.getWindowTracker()?.getGeometry?.() ?? null;
if (trackerGeometry) return trackerGeometry;
return getOverlayGeometryFallback();
};
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
};
const applyOverlayRegions = (geometry: WindowGeometry): void => {
lastOverlayWindowGeometry = geometry;
input.windowState.setOverlayWindowBounds(geometry);
input.windowState.setModalWindowBounds(geometry);
};
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
})(),
);
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
createBuildEnsureOverlayWindowLevelMainDepsHandler({
ensureOverlayWindowLevelCore: (window) =>
input.ensureOverlayWindowLevelCore(window as TWindow),
})(),
);
const syncPrimaryOverlayWindowLayer = (layer: 'visible'): void => {
const mainWindow = input.windowState.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
input.syncOverlayWindowLayer(mainWindow, layer);
};
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
createBuildEnforceOverlayLayerOrderMainDepsHandler({
enforceOverlayLayerOrderCore: (params) =>
input.enforceOverlayLayerOrderCore({
visibleOverlayVisible: params.visibleOverlayVisible,
mainWindow: params.mainWindow as TWindow | null,
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
}),
getVisibleOverlayVisible: () => input.windowState.getVisibleOverlayVisible(),
getMainWindow: () => input.windowState.getMainWindow(),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as TWindow),
})(),
);
return {
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
getOverlayGeometryFallback,
getCurrentOverlayGeometry,
geometryMatches,
applyOverlayRegions,
updateVisibleOverlayBounds,
ensureOverlayWindowLevel,
syncPrimaryOverlayWindowLayer,
enforceOverlayLayerOrder,
};
}

View File

@@ -0,0 +1,251 @@
import fs from 'node:fs';
import path from 'node:path';
import {
Menu,
MenuItem,
nativeImage,
Tray,
type BrowserWindow,
type MenuItemConstructorOptions,
} from 'electron';
import type { AnilistRuntime } from './anilist-runtime';
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
import type { FirstRunRuntime } from './first-run-runtime';
import type { JellyfinRuntime } from './jellyfin-runtime';
import type { MpvRuntime } from './mpv-runtime';
import type { ResolvedConfig } from '../types';
import {
broadcastRuntimeOptionsChangedRuntime,
createOverlayWindow as createOverlayWindowCore,
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
setOverlayDebugVisualizationEnabledRuntime,
syncOverlayWindowLayer,
} from '../core/services';
import {
buildTrayMenuTemplateRuntime,
resolveTrayIconPathRuntime,
} from './runtime/domains/overlay';
import {
createOverlayUiBootstrapRuntime,
type OverlayUiBootstrapInput,
type OverlayUiBootstrapRuntime,
} from './overlay-ui-bootstrap-runtime';
import type { OverlayModalRuntime } from './overlay-runtime';
import type { ShortcutsRuntimeBootstrap } from './shortcuts-runtime';
import { createWindowTracker as createWindowTrackerCore } from '../window-trackers';
export interface OverlayUiBootstrapFromMainStateInput<
TWindow extends BrowserWindow,
TMenuItem = MenuItemConstructorOptions | MenuItem,
> {
appState: OverlayUiBootstrapInput<TWindow>['appState'];
overlayManager: OverlayUiBootstrapInput<TWindow>['overlayManager'];
overlayModalInputState: OverlayUiBootstrapInput<TWindow>['overlayModalInputState'];
overlayModalRuntime: OverlayModalRuntime;
overlayShortcutsRuntime: ShortcutsRuntimeBootstrap['overlayShortcutsRuntime'];
runtimes: {
dictionarySupport: Pick<DictionarySupportRuntime, 'createFieldGroupingCallback'>;
firstRun: Pick<FirstRunRuntime, 'isSetupCompleted' | 'openFirstRunSetupWindow'>;
yomitan: {
openYomitanSettings: () => boolean;
};
jellyfin: Pick<JellyfinRuntime, 'openJellyfinSetupWindow'>;
anilist: Pick<AnilistRuntime, 'openAnilistSetupWindow'>;
shortcuts: Pick<ShortcutsRuntimeBootstrap['shortcuts'], 'registerGlobalShortcuts'>;
mpvRuntime: Pick<MpvRuntime, 'startBackgroundWarmups'>;
};
electron: OverlayUiBootstrapInput<TWindow>['electron'] & {
buildMenuFromTemplate: (template: TMenuItem[]) => unknown;
createTray: (
icon: ReturnType<OverlayUiBootstrapInput<TWindow>['electron']['createEmptyImage']>,
) => Tray;
};
windowing: OverlayUiBootstrapInput<TWindow>['windowing'];
actions: Omit<
OverlayUiBootstrapInput<TWindow>['actions'],
'registerGlobalShortcuts' | 'startBackgroundWarmups'
>;
trayState: OverlayUiBootstrapInput<TWindow>['trayState'];
startup: OverlayUiBootstrapInput<TWindow>['startup'];
}
export function createOverlayUiBootstrapFromMainState<TWindow extends BrowserWindow>(
input: OverlayUiBootstrapFromMainStateInput<TWindow>,
): OverlayUiBootstrapRuntime<TWindow> {
return createOverlayUiBootstrapRuntime<TWindow>({
appState: input.appState,
overlayManager: input.overlayManager,
overlayModalInputState: input.overlayModalInputState,
overlayModalRuntime: input.overlayModalRuntime,
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
dictionarySupport: {
createFieldGroupingCallback: () =>
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
},
firstRun: {
isSetupCompleted: () => input.runtimes.firstRun.isSetupCompleted(),
openFirstRunSetupWindow: () => input.runtimes.firstRun.openFirstRunSetupWindow(),
},
yomitan: {
openYomitanSettings: () => {
input.runtimes.yomitan.openYomitanSettings();
},
},
jellyfin: {
openJellyfinSetupWindow: () => input.runtimes.jellyfin.openJellyfinSetupWindow(),
},
anilist: {
openAnilistSetupWindow: () => input.runtimes.anilist.openAnilistSetupWindow(),
},
electron: input.electron,
windowing: input.windowing,
actions: {
...input.actions,
registerGlobalShortcuts: () => input.runtimes.shortcuts.registerGlobalShortcuts(),
startBackgroundWarmups: () => input.runtimes.mpvRuntime.startBackgroundWarmups(),
},
trayState: input.trayState,
startup: input.startup,
});
}
export interface OverlayUiBootstrapCoordinatorInput<TWindow extends BrowserWindow> {
appState: OverlayUiBootstrapFromMainStateInput<TWindow>['appState'];
overlayManager: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayManager'];
overlayModalInputState: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalInputState'];
overlayModalRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalRuntime'];
overlayShortcutsRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayShortcutsRuntime'];
runtimes: OverlayUiBootstrapFromMainStateInput<TWindow>['runtimes'];
env: {
screen: OverlayUiBootstrapFromMainStateInput<TWindow>['electron']['screen'];
appPath: string;
resourcesPath: string;
dirname: string;
platform: NodeJS.Platform;
};
windowing: OverlayUiBootstrapFromMainStateInput<TWindow>['windowing'];
actions: Omit<
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
| 'resolveTrayIconPathRuntime'
| 'buildTrayMenuTemplateRuntime'
| 'broadcastRuntimeOptionsChangedRuntime'
| 'setOverlayDebugVisualizationEnabledRuntime'
| 'initializeOverlayRuntimeCore'
> &
Pick<
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
| 'resolveTrayIconPathRuntime'
| 'buildTrayMenuTemplateRuntime'
| 'broadcastRuntimeOptionsChangedRuntime'
| 'setOverlayDebugVisualizationEnabledRuntime'
| 'initializeOverlayRuntimeCore'
>;
trayState: OverlayUiBootstrapFromMainStateInput<TWindow>['trayState'];
startup: OverlayUiBootstrapFromMainStateInput<TWindow>['startup'];
}
export function createOverlayUiBootstrapCoordinator<TWindow extends BrowserWindow>(
input: OverlayUiBootstrapCoordinatorInput<TWindow>,
): OverlayUiBootstrapRuntime<TWindow> {
return createOverlayUiBootstrapFromMainState<TWindow>({
appState: input.appState,
overlayManager: input.overlayManager,
overlayModalInputState: input.overlayModalInputState,
overlayModalRuntime: input.overlayModalRuntime,
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
runtimes: input.runtimes,
electron: {
screen: input.env.screen,
appPath: input.env.appPath,
resourcesPath: input.env.resourcesPath,
dirname: input.env.dirname,
platform: input.env.platform,
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
createEmptyImage: () => nativeImage.createEmpty(),
createTray: (icon) => new Tray(icon as ConstructorParameters<typeof Tray>[0]),
buildMenuFromTemplate: (template) =>
Menu.buildFromTemplate(template as (MenuItemConstructorOptions | MenuItem)[]),
},
windowing: input.windowing,
actions: input.actions,
trayState: input.trayState,
startup: input.startup,
});
}
export interface OverlayUiBootstrapFromProcessStateInput<TWindow extends BrowserWindow> {
appState: OverlayUiBootstrapCoordinatorInput<TWindow>['appState'];
overlayManager: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayManager'];
overlayModalInputState: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalInputState'];
overlayModalRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalRuntime'];
overlayShortcutsRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayShortcutsRuntime'];
runtimes: OverlayUiBootstrapCoordinatorInput<TWindow>['runtimes'];
env: OverlayUiBootstrapCoordinatorInput<TWindow>['env'] & {
isDev: boolean;
};
actions: {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
sendMpvCommand: (command: (string | number)[]) => void;
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
syncOverlayMpvSubtitleSuppression: () => void;
getResolvedConfig: () => ResolvedConfig;
requestAppQuit: () => void;
};
trayState: OverlayUiBootstrapCoordinatorInput<TWindow>['trayState'];
startup: OverlayUiBootstrapCoordinatorInput<TWindow>['startup'];
}
export function createOverlayUiBootstrapFromProcessState<TWindow extends BrowserWindow>(
input: OverlayUiBootstrapFromProcessStateInput<TWindow>,
): OverlayUiBootstrapRuntime<TWindow> {
return createOverlayUiBootstrapCoordinator({
appState: input.appState,
overlayManager: input.overlayManager,
overlayModalInputState: input.overlayModalInputState,
overlayModalRuntime: input.overlayModalRuntime,
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
runtimes: input.runtimes,
env: input.env,
windowing: {
isDev: input.env.isDev,
createOverlayWindowCore: (kind, options) =>
createOverlayWindowCore(kind, options as never) as TWindow,
ensureOverlayWindowLevelCore: (window) =>
ensureOverlayWindowLevelCore(window as BrowserWindow),
syncOverlayWindowLayer: (window, layer) =>
syncOverlayWindowLayer(window as BrowserWindow, layer),
enforceOverlayLayerOrderCore: (params) =>
enforceOverlayLayerOrderCore({
...params,
mainWindow: params.mainWindow as BrowserWindow | null,
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
}),
createWindowTrackerCore: (override, targetMpvSocketPath) =>
createWindowTrackerCore(override, targetMpvSocketPath),
},
actions: {
showMpvOsd: (message) => input.actions.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.actions.showDesktopNotification(title, options),
sendMpvCommand: (command) => input.actions.sendMpvCommand(command),
broadcastRuntimeOptionsChangedRuntime,
setOverlayDebugVisualizationEnabledRuntime,
resolveTrayIconPathRuntime,
buildTrayMenuTemplateRuntime,
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never),
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
getResolvedConfig: () => input.actions.getResolvedConfig(),
requestAppQuit: input.actions.requestAppQuit,
},
trayState: input.trayState,
startup: input.startup,
});
}

View File

@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
test('overlay ui bootstrap runtime input builder preserves grouped wiring', () => {
const input = createOverlayUiBootstrapRuntimeInput({
windows: {
state: {
getMainWindow: () => null,
setMainWindow: () => {},
getModalWindow: () => null,
setModalWindow: () => {},
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getOverlayDebugVisualizationEnabled: () => false,
setOverlayDebugVisualizationEnabled: () => {},
},
geometry: {
getCurrentOverlayGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
},
modal: {
setModalWindowBounds: () => {},
onModalStateChange: () => {},
},
modalRuntime: {
handleOverlayModalClosed: (_modal: OverlayHostedModal) => {},
notifyOverlayModalOpened: (_modal: OverlayHostedModal) => {},
waitForModalOpen: async () => true,
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
openRuntimeOptionsPalette: () => {},
sendToActiveOverlayWindow: () => false,
},
visibility: {
service: {
getModalActive: () => false,
getForceMousePassthrough: () => false,
getWindowTracker: () => null,
getTrackerNotReadyWarningShown: () => false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: () => false,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 5, y: 6, width: 7, height: 8 }),
},
overlayWindows: {
createOverlayWindowCore: () => ({ isDestroyed: () => false }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
getYomitanSession: () => null,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
},
actions: {
setVisibleOverlayVisibleCore: () => {},
},
},
},
overlayActions: {
getRuntimeOptionsManager: () => null,
getMpvClient: () => null,
broadcastRuntimeOptionsChangedRuntime: () => {},
broadcastToOverlayWindows: () => {},
setOverlayDebugVisualizationEnabledRuntime: () => {},
},
tray: null,
bootstrap: {
initializeOverlayRuntimeMainDeps: {
appState: {
backendOverride: null,
windowTracker: null,
subtitleTimingTracker: null,
mpvClient: null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null,
ankiIntegration: null,
},
overlayManager: {
getVisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({}) as never,
showDesktopNotification: () => {},
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
getKnownWordCacheStatePath: () => '/tmp/known.json',
shouldStartAnkiIntegration: () => false,
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
},
onInitialized: () => {},
},
runtimeState: {
isOverlayRuntimeInitialized: () => false,
setOverlayRuntimeInitialized: () => {},
},
mpvSubtitle: {
ensureOverlayMpvSubtitlesHidden: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
});
assert.equal(input.tray, null);
assert.equal(input.windows.windowState.getMainWindow(), null);
assert.equal(input.windows.geometry.getCurrentOverlayGeometry().width, 3);
assert.equal(input.windows.visibilityService.resolveFallbackBounds().height, 8);
assert.equal(
input.bootstrap.initializeOverlayRuntimeBootstrapDeps.isOverlayRuntimeInitialized(),
false,
);
});

View File

@@ -0,0 +1,87 @@
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type {
OverlayUiActionsInput,
OverlayUiBootstrapInput,
OverlayUiGeometryInput,
OverlayUiModalInput,
OverlayUiMpvSubtitleInput,
OverlayUiRuntimeStateInput,
OverlayUiTrayInput,
OverlayUiVisibilityActionsInput,
OverlayUiVisibilityServiceInput,
OverlayUiWindowState,
OverlayUiWindowsInput,
} from './overlay-ui-runtime';
import type { OverlayUiRuntimeGroupedInput } from './overlay-ui-runtime-input';
type WindowLike = {
isDestroyed: () => boolean;
};
export interface OverlayUiBootstrapRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
state: OverlayUiWindowState<TWindow>;
geometry: OverlayUiGeometryInput;
modal: OverlayUiModalInput;
modalRuntime: {
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
openRuntimeOptionsPalette: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
};
visibility: {
service: OverlayUiVisibilityServiceInput<TWindow>;
overlayWindows: OverlayUiWindowsInput<TWindow>;
actions: OverlayUiVisibilityActionsInput;
};
}
export interface OverlayUiBootstrapRuntimeInput<TWindow extends WindowLike = WindowLike> {
windows: OverlayUiBootstrapRuntimeWindowsInput<TWindow>;
overlayActions: OverlayUiActionsInput;
tray: OverlayUiTrayInput | null;
bootstrap: OverlayUiBootstrapInput;
runtimeState: OverlayUiRuntimeStateInput;
mpvSubtitle: OverlayUiMpvSubtitleInput;
}
export function createOverlayUiBootstrapRuntimeInput<TWindow extends WindowLike>(
input: OverlayUiBootstrapRuntimeInput<TWindow>,
): OverlayUiRuntimeGroupedInput<TWindow> {
return {
windows: {
windowState: input.windows.state,
geometry: input.windows.geometry,
modal: input.windows.modal,
modalRuntime: {
handleOverlayModalClosed: (modal) =>
input.windows.modalRuntime.handleOverlayModalClosed(modal),
notifyOverlayModalOpened: (modal) =>
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
waitForModalOpen: (modal, timeoutMs) =>
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
getRestoreVisibleOverlayOnModalClose: () =>
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
},
visibilityService: input.windows.visibility.service,
overlayWindows: input.windows.visibility.overlayWindows,
visibilityActions: input.windows.visibility.actions,
},
overlayActions: input.overlayActions,
tray: input.tray,
bootstrap: input.bootstrap,
runtimeState: input.runtimeState,
mpvSubtitle: input.mpvSubtitle,
};
}

View File

@@ -0,0 +1,503 @@
import type { BrowserWindow, Session } from 'electron';
import type {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
RuntimeOptionState,
WindowGeometry,
} from '../types';
import type { BaseWindowTracker } from '../window-trackers';
import {
createOverlayGeometryRuntime,
type OverlayGeometryRuntime,
} from './overlay-geometry-runtime';
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
import type { OverlayModalRuntime } from './overlay-runtime';
import { createOverlayUiRuntime, type OverlayUiRuntime } from './overlay-ui-runtime';
type WindowLike = {
isDestroyed: () => boolean;
};
type OverlayWindowKind = 'visible' | 'modal';
type ScreenLike = {
getCursorScreenPoint: () => { x: number; y: number };
getDisplayNearestPoint: (point: { x: number; y: number }) => {
workArea: { x: number; y: number; width: number; height: number };
};
};
type OverlayWindowTrackerLike = BaseWindowTracker | null;
type OverlayRuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[];
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
type OverlayMpvClientLike = {
connected: boolean;
restorePreviousSecondarySubVisibility: () => void;
send?: (payload: { command: string[] }) => void;
} | null;
type OverlayManagerLike<TWindow extends WindowLike> = {
getMainWindow: () => TWindow | null;
setMainWindow: (window: TWindow | null) => void;
getModalWindow: () => TWindow | null;
setModalWindow: (window: TWindow | null) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
setModalWindowBounds: (geometry: WindowGeometry) => void;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
getOverlayWindows: () => BrowserWindow[];
};
type OverlayModalInputStateLike = {
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (active: boolean) => void;
};
type OverlayShortcutsRuntimeLike = {
syncOverlayShortcuts: () => void;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
};
type DictionarySupportLike = {
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
};
type FirstRunLike = {
isSetupCompleted: () => boolean;
openFirstRunSetupWindow: () => void;
};
type YomitanLike = {
openYomitanSettings: () => void;
};
type JellyfinLike = {
openJellyfinSetupWindow: () => void;
};
type AnilistLike = {
openAnilistSetupWindow: () => void;
};
type BootstrapTrayIconLike = {
isEmpty: () => boolean;
resize: (options: {
width: number;
height: number;
quality?: 'best' | 'better' | 'good';
}) => BootstrapTrayIconLike;
setTemplateImage: (enabled: boolean) => void;
};
type BootstrapTrayLike = {
setContextMenu: (menu: any) => void;
setToolTip: (tooltip: string) => void;
on: (event: 'click', handler: () => void) => void;
destroy: () => void;
};
export interface OverlayUiBootstrapAppStateInput {
backendOverride: string | null;
windowTracker: OverlayWindowTrackerLike;
subtitleTimingTracker: unknown;
mpvClient: OverlayMpvClientLike;
mpvSocketPath: string;
runtimeOptionsManager: OverlayRuntimeOptionsManagerLike;
ankiIntegration: unknown;
overlayRuntimeInitialized: boolean;
overlayDebugVisualizationEnabled: boolean;
statsOverlayVisible: boolean;
trackerNotReadyWarningShown: boolean;
yomitanSession: Session | null;
}
export interface OverlayUiBootstrapElectronInput<
TWindow extends WindowLike,
TMenuItem = unknown,
TMenu = unknown,
> {
screen: ScreenLike;
appPath: string;
resourcesPath: string;
dirname: string;
platform: NodeJS.Platform;
joinPath: (...parts: string[]) => string;
fileExists: (candidate: string) => boolean;
createImageFromPath: (iconPath: string) => BootstrapTrayIconLike;
createEmptyImage: () => BootstrapTrayIconLike;
createTray: (icon: BootstrapTrayIconLike) => BootstrapTrayLike;
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
}
export interface OverlayUiBootstrapInput<TWindow extends WindowLike> {
appState: OverlayUiBootstrapAppStateInput;
overlayManager: OverlayManagerLike<TWindow>;
overlayModalInputState: OverlayModalInputStateLike;
overlayModalRuntime: OverlayModalRuntime;
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
dictionarySupport: DictionarySupportLike;
firstRun: FirstRunLike;
yomitan: YomitanLike;
jellyfin: JellyfinLike;
anilist: AnilistLike;
electron: OverlayUiBootstrapElectronInput<TWindow>;
windowing: {
isDev: boolean;
createOverlayWindowCore: (
kind: OverlayWindowKind,
options: {
isDev: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Electron.Session | null;
},
) => TWindow;
ensureOverlayWindowLevelCore: (window: TWindow) => void;
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
enforceOverlayLayerOrderCore: (params: {
visibleOverlayVisible: boolean;
mainWindow: TWindow | null;
ensureOverlayWindowLevel: (window: TWindow) => void;
}) => void;
createWindowTrackerCore: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
};
actions: {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
sendMpvCommand: (command: (string | number)[]) => void;
broadcastRuntimeOptionsChangedRuntime: (
getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
) => void;
setOverlayDebugVisualizationEnabledRuntime: (
currentEnabled: boolean,
nextEnabled: boolean,
setCurrentEnabled: (enabled: boolean) => void,
) => void;
resolveTrayIconPathRuntime: (options: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}) => string | null;
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
}) => unknown[];
initializeOverlayRuntimeCore: (options: unknown) => void;
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
syncOverlayMpvSubtitleSuppression: () => void;
registerGlobalShortcuts: () => void;
startBackgroundWarmups: () => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
requestAppQuit: () => void;
};
trayState: {
getTray: () => BootstrapTrayLike | null;
setTray: (tray: BootstrapTrayLike | null) => void;
trayTooltip: string;
logWarn: (message: string) => void;
};
startup: {
shouldSkipHeadlessOverlayBootstrap: () => boolean;
getKnownWordCacheStatePath: () => string;
onInitialized?: () => void;
};
}
export interface OverlayUiBootstrapRuntime<TWindow extends WindowLike> {
overlayGeometry: OverlayGeometryRuntime<TWindow>;
overlayUi: OverlayUiRuntime<TWindow>;
syncOverlayVisibilityForModal: () => void;
}
export function createOverlayUiBootstrapRuntime<TWindow extends WindowLike>(
input: OverlayUiBootstrapInput<TWindow>,
): OverlayUiBootstrapRuntime<TWindow> {
const overlayGeometry = createOverlayGeometryRuntime<TWindow>({
screen: input.electron.screen,
windowState: {
getMainWindow: () => input.overlayManager.getMainWindow(),
setOverlayWindowBounds: (geometry) => input.overlayManager.setOverlayWindowBounds(geometry),
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
},
getWindowTracker: () => input.appState.windowTracker,
ensureOverlayWindowLevelCore: (window) => input.windowing.ensureOverlayWindowLevelCore(window),
syncOverlayWindowLayer: (window, layer) =>
input.windowing.syncOverlayWindowLayer(window, layer),
enforceOverlayLayerOrderCore: (params) => input.windowing.enforceOverlayLayerOrderCore(params),
});
let overlayUi: OverlayUiRuntime<TWindow> | undefined;
overlayUi = createOverlayUiRuntime(
createOverlayUiBootstrapRuntimeInput<TWindow>({
windows: {
state: {
getMainWindow: () => input.overlayManager.getMainWindow(),
setMainWindow: (window) => input.overlayManager.setMainWindow(window),
getModalWindow: () => input.overlayManager.getModalWindow(),
setModalWindow: (window) => input.overlayManager.setModalWindow(window),
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) =>
input.overlayManager.setVisibleOverlayVisible(visible),
getOverlayDebugVisualizationEnabled: () =>
input.appState.overlayDebugVisualizationEnabled,
setOverlayDebugVisualizationEnabled: (enabled) => {
input.appState.overlayDebugVisualizationEnabled = enabled;
},
},
geometry: {
getCurrentOverlayGeometry: () => overlayGeometry.getCurrentOverlayGeometry(),
},
modal: {
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
onModalStateChange: (active) => {
input.overlayModalInputState.handleModalInputStateChange(active);
},
},
modalRuntime: input.overlayModalRuntime as never,
visibility: {
service: {
getModalActive: () => input.overlayModalInputState.getModalInputExclusive(),
getForceMousePassthrough: () => input.appState.statsOverlayVisible,
getWindowTracker: () => input.appState.windowTracker,
getTrackerNotReadyWarningShown: () => input.appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
input.appState.trackerNotReadyWarningShown = shown;
},
updateVisibleOverlayBounds: (geometry) =>
overlayGeometry.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
syncPrimaryOverlayWindowLayer: (layer) =>
overlayGeometry.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => overlayGeometry.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
isMacOSPlatform: () => input.electron.platform === 'darwin',
isWindowsPlatform: () => input.electron.platform === 'win32',
showOverlayLoadingOsd: (message) => input.actions.showMpvOsd(message),
resolveFallbackBounds: () => overlayGeometry.getOverlayGeometryFallback(),
},
overlayWindows: {
createOverlayWindowCore: (kind, options) =>
input.windowing.createOverlayWindowCore(kind, options),
isDev: input.windowing.isDev,
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => {
overlayUi?.broadcastRuntimeOptionsChanged();
},
setOverlayDebugVisualizationEnabled: (enabled) => {
overlayUi?.setOverlayDebugVisualizationEnabled(enabled);
},
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? input.overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => input.appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
input.overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(overlayInput),
forwardTabToMpv: () => input.actions.sendMpvCommand(['keypress', 'TAB']),
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
input.overlayManager.setMainWindow(null);
return;
}
input.overlayManager.setModalWindow(null);
},
},
actions: {
setVisibleOverlayVisibleCore: ({
visible,
setVisibleOverlayVisibleState,
updateVisibleOverlayVisibility,
}) => {
setVisibleOverlayVisibleState(visible);
updateVisibleOverlayVisibility();
},
},
},
},
overlayActions: {
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
getMpvClient: () => input.appState.mpvClient,
broadcastRuntimeOptionsChangedRuntime: (
getRuntimeOptionsState,
broadcastToOverlayWindows,
) =>
input.actions.broadcastRuntimeOptionsChangedRuntime(
getRuntimeOptionsState,
broadcastToOverlayWindows,
),
broadcastToOverlayWindows: (channel, ...args) =>
input.overlayManager.broadcastToOverlayWindows(channel, ...args),
setOverlayDebugVisualizationEnabledRuntime: (
currentEnabled,
nextEnabled,
setCurrentEnabled,
) =>
input.actions.setOverlayDebugVisualizationEnabledRuntime(
currentEnabled,
nextEnabled,
setCurrentEnabled,
),
},
tray: {
resolveTrayIconPathDeps: {
resolveTrayIconPathRuntime: input.actions.resolveTrayIconPathRuntime,
platform: input.electron.platform,
resourcesPath: input.electron.resourcesPath,
appPath: input.electron.appPath,
dirname: input.electron.dirname,
joinPath: (...parts) => input.electron.joinPath(...parts),
fileExists: (candidate) => input.electron.fileExists(candidate),
},
buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime: input.actions.buildTrayMenuTemplateRuntime,
initializeOverlayRuntime: () => {
overlayUi?.initializeOverlayRuntime();
},
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
setVisibleOverlayVisible: (visible) => {
overlayUi?.setVisibleOverlayVisible(visible);
},
showFirstRunSetup: () => !input.firstRun.isSetupCompleted(),
openFirstRunSetupWindow: () => input.firstRun.openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => input.electron.platform === 'win32',
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
openRuntimeOptionsPalette: () => {
overlayUi?.openRuntimeOptionsPalette();
},
openJellyfinSetupWindow: () => input.jellyfin.openJellyfinSetupWindow(),
openAnilistSetupWindow: () => input.anilist.openAnilistSetupWindow(),
quitApp: () => input.actions.requestAppQuit(),
},
ensureTrayDeps: {
getTray: () => input.trayState.getTray(),
setTray: (tray) => input.trayState.setTray(tray),
createImageFromPath: (iconPath) => input.electron.createImageFromPath(iconPath),
createEmptyImage: () => input.electron.createEmptyImage(),
createTray: (icon) => input.electron.createTray(icon),
trayTooltip: input.trayState.trayTooltip,
platform: input.electron.platform,
logWarn: (message) => input.trayState.logWarn(message),
initializeOverlayRuntime: () => {
overlayUi?.initializeOverlayRuntime();
},
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
setVisibleOverlayVisible: (visible) => {
overlayUi?.setVisibleOverlayVisible(visible);
},
},
destroyTrayDeps: {
getTray: () => input.trayState.getTray(),
setTray: (tray) => input.trayState.setTray(tray),
},
buildMenuFromTemplate: (template) => input.electron.buildMenuFromTemplate(template),
},
bootstrap: {
initializeOverlayRuntimeMainDeps: {
appState: input.appState,
overlayManager: {
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {
overlayUi?.updateVisibleOverlayVisibility();
},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
},
createMainWindow: () => {
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
return;
}
overlayUi?.createMainWindow();
},
registerGlobalShortcuts: () => {
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
return;
}
input.actions.registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) => {
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
return null;
}
return input.windowing.createWindowTrackerCore(
override as string | null | undefined,
targetMpvSocketPath as string | null | undefined,
);
},
updateVisibleOverlayBounds: (geometry) =>
overlayGeometry.updateVisibleOverlayBounds(geometry),
getOverlayWindows: () => input.overlayManager.getOverlayWindows(),
getResolvedConfig: () => input.actions.getResolvedConfig(),
showDesktopNotification: (title, options) =>
input.actions.showDesktopNotification(title, options),
createFieldGroupingCallback: () => input.dictionarySupport.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => input.startup.getKnownWordCacheStatePath(),
shouldStartAnkiIntegration: () => !input.startup.shouldSkipHeadlessOverlayBootstrap(),
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore: (options) =>
input.actions.initializeOverlayRuntimeCore(options),
setOverlayRuntimeInitialized: (initialized) => {
input.appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => {
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
return;
}
input.actions.startBackgroundWarmups();
},
},
onInitialized: input.startup.onInitialized,
},
runtimeState: {
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
setOverlayRuntimeInitialized: (initialized) => {
input.appState.overlayRuntimeInitialized = initialized;
},
},
mpvSubtitle: {
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
},
}),
);
return {
overlayGeometry,
overlayUi,
syncOverlayVisibilityForModal: () => {
overlayUi.updateVisibleOverlayVisibility();
},
};
}

View File

@@ -0,0 +1,92 @@
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type {
OverlayUiActionsInput,
OverlayUiBootstrapInput,
OverlayUiGeometryInput,
OverlayUiModalInput,
OverlayUiMpvSubtitleInput,
OverlayUiRuntimeInput,
OverlayUiRuntimeStateInput,
OverlayUiTrayInput,
OverlayUiVisibilityActionsInput,
OverlayUiVisibilityServiceInput,
OverlayUiWindowState,
OverlayUiWindowsInput,
} from './overlay-ui-runtime';
type WindowLike = {
isDestroyed: () => boolean;
};
export interface OverlayUiRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
windowState: OverlayUiWindowState<TWindow>;
geometry: OverlayUiGeometryInput;
modal: OverlayUiModalInput;
modalRuntime: {
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
openRuntimeOptionsPalette: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
};
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
overlayWindows: OverlayUiWindowsInput<TWindow>;
visibilityActions: OverlayUiVisibilityActionsInput;
}
export interface OverlayUiRuntimeGroupedInput<TWindow extends WindowLike = WindowLike> {
windows: OverlayUiRuntimeWindowsInput<TWindow>;
overlayActions: OverlayUiActionsInput;
tray: OverlayUiTrayInput | null;
bootstrap: OverlayUiBootstrapInput;
runtimeState: OverlayUiRuntimeStateInput;
mpvSubtitle: OverlayUiMpvSubtitleInput;
}
export type OverlayUiRuntimeInputLike<TWindow extends WindowLike = WindowLike> =
| OverlayUiRuntimeInput<TWindow>
| OverlayUiRuntimeGroupedInput<TWindow>;
export function normalizeOverlayUiRuntimeInput<TWindow extends WindowLike>(
input: OverlayUiRuntimeInputLike<TWindow>,
): OverlayUiRuntimeInput<TWindow> {
if (!('windows' in input)) {
return input;
}
return {
windowState: input.windows.windowState,
geometry: input.windows.geometry,
modal: input.windows.modal,
modalRuntime: {
handleOverlayModalClosed: (modal) =>
input.windows.modalRuntime.handleOverlayModalClosed(modal),
notifyOverlayModalOpened: (modal) =>
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
waitForModalOpen: (modal, timeoutMs) =>
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
getRestoreVisibleOverlayOnModalClose: () =>
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
},
visibilityService: input.windows.visibilityService,
overlayWindows: input.windows.overlayWindows,
visibilityActions: input.windows.visibilityActions,
overlayActions: input.overlayActions,
tray: input.tray,
bootstrap: input.bootstrap,
runtimeState: input.runtimeState,
mpvSubtitle: input.mpvSubtitle,
};
}

View File

@@ -0,0 +1,461 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { createOverlayUiRuntime } from './overlay-ui-runtime';
type MockWindow = {
destroyed: boolean;
isDestroyed: () => boolean;
};
function createWindow(): MockWindow {
return {
destroyed: false,
isDestroyed() {
return this.destroyed;
},
};
}
test('overlay ui runtime lazy-creates main window for toggle visibility actions', async () => {
const calls: string[] = [];
let mainWindow: MockWindow | null = null;
const createdWindow = createWindow();
let visibleOverlayVisible = false;
const overlayUi = createOverlayUiRuntime({
windows: {
windowState: {
getMainWindow: () => mainWindow,
setMainWindow: (window) => {
mainWindow = window;
},
getModalWindow: () => null,
setModalWindow: () => {},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
},
getOverlayDebugVisualizationEnabled: () => false,
setOverlayDebugVisualizationEnabled: () => {},
},
geometry: {
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
modal: {
onModalStateChange: () => {},
},
modalRuntime: {
handleOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
waitForModalOpen: async () => false,
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
openRuntimeOptionsPalette: () => {},
sendToActiveOverlayWindow: () => false,
},
visibilityService: {
getModalActive: () => false,
getForceMousePassthrough: () => false,
getWindowTracker: () => null,
getTrackerNotReadyWarningShown: () => false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: () => false,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
overlayWindows: {
createOverlayWindowCore: () => createdWindow,
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => visibleOverlayVisible,
getYomitanSession: () => null,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
},
visibilityActions: {
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
calls.push(`setVisible:${visible}`);
setVisibleOverlayVisibleState(visible);
},
},
},
overlayActions: {
getRuntimeOptionsManager: () => null,
getMpvClient: () => null,
broadcastRuntimeOptionsChangedRuntime: () => {},
broadcastToOverlayWindows: () => {},
setOverlayDebugVisualizationEnabledRuntime: () => {},
},
tray: null,
bootstrap: {
initializeOverlayRuntimeMainDeps: {
appState: {
backendOverride: null,
windowTracker: null,
subtitleTimingTracker: null,
mpvClient: null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null,
ankiIntegration: null,
},
overlayManager: {
getVisibleOverlayVisible: () => visibleOverlayVisible,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
createMainWindow: () => {
calls.push('bootstrapCreateMainWindow');
},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
showDesktopNotification: () => {},
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
getKnownWordCacheStatePath: () => '/tmp/known.json',
shouldStartAnkiIntegration: () => false,
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
},
onInitialized: () => {},
},
runtimeState: {
isOverlayRuntimeInitialized: () => true,
setOverlayRuntimeInitialized: () => {},
},
mpvSubtitle: {
ensureOverlayMpvSubtitlesHidden: async () => {
calls.push('hideMpvSubs');
},
syncOverlayMpvSubtitleSuppression: () => {
calls.push('syncMpvSubs');
},
},
});
overlayUi.toggleVisibleOverlay();
assert.equal(mainWindow, createdWindow);
assert.deepEqual(calls, ['hideMpvSubs', 'setVisible:true', 'syncMpvSubs']);
});
test('overlay ui runtime initializes overlay runtime before visible action when needed', async () => {
const calls: string[] = [];
let visibleOverlayVisible = false;
let overlayRuntimeInitialized = false;
const overlayUi = createOverlayUiRuntime({
windowState: {
getMainWindow: () => null,
setMainWindow: () => {},
getModalWindow: () => null,
setModalWindow: () => {},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;
},
getOverlayDebugVisualizationEnabled: () => false,
setOverlayDebugVisualizationEnabled: () => {},
},
geometry: {
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
modal: {
onModalStateChange: () => {},
},
modalRuntime: {
handleOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
waitForModalOpen: async () => false,
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
openRuntimeOptionsPalette: () => {},
sendToActiveOverlayWindow: () => false,
},
visibilityService: {
getModalActive: () => false,
getForceMousePassthrough: () => false,
getWindowTracker: () => null,
getTrackerNotReadyWarningShown: () => false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: () => false,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
overlayWindows: {
createOverlayWindowCore: () => createWindow(),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => visibleOverlayVisible,
getYomitanSession: () => null,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
},
visibilityActions: {
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
calls.push(`setVisible:${visible}`);
setVisibleOverlayVisibleState(visible);
},
},
overlayActions: {
getRuntimeOptionsManager: () => null,
getMpvClient: () => null,
broadcastRuntimeOptionsChangedRuntime: () => {},
broadcastToOverlayWindows: () => {},
setOverlayDebugVisualizationEnabledRuntime: () => {},
},
tray: null,
bootstrap: {
initializeOverlayRuntimeMainDeps: {
appState: {
backendOverride: null,
windowTracker: null,
subtitleTimingTracker: null,
mpvClient: null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null,
ankiIntegration: null,
},
overlayManager: {
getVisibleOverlayVisible: () => visibleOverlayVisible,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
showDesktopNotification: () => {},
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
getKnownWordCacheStatePath: () => '/tmp/known.json',
shouldStartAnkiIntegration: () => false,
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
initializeOverlayRuntimeCore: () => {
calls.push('initializeOverlayRuntimeCore');
},
setOverlayRuntimeInitialized: (initialized) => {
overlayRuntimeInitialized = initialized;
calls.push(`setInitialized:${initialized}`);
},
startBackgroundWarmups: () => {
calls.push('startBackgroundWarmups');
},
},
onInitialized: () => {
calls.push('onInitialized');
},
},
runtimeState: {
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
setOverlayRuntimeInitialized: (initialized) => {
overlayRuntimeInitialized = initialized;
},
},
mpvSubtitle: {
ensureOverlayMpvSubtitlesHidden: async () => {
calls.push('hideMpvSubs');
},
syncOverlayMpvSubtitleSuppression: () => {
calls.push('syncMpvSubs');
},
},
});
overlayUi.setVisibleOverlayVisible(true);
assert.deepEqual(calls, [
'setInitialized:true',
'initializeOverlayRuntimeCore',
'startBackgroundWarmups',
'onInitialized',
'syncMpvSubs',
'hideMpvSubs',
'setVisible:true',
'syncMpvSubs',
]);
});
test('overlay ui runtime delegates modal actions to injected modal runtime', async () => {
const calls: string[] = [];
const restoreOnClose = new Set<OverlayHostedModal>();
const overlayUi = createOverlayUiRuntime({
windowState: {
getMainWindow: () => null,
setMainWindow: () => {},
getModalWindow: () => null,
setModalWindow: () => {},
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getOverlayDebugVisualizationEnabled: () => false,
setOverlayDebugVisualizationEnabled: () => {},
},
geometry: {
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
modal: {
onModalStateChange: () => {},
},
visibilityService: {
getModalActive: () => false,
getForceMousePassthrough: () => false,
getWindowTracker: () => null,
getTrackerNotReadyWarningShown: () => false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {},
ensureOverlayWindowLevel: () => {},
syncPrimaryOverlayWindowLayer: () => {},
enforceOverlayLayerOrder: () => {},
syncOverlayShortcuts: () => {},
isMacOSPlatform: () => false,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
},
overlayWindows: {
createOverlayWindowCore: () => createWindow(),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
getYomitanSession: () => null,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
},
visibilityActions: {
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
setVisibleOverlayVisibleState(visible);
},
},
overlayActions: {
getRuntimeOptionsManager: () => null,
getMpvClient: () => null,
broadcastRuntimeOptionsChangedRuntime: () => {},
broadcastToOverlayWindows: () => {},
setOverlayDebugVisualizationEnabledRuntime: () => {},
},
modalRuntime: {
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(`send:${channel}:${String(payload)}`);
if (runtimeOptions?.restoreOnModalClose) {
restoreOnClose.add(runtimeOptions.restoreOnModalClose);
}
return true;
},
openRuntimeOptionsPalette: () => {
calls.push('openRuntimeOptionsPalette');
},
handleOverlayModalClosed: (modal) => {
calls.push(`closed:${modal}`);
},
notifyOverlayModalOpened: (modal) => {
calls.push(`opened:${modal}`);
},
waitForModalOpen: async (modal, timeoutMs) => {
calls.push(`wait:${modal}:${timeoutMs}`);
return true;
},
getRestoreVisibleOverlayOnModalClose: () => restoreOnClose,
},
tray: null,
bootstrap: {
initializeOverlayRuntimeMainDeps: {
appState: {
backendOverride: null,
windowTracker: null,
subtitleTimingTracker: null,
mpvClient: null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null,
ankiIntegration: null,
},
overlayManager: {
getVisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {},
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => {},
},
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
getOverlayWindows: () => [],
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
showDesktopNotification: () => {},
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
getKnownWordCacheStatePath: () => '/tmp/known.json',
shouldStartAnkiIntegration: () => false,
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
},
},
runtimeState: {
isOverlayRuntimeInitialized: () => true,
setOverlayRuntimeInitialized: () => {},
},
mpvSubtitle: {
ensureOverlayMpvSubtitlesHidden: async () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
});
assert.equal(
overlayUi.sendToActiveOverlayWindow('jimaku:open', 'payload', {
restoreOnModalClose: 'jimaku',
}),
true,
);
overlayUi.openRuntimeOptionsPalette();
overlayUi.notifyOverlayModalOpened('runtime-options');
overlayUi.handleOverlayModalClosed('runtime-options');
assert.equal(await overlayUi.waitForModalOpen('youtube-track-picker', 50), true);
assert.equal(overlayUi.getRestoreVisibleOverlayOnModalClose(), restoreOnClose);
assert.deepEqual(calls, [
'send:jimaku:open:payload',
'openRuntimeOptionsPalette',
'opened:runtime-options',
'closed:runtime-options',
'wait:youtube-track-picker:50',
]);
});

View File

@@ -0,0 +1,408 @@
import type { Session } from 'electron';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { RuntimeOptionState, WindowGeometry } from '../types';
import type { OverlayModalRuntime } from './overlay-runtime';
import {
normalizeOverlayUiRuntimeInput,
type OverlayUiRuntimeInputLike,
} from './overlay-ui-runtime-input';
import {
createOverlayVisibilityRuntimeBridge,
type OverlayUiVisibilityBridgeWindowLike,
} from './overlay-ui-visibility';
import { createOverlayVisibilityRuntime } from './runtime/overlay-visibility-runtime';
import { createOverlayWindowRuntimeHandlers } from './runtime/overlay-window-runtime-handlers';
import { createTrayRuntimeHandlers } from './runtime/tray-runtime-handlers';
import { createOverlayRuntimeBootstrapHandlers } from './runtime/overlay-runtime-bootstrap-handlers';
import { composeOverlayVisibilityRuntime } from './runtime/composers/overlay-visibility-runtime-composer';
import {
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
createBuildGetRuntimeOptionsStateMainDepsHandler,
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
createBuildSendToActiveOverlayWindowMainDepsHandler,
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
} from './runtime/overlay-runtime-main-actions-main-deps';
import { createGetRuntimeOptionsStateHandler } from './runtime/overlay-runtime-main-actions';
type OverlayWindowKind = 'visible' | 'modal';
type WindowLike = OverlayUiVisibilityBridgeWindowLike;
type RuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[];
};
type MpvClientLike = {
connected: boolean;
restorePreviousSecondarySubVisibility: () => void;
};
type TrayHandlersDeps = Parameters<typeof createTrayRuntimeHandlers>[0];
type BootstrapHandlersDeps = Parameters<typeof createOverlayRuntimeBootstrapHandlers>[0];
type OverlayWindowCreateOptions<TWindow extends WindowLike> = {
isDev: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
yomitanSession?: Electron.Session | null;
};
export interface OverlayUiWindowState<TWindow extends WindowLike = WindowLike> {
getMainWindow: () => TWindow | null;
setMainWindow: (window: TWindow | null) => void;
getModalWindow: () => TWindow | null;
setModalWindow: (window: TWindow | null) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getOverlayDebugVisualizationEnabled: () => boolean;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
}
export interface OverlayUiGeometryInput {
getCurrentOverlayGeometry: () => WindowGeometry;
}
export interface OverlayUiModalInput {
setModalWindowBounds?: (geometry: WindowGeometry) => void;
onModalStateChange?: (active: boolean) => void;
}
export interface OverlayUiVisibilityServiceInput<TWindow extends WindowLike = WindowLike> {
getModalActive: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => unknown;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: TWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform: () => boolean;
isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
}
export interface OverlayUiWindowsInput<TWindow extends WindowLike = WindowLike> {
createOverlayWindowCore: (
kind: OverlayWindowKind,
options: OverlayWindowCreateOptions<TWindow>,
) => TWindow;
isDev: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
getYomitanSession: () => Session | null;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
}
export interface OverlayUiVisibilityActionsInput {
setVisibleOverlayVisibleCore: (options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
updateVisibleOverlayVisibility: () => void;
}) => void;
}
export interface OverlayUiActionsInput {
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getMpvClient: () => MpvClientLike | null;
broadcastRuntimeOptionsChangedRuntime: (
getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
) => void;
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
setOverlayDebugVisualizationEnabledRuntime: (
currentEnabled: boolean,
nextEnabled: boolean,
setCurrentEnabled: (enabled: boolean) => void,
) => void;
}
export interface OverlayUiTrayInput {
resolveTrayIconPathDeps: TrayHandlersDeps['resolveTrayIconPathDeps'];
buildTrayMenuTemplateDeps: TrayHandlersDeps['buildTrayMenuTemplateDeps'];
ensureTrayDeps: TrayHandlersDeps['ensureTrayDeps'];
destroyTrayDeps: TrayHandlersDeps['destroyTrayDeps'];
buildMenuFromTemplate: TrayHandlersDeps['buildMenuFromTemplate'];
}
export interface OverlayUiBootstrapInput {
initializeOverlayRuntimeMainDeps: BootstrapHandlersDeps['initializeOverlayRuntimeMainDeps'];
initializeOverlayRuntimeBootstrapDeps: BootstrapHandlersDeps['initializeOverlayRuntimeBootstrapDeps'];
onInitialized?: () => void;
}
export interface OverlayUiRuntimeStateInput {
isOverlayRuntimeInitialized: () => boolean;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
}
export interface OverlayUiMpvSubtitleInput {
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
syncOverlayMpvSubtitleSuppression: () => void;
}
export interface OverlayUiRuntimeInput<TWindow extends WindowLike = WindowLike> {
windowState: OverlayUiWindowState<TWindow>;
geometry: OverlayUiGeometryInput;
modal: OverlayUiModalInput;
modalRuntime: OverlayModalRuntime;
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
overlayWindows: OverlayUiWindowsInput<TWindow>;
visibilityActions: OverlayUiVisibilityActionsInput;
overlayActions: OverlayUiActionsInput;
tray: OverlayUiTrayInput | null;
bootstrap: OverlayUiBootstrapInput;
runtimeState: OverlayUiRuntimeStateInput;
mpvSubtitle: OverlayUiMpvSubtitleInput;
}
export interface OverlayUiRuntime<TWindow extends WindowLike = WindowLike> {
createMainWindow: () => TWindow;
createModalWindow: () => TWindow;
ensureTray: () => void;
destroyTray: () => void;
initializeOverlayRuntime: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
toggleVisibleOverlay: () => void;
setOverlayVisible: (visible: boolean) => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
updateVisibleOverlayVisibility: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
openRuntimeOptionsPalette: () => void;
broadcastRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
restorePreviousSecondarySubVisibility: () => void;
}
export function createOverlayUiRuntime<TWindow extends WindowLike>(
input: OverlayUiRuntimeInputLike<TWindow>,
): OverlayUiRuntime<TWindow> {
const runtimeInput = normalizeOverlayUiRuntimeInput(input);
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeBridge({
getMainWindow: () => runtimeInput.windowState.getMainWindow(),
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
getModalActive: () => runtimeInput.visibilityService.getModalActive(),
getForceMousePassthrough: () => runtimeInput.visibilityService.getForceMousePassthrough(),
getWindowTracker: () => runtimeInput.visibilityService.getWindowTracker(),
getTrackerNotReadyWarningShown: () =>
runtimeInput.visibilityService.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown) =>
runtimeInput.visibilityService.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry) =>
runtimeInput.visibilityService.updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) =>
runtimeInput.visibilityService.ensureOverlayWindowLevel(window),
syncPrimaryOverlayWindowLayer: (layer) =>
runtimeInput.visibilityService.syncPrimaryOverlayWindowLayer(layer),
enforceOverlayLayerOrder: () => runtimeInput.visibilityService.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => runtimeInput.visibilityService.syncOverlayShortcuts(),
isMacOSPlatform: () => runtimeInput.visibilityService.isMacOSPlatform(),
isWindowsPlatform: () => runtimeInput.visibilityService.isWindowsPlatform(),
showOverlayLoadingOsd: (message) =>
runtimeInput.visibilityService.showOverlayLoadingOsd(message),
});
const overlayWindowHandlers = createOverlayWindowRuntimeHandlers<TWindow>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) =>
runtimeInput.overlayWindows.createOverlayWindowCore(kind, options),
isDev: runtimeInput.overlayWindows.isDev,
ensureOverlayWindowLevel: (window) =>
runtimeInput.overlayWindows.ensureOverlayWindowLevel(window),
onRuntimeOptionsChanged: () => runtimeInput.overlayWindows.onRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) =>
runtimeInput.overlayWindows.setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) => runtimeInput.overlayWindows.isOverlayVisible(windowKind),
getYomitanSession: () => runtimeInput.overlayWindows.getYomitanSession(),
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
runtimeInput.overlayWindows.tryHandleOverlayShortcutLocalFallback(overlayInput),
forwardTabToMpv: () => runtimeInput.overlayWindows.forwardTabToMpv(),
onWindowClosed: (windowKind) => runtimeInput.overlayWindows.onWindowClosed(windowKind),
},
setMainWindow: (window) => runtimeInput.windowState.setMainWindow(window),
setModalWindow: (window) => runtimeInput.windowState.setModalWindow(window),
});
const visibilityActions = createOverlayVisibilityRuntime({
setVisibleOverlayVisibleDeps: {
setVisibleOverlayVisibleCore: (options) =>
runtimeInput.visibilityActions.setVisibleOverlayVisibleCore(options),
setVisibleOverlayVisibleState: (visible) =>
runtimeInput.windowState.setVisibleOverlayVisible(visible),
updateVisibleOverlayVisibility: () =>
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
},
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
});
const getRuntimeOptionsState = createGetRuntimeOptionsStateHandler(
createBuildGetRuntimeOptionsStateMainDepsHandler({
getRuntimeOptionsManager: () => runtimeInput.overlayActions.getRuntimeOptionsManager(),
})(),
);
const overlayActions = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime,
restorePreviousSecondarySubVisibilityMainDeps:
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
getMpvClient: () => runtimeInput.overlayActions.getMpvClient(),
})(),
broadcastRuntimeOptionsChangedMainDeps:
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
broadcastRuntimeOptionsChangedRuntime: (getState, broadcast) =>
runtimeInput.overlayActions.broadcastRuntimeOptionsChangedRuntime(getState, broadcast),
getRuntimeOptionsState: () => getRuntimeOptionsState(),
broadcastToOverlayWindows: (channel, ...args) =>
runtimeInput.overlayActions.broadcastToOverlayWindows(channel, ...args),
})(),
sendToActiveOverlayWindowMainDeps: createBuildSendToActiveOverlayWindowMainDepsHandler({
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
runtimeInput.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(),
setOverlayDebugVisualizationEnabledMainDeps:
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({
setOverlayDebugVisualizationEnabledRuntime: (currentEnabled, nextEnabled, setCurrent) =>
runtimeInput.overlayActions.setOverlayDebugVisualizationEnabledRuntime(
currentEnabled,
nextEnabled,
setCurrent,
),
getCurrentEnabled: () => runtimeInput.windowState.getOverlayDebugVisualizationEnabled(),
setCurrentEnabled: (enabled) =>
runtimeInput.windowState.setOverlayDebugVisualizationEnabled(enabled),
})(),
openRuntimeOptionsPaletteMainDeps: createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
openRuntimeOptionsPaletteRuntime: () => runtimeInput.modalRuntime.openRuntimeOptionsPalette(),
})(),
});
const trayHandlers = runtimeInput.tray
? createTrayRuntimeHandlers({
resolveTrayIconPathDeps: runtimeInput.tray.resolveTrayIconPathDeps,
buildTrayMenuTemplateDeps: runtimeInput.tray.buildTrayMenuTemplateDeps,
ensureTrayDeps: runtimeInput.tray.ensureTrayDeps,
destroyTrayDeps: runtimeInput.tray.destroyTrayDeps,
buildMenuFromTemplate: (template) => runtimeInput.tray!.buildMenuFromTemplate(template),
})
: null;
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: runtimeInput.bootstrap.initializeOverlayRuntimeMainDeps,
initializeOverlayRuntimeBootstrapDeps:
runtimeInput.bootstrap.initializeOverlayRuntimeBootstrapDeps,
});
function createMainWindow(): TWindow {
return overlayWindowHandlers.createMainWindow();
}
function createModalWindow(): TWindow {
const existingWindow = runtimeInput.windowState.getModalWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
return existingWindow;
}
const window = overlayWindowHandlers.createModalWindow();
runtimeInput.modal.setModalWindowBounds?.(runtimeInput.geometry.getCurrentOverlayGeometry());
return window;
}
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
runtimeInput.bootstrap.onInitialized?.();
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
}
function ensureOverlayWindowsReadyForVisibilityActions(): void {
if (!runtimeInput.runtimeState.isOverlayRuntimeInitialized()) {
initializeOverlayRuntime();
return;
}
const mainWindow = runtimeInput.windowState.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow();
}
}
function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (visible) {
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
}
visibilityActions.setVisibleOverlayVisible(visible);
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
}
function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions();
if (!runtimeInput.windowState.getVisibleOverlayVisible()) {
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
}
visibilityActions.toggleVisibleOverlay();
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
}
function setOverlayVisible(visible: boolean): void {
if (visible) {
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
}
visibilityActions.setOverlayVisible(visible);
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
}
return {
createMainWindow,
createModalWindow,
ensureTray: () => {
trayHandlers?.ensureTray();
},
destroyTray: () => {
trayHandlers?.destroyTray();
},
initializeOverlayRuntime,
ensureOverlayWindowsReadyForVisibilityActions,
setVisibleOverlayVisible,
toggleVisibleOverlay,
setOverlayVisible,
handleOverlayModalClosed: (modal) => runtimeInput.modalRuntime.handleOverlayModalClosed(modal),
notifyOverlayModalOpened: (modal) => runtimeInput.modalRuntime.notifyOverlayModalOpened(modal),
waitForModalOpen: (modal, timeoutMs) =>
runtimeInput.modalRuntime.waitForModalOpen(modal, timeoutMs),
getRestoreVisibleOverlayOnModalClose: () =>
runtimeInput.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayActions.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
openRuntimeOptionsPalette: () => overlayActions.openRuntimeOptionsPalette(),
broadcastRuntimeOptionsChanged: () => overlayActions.broadcastRuntimeOptionsChanged(),
setOverlayDebugVisualizationEnabled: (enabled) =>
overlayActions.setOverlayDebugVisualizationEnabled(enabled),
restorePreviousSecondarySubVisibility: () =>
overlayActions.restorePreviousSecondarySubVisibility(),
};
}

View File

@@ -0,0 +1,128 @@
import type { WindowGeometry } from '../types';
export type OverlayUiVisibilityBridgeWindowLike = {
isDestroyed: () => boolean;
hide?: () => void;
show?: () => void;
focus?: () => void;
setIgnoreMouseEvents?: (ignore: boolean, options?: { forward?: boolean }) => void;
};
export interface OverlayUiVisibilityBridgeInput<
TWindow extends OverlayUiVisibilityBridgeWindowLike = OverlayUiVisibilityBridgeWindowLike,
> {
getMainWindow: () => TWindow | null;
getVisibleOverlayVisible: () => boolean;
getModalActive: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => unknown;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: TWindow) => void;
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
isMacOSPlatform: () => boolean;
isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
}
export function createOverlayVisibilityRuntimeBridge<
TWindow extends OverlayUiVisibilityBridgeWindowLike,
>(input: OverlayUiVisibilityBridgeInput<TWindow>) {
let lastOverlayLoadingOsdAtMs: number | null = null;
return {
updateVisibleOverlayVisibility(): void {
const mainWindow = input.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (input.getModalActive()) {
mainWindow.hide?.();
input.syncOverlayShortcuts();
return;
}
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = input.getForceMousePassthrough() === true;
if (input.isWindowsPlatform() || forceMousePassthrough) {
mainWindow.setIgnoreMouseEvents?.(true, { forward: true });
} else {
mainWindow.setIgnoreMouseEvents?.(false);
}
input.ensureOverlayWindowLevel(mainWindow);
mainWindow.show?.();
if (!input.isWindowsPlatform() && !input.isMacOSPlatform() && !forceMousePassthrough) {
mainWindow.focus?.();
}
};
const maybeShowOverlayLoadingOsd = (): void => {
if (!input.isMacOSPlatform()) {
return;
}
if (lastOverlayLoadingOsdAtMs !== null && Date.now() - lastOverlayLoadingOsdAtMs < 30_000) {
return;
}
input.showOverlayLoadingOsd('Overlay loading...');
lastOverlayLoadingOsdAtMs = Date.now();
};
if (!input.getVisibleOverlayVisible()) {
input.setTrackerNotReadyWarningShown(false);
lastOverlayLoadingOsdAtMs = null;
mainWindow.hide?.();
input.syncOverlayShortcuts();
return;
}
const windowTracker = input.getWindowTracker() as {
isTracking: () => boolean;
getGeometry: () => WindowGeometry | null;
} | null;
if (windowTracker && windowTracker.isTracking()) {
input.setTrackerNotReadyWarningShown(false);
const geometry = windowTracker.getGeometry();
if (geometry) {
input.updateVisibleOverlayBounds(geometry);
}
input.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
input.enforceOverlayLayerOrder();
input.syncOverlayShortcuts();
return;
}
if (!windowTracker) {
if (input.isMacOSPlatform() || input.isWindowsPlatform()) {
if (!input.getTrackerNotReadyWarningShown()) {
input.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
mainWindow.hide?.();
input.syncOverlayShortcuts();
return;
}
input.setTrackerNotReadyWarningShown(false);
input.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
input.enforceOverlayLayerOrder();
input.syncOverlayShortcuts();
return;
}
if (!input.getTrackerNotReadyWarningShown()) {
input.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
mainWindow.hide?.();
input.syncOverlayShortcuts();
},
};
}

View File

@@ -0,0 +1,41 @@
import type { ResolvedConfig } from '../types';
export function getRuntimeBooleanOption(
getOptionValue: (
id:
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency',
) => unknown,
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
fallback: boolean,
): boolean {
const value = getOptionValue(id);
return typeof value === 'boolean' ? value : fallback;
}
export function shouldInitializeMecabForAnnotations(input: {
getResolvedConfig: () => ResolvedConfig;
getRuntimeBooleanOption: (
id:
| 'subtitle.annotation.nPlusOne'
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency',
fallback: boolean,
) => boolean;
}): boolean {
const config = input.getResolvedConfig();
const nPlusOneEnabled = input.getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
);
const jlptEnabled = input.getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
);
const frequencyEnabled = input.getRuntimeBooleanOption(
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
);
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
}

View File

@@ -1,3 +1,7 @@
import { createDiscordPresenceService } from '../../core/services';
import type { ResolvedConfig } from '../../types';
import { createDiscordRpcClient } from './discord-rpc-client.js';
type DiscordPresenceServiceLike = {
publish: (snapshot: {
mediaTitle: string | null;
@@ -72,3 +76,59 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
publishDiscordPresence,
};
}
export function createDiscordPresenceRuntimeFromMainState(input: {
appId: string;
appState: {
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
mpvClient: MpvClientLike | null;
currentMediaTitle: string | null;
currentMediaPath: string | null;
currentSubText: string;
playbackPaused: boolean | null;
};
getResolvedConfig: () => ResolvedConfig;
getFallbackMediaDurationSec: () => number | null;
logger: {
debug: (message: string, meta?: unknown) => void;
};
}) {
const sessionStartedAtMs = Date.now();
let mediaDurationSec: number | null = null;
const discordPresenceRuntime = createDiscordPresenceRuntime({
getDiscordPresenceService: () => input.appState.discordPresenceService,
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
getMpvClient: () => input.appState.mpvClient,
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
getCurrentMediaPath: () => input.appState.currentMediaPath,
getCurrentSubtitleText: () => input.appState.currentSubText,
getPlaybackPaused: () => input.appState.playbackPaused,
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
getSessionStartedAtMs: () => sessionStartedAtMs,
getMediaDurationSec: () => mediaDurationSec,
setMediaDurationSec: (next) => {
mediaDurationSec = next;
},
});
const initializeDiscordPresenceService = async (): Promise<void> => {
if (input.getResolvedConfig().discordPresence.enabled !== true) {
input.appState.discordPresenceService = null;
return;
}
input.appState.discordPresenceService = createDiscordPresenceService({
config: input.getResolvedConfig().discordPresence,
createClient: () => createDiscordRpcClient(input.appId),
logDebug: (message, meta) => input.logger.debug(message, meta),
});
await input.appState.discordPresenceService.start();
discordPresenceRuntime.publishDiscordPresence();
};
return {
discordPresenceRuntime,
initializeDiscordPresenceService,
};
}

View File

@@ -98,10 +98,62 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
return;
}
if (savedVisibility !== null) {
deps.setMpvSubVisibility(savedVisibility);
}
deps.setSavedSubVisibility(null);
};
}
export function createOverlayMpvSubtitleSuppressionRuntime(deps: {
appState: {
mpvClient: MpvVisibilityClient | null;
overlaySavedMpvSubVisibility: boolean | null;
overlayMpvSubVisibilityRevision: number;
};
getVisibleOverlayVisible: () => boolean;
setMpvSubVisibility: (visible: boolean) => void;
logWarn: (message: string, error: unknown) => void;
}) {
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
getMpvClient: () => deps.appState.mpvClient,
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
deps.appState.overlaySavedMpvSubVisibility = visible;
},
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
deps.appState.overlayMpvSubVisibilityRevision = revision;
},
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
logWarn: (message, error) => deps.logWarn(message, error),
});
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
setSavedSubVisibility: (visible) => {
deps.appState.overlaySavedMpvSubVisibility = visible;
},
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
setRevision: (revision) => {
deps.appState.overlayMpvSubVisibilityRevision = revision;
},
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
shouldKeepSuppressedFromVisibleOverlayBinding: () => deps.getVisibleOverlayVisible(),
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
});
const syncOverlayMpvSubtitleSuppression = (): void => {
if (deps.getVisibleOverlayVisible()) {
void ensureOverlayMpvSubtitlesHidden();
return;
}
restoreOverlayMpvSubtitles();
};
return {
ensureOverlayMpvSubtitlesHidden,
restoreOverlayMpvSubtitles,
syncOverlayMpvSubtitleSuppression,
};
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createShortcutsRuntime } from './shortcuts-runtime';
test('shortcuts runtime bridges modal shortcut sync to unregister and sync', () => {
const calls: string[] = [];
const runtime = createShortcutsRuntime({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => ({}) as never,
defaultConfig: {} as never,
resolveConfiguredShortcuts: () => ({}) as never,
},
buildRegisterGlobalShortcutsMainDeps: () => ({
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {
calls.push('registerGlobalShortcutsCore');
},
toggleVisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
unregisterAllGlobalShortcuts: () => {
calls.push('unregisterAllGlobalShortcuts');
},
registerGlobalShortcuts: () => {
calls.push('registerGlobalShortcuts');
},
syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcuts');
},
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: {
register: () => true,
unregister: () => {},
},
showMpvOsd: () => {},
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: () => {},
onMineSentenceDigit: () => {},
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {
calls.push('registerOverlayShortcuts');
},
unregisterOverlayShortcuts: () => {
calls.push('unregisterOverlayShortcuts');
},
syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcutsRuntime');
},
refreshOverlayShortcuts: () => {
calls.push('refreshOverlayShortcuts');
},
},
},
});
assert.equal(typeof runtime.getConfiguredShortcuts, 'function');
assert.equal(typeof runtime.registerGlobalShortcuts, 'function');
assert.equal(typeof runtime.syncOverlayShortcutsForModal, 'function');
runtime.syncOverlayShortcutsForModal(true);
runtime.syncOverlayShortcutsForModal(false);
assert.deepEqual(calls, ['unregisterOverlayShortcuts', 'syncOverlayShortcutsRuntime']);
});

View File

@@ -0,0 +1,278 @@
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { ResolvedConfig } from '../types';
import type { ConfiguredShortcuts } from '../core/utils/shortcut-config';
import { DEFAULT_CONFIG } from '../config';
import { resolveConfiguredShortcuts } from '../core/utils';
import type { AppState } from './state';
import type { MiningRuntime } from './mining-runtime';
import type { OverlayModalRuntime } from './overlay-runtime';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './runtime/domains/shortcuts';
import {
composeShortcutRuntimes,
type ShortcutsRuntimeComposerOptions,
} from './runtime/composers/shortcuts-runtime-composer';
import { createOverlayShortcutsRuntimeService } from './overlay-shortcuts-runtime';
type GlobalShortcutsInput = ShortcutsRuntimeComposerOptions['globalShortcuts'];
type NumericShortcutRuntimeMainDepsInput =
ShortcutsRuntimeComposerOptions['numericShortcutRuntimeMainDeps'];
type NumericSessionsInput = ShortcutsRuntimeComposerOptions['numericSessions'];
type OverlayShortcutsRuntimeMainDepsInput =
ShortcutsRuntimeComposerOptions['overlayShortcutsRuntimeMainDeps'];
export interface ShortcutsRuntimeInput {
globalShortcuts: GlobalShortcutsInput;
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDepsInput;
numericSessions: NumericSessionsInput;
overlayShortcutsRuntimeMainDeps: OverlayShortcutsRuntimeMainDepsInput;
}
export interface ShortcutsRuntime {
getConfiguredShortcuts: () => ConfiguredShortcuts;
registerGlobalShortcuts: () => void;
refreshGlobalAndOverlayShortcuts: () => void;
cancelPendingMultiCopy: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
cancelPendingMineSentenceMultiple: () => void;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
registerOverlayShortcuts: () => void;
unregisterOverlayShortcuts: () => void;
syncOverlayShortcuts: () => void;
refreshOverlayShortcuts: () => void;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
}
export interface ShortcutsRuntimeBootstrapInput {
globalShortcuts: ShortcutsRuntimeInput['globalShortcuts'];
numericShortcutRuntimeMainDeps: ShortcutsRuntimeInput['numericShortcutRuntimeMainDeps'];
numericSessions: ShortcutsRuntimeInput['numericSessions'];
overlayShortcuts: {
getConfiguredShortcuts: () => ConfiguredShortcuts;
getShortcutsRegistered: () => boolean;
setShortcutsRegistered: (registered: boolean) => void;
isOverlayRuntimeInitialized: () => boolean;
isOverlayShortcutContextActive: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
markAudioCard: () => void | Promise<void>;
copySubtitle: () => void | Promise<void>;
toggleSecondarySubMode: () => void;
updateLastCardFromClipboard: () => void | Promise<void>;
triggerFieldGrouping: () => void | Promise<void>;
triggerSubsyncFromConfig: () => void | Promise<void>;
mineSentenceCard: () => void | Promise<void>;
};
}
export function createShortcutsRuntime(input: ShortcutsRuntimeInput): ShortcutsRuntime {
const shortcutsRuntime = composeShortcutRuntimes({
globalShortcuts: input.globalShortcuts,
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
numericSessions: input.numericSessions,
overlayShortcutsRuntimeMainDeps: input.overlayShortcutsRuntimeMainDeps,
});
return {
...shortcutsRuntime,
syncOverlayShortcutsForModal: (isActive: boolean) => {
if (isActive) {
shortcutsRuntime.unregisterOverlayShortcuts();
return;
}
shortcutsRuntime.syncOverlayShortcuts();
},
};
}
export interface ShortcutsRuntimeBootstrap {
shortcuts: ShortcutsRuntime;
overlayShortcutsRuntime: ReturnType<typeof createOverlayShortcutsRuntimeService>;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
}
export interface ShortcutsRuntimeFromMainStateInput {
appState: Pick<AppState, 'overlayRuntimeInitialized' | 'shortcutsRegistered' | 'windowTracker'>;
getResolvedConfig: () => ResolvedConfig;
globalShortcut: NumericShortcutRuntimeMainDepsInput['globalShortcut'] & {
unregisterAll: () => void;
};
registerGlobalShortcutsCore: typeof import('../core/services').registerGlobalShortcuts;
isDev: boolean;
overlay: {
getOverlayUi: () =>
| {
toggleVisibleOverlay: () => void;
openRuntimeOptionsPalette: () => void;
}
| null
| undefined;
overlayManager: {
getMainWindow: () => Electron.BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
};
overlayModalRuntime: Pick<OverlayModalRuntime, 'sendToActiveOverlayWindow'>;
};
actions: {
showMpvOsd: (text: string) => void;
openYomitanSettings: () => boolean;
triggerSubsyncFromConfig: () => Promise<void>;
handleCycleSecondarySubMode: () => void;
handleMultiCopyDigit: (count: number) => void;
};
mining: {
copyCurrentSubtitle: () => void;
handleMineSentenceDigit: (count: number) => void;
markLastCardAsAudioCard: () => Promise<void>;
mineSentenceCard: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
updateLastCardFromClipboard: () => Promise<void>;
};
}
export function createShortcutsRuntimeBootstrap(
input: ShortcutsRuntimeBootstrapInput,
): ShortcutsRuntimeBootstrap {
let shortcuts: ShortcutsRuntime;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => input.overlayShortcuts.getConfiguredShortcuts(),
getShortcutsRegistered: () => input.overlayShortcuts.getShortcutsRegistered(),
setShortcutsRegistered: (registered: boolean) => {
input.overlayShortcuts.setShortcutsRegistered(registered);
},
isOverlayRuntimeInitialized: () => input.overlayShortcuts.isOverlayRuntimeInitialized(),
isOverlayShortcutContextActive: () => input.overlayShortcuts.isOverlayShortcutContextActive(),
showMpvOsd: (text: string) => input.overlayShortcuts.showMpvOsd(text),
openRuntimeOptionsPalette: () => {
input.overlayShortcuts.openRuntimeOptionsPalette();
},
openJimaku: () => {
input.overlayShortcuts.openJimaku();
},
markAudioCard: () => Promise.resolve(input.overlayShortcuts.markAudioCard()),
copySubtitleMultiple: (timeoutMs: number) => {
shortcuts.startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => Promise.resolve(input.overlayShortcuts.copySubtitle()),
toggleSecondarySubMode: () => input.overlayShortcuts.toggleSecondarySubMode(),
updateLastCardFromClipboard: () =>
Promise.resolve(input.overlayShortcuts.updateLastCardFromClipboard()),
triggerFieldGrouping: () => Promise.resolve(input.overlayShortcuts.triggerFieldGrouping()),
triggerSubsyncFromConfig: () =>
Promise.resolve(input.overlayShortcuts.triggerSubsyncFromConfig()),
mineSentenceCard: () => Promise.resolve(input.overlayShortcuts.mineSentenceCard()),
mineSentenceMultiple: (timeoutMs: number) => {
shortcuts.startPendingMineSentenceMultiple(timeoutMs);
},
cancelPendingMultiCopy: () => {
shortcuts.cancelPendingMultiCopy();
},
cancelPendingMineSentenceMultiple: () => {
shortcuts.cancelPendingMineSentenceMultiple();
},
})(),
);
shortcuts = createShortcutsRuntime({
globalShortcuts: input.globalShortcuts,
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
numericSessions: input.numericSessions,
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime,
},
});
return {
shortcuts,
overlayShortcutsRuntime,
syncOverlayShortcutsForModal: (isActive: boolean) => {
shortcuts.syncOverlayShortcutsForModal(isActive);
},
};
}
export function createShortcutsRuntimeFromMainState(
input: ShortcutsRuntimeFromMainStateInput,
): ShortcutsRuntimeBootstrap {
let shortcuts: ShortcutsRuntime;
const bootstrap = createShortcutsRuntimeBootstrap({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => input.getResolvedConfig(),
defaultConfig: DEFAULT_CONFIG,
resolveConfiguredShortcuts,
},
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore: input.registerGlobalShortcutsCore,
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
openYomitanSettings: () => {
input.actions.openYomitanSettings();
},
isDev: input.isDev,
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({
unregisterAllGlobalShortcuts: () => input.globalShortcut.unregisterAll(),
registerGlobalShortcuts: () => registerGlobalShortcutsHandler(),
syncOverlayShortcuts: () => shortcuts.syncOverlayShortcuts(),
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: input.globalShortcut,
showMpvOsd: (text) => input.actions.showMpvOsd(text),
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: (count) => input.actions.handleMultiCopyDigit(count),
onMineSentenceDigit: (count) => input.mining.handleMineSentenceDigit(count),
},
overlayShortcuts: {
getConfiguredShortcuts: () => shortcuts.getConfiguredShortcuts(),
getShortcutsRegistered: () => input.appState.shortcutsRegistered,
setShortcutsRegistered: (registered: boolean) => {
input.appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
isOverlayShortcutContextActive: () => {
if (process.platform !== 'win32') {
return true;
}
if (!input.overlay.overlayManager.getVisibleOverlayVisible()) {
return false;
}
const windowTracker = input.appState.windowTracker;
if (!windowTracker || !windowTracker.isTracking()) {
return false;
}
return windowTracker.isTargetWindowFocused();
},
showMpvOsd: (text: string) => input.actions.showMpvOsd(text),
openRuntimeOptionsPalette: () => {
input.overlay.getOverlayUi()?.openRuntimeOptionsPalette();
},
openJimaku: () => {
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow('jimaku:open', undefined, {
restoreOnModalClose: 'jimaku' as OverlayHostedModal,
});
},
markAudioCard: () => input.mining.markLastCardAsAudioCard(),
copySubtitle: () => input.mining.copyCurrentSubtitle(),
toggleSecondarySubMode: () => input.actions.handleCycleSecondarySubMode(),
updateLastCardFromClipboard: () => input.mining.updateLastCardFromClipboard(),
triggerFieldGrouping: () => input.mining.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => input.actions.triggerSubsyncFromConfig(),
mineSentenceCard: () => input.mining.mineSentenceCard(),
},
});
shortcuts = bootstrap.shortcuts;
return bootstrap;
}

57
src/main/startup-flags.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { CliArgs } from '../cli/args';
import { isStandaloneTexthookerCommand, shouldRunSettingsOnlyStartup } from '../cli/args';
export function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith('--password-store')) {
continue;
}
if (arg === '--password-store') {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
return value;
}
return null;
}
const [prefix, value] = arg.split('=', 2);
if (prefix === '--password-store' && value && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
export function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') {
return 'gnome-libsecret';
}
return normalized;
}
export function getDefaultPasswordStore(): string {
return 'gnome-libsecret';
}
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
shouldUseMinimalStartup: boolean;
shouldSkipHeavyStartup: boolean;
} {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
),
};
}

View File

@@ -0,0 +1,41 @@
import {
composeStartupLifecycleHandlers,
type StartupLifecycleComposerOptions,
} from './runtime/composers';
export interface StartupLifecycleRuntimeInput {
protocolUrl: StartupLifecycleComposerOptions['registerProtocolUrlHandlersMainDeps'];
cleanup: StartupLifecycleComposerOptions['onWillQuitCleanupMainDeps'];
shouldRestoreWindowsOnActivate: StartupLifecycleComposerOptions['shouldRestoreWindowsOnActivateMainDeps'];
restoreWindowsOnActivate: StartupLifecycleComposerOptions['restoreWindowsOnActivateMainDeps'];
}
export interface StartupLifecycleRuntime {
registerProtocolUrlHandlers: () => void;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
restoreWindowsOnActivate: () => void;
}
export function createStartupLifecycleRuntime(
input: StartupLifecycleRuntimeInput,
): StartupLifecycleRuntime {
const {
registerProtocolUrlHandlers,
onWillQuitCleanup,
shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate,
} = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: input.protocolUrl,
onWillQuitCleanupMainDeps: input.cleanup,
shouldRestoreWindowsOnActivateMainDeps: input.shouldRestoreWindowsOnActivate,
restoreWindowsOnActivateMainDeps: input.restoreWindowsOnActivate,
});
return {
registerProtocolUrlHandlers,
onWillQuitCleanup,
shouldRestoreWindowsOnActivate,
restoreWindowsOnActivate,
};
}

View File

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createStartupSequenceRuntime } from './startup-sequence-runtime';
test('startup sequence delegates non-refresh headless command to initial args handler', async () => {
const calls: string[] = [];
const runtime = createStartupSequenceRuntime({
appState: {
initialArgs: { refreshKnownWords: false } as never,
runtimeOptionsManager: null,
},
userDataPath: '/tmp/subminer',
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
anilist: {
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
refreshRetryQueueState: () => {},
},
actions: {
initializeDiscordPresenceService: async () => {},
requestAppQuit: () => {},
},
logger: {
error: () => {},
},
runHeadlessKnownWordRefresh: async () => {
calls.push('refreshKnownWords');
},
});
await runtime.runHeadlessInitialCommand({
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
});
assert.deepEqual(calls, ['handleInitialArgs']);
});
test('startup sequence runs headless known-word refresh when requested', async () => {
const calls: string[] = [];
const runtimeOptionsManager = {
getEffectiveAnkiConnectConfig: (config: never) => config,
} as never;
const runtime = createStartupSequenceRuntime({
appState: {
initialArgs: { refreshKnownWords: true } as never,
runtimeOptionsManager,
},
userDataPath: '/tmp/subminer',
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
anilist: {
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
refreshRetryQueueState: () => {},
},
actions: {
initializeDiscordPresenceService: async () => {},
requestAppQuit: () => {
calls.push('requestAppQuit');
},
},
logger: {
error: () => {},
},
runHeadlessKnownWordRefresh: async (input) => {
calls.push(`refresh:${input.userDataPath}`);
assert.equal(input.runtimeOptionsManager, runtimeOptionsManager);
},
});
await runtime.runHeadlessInitialCommand({
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
});
assert.deepEqual(calls, ['refresh:/tmp/subminer']);
});
test('startup sequence runs deferred AniList and Discord init only for full startup', async () => {
const calls: string[] = [];
const runtime = createStartupSequenceRuntime({
appState: {
initialArgs: null,
runtimeOptionsManager: null,
},
userDataPath: '/tmp/subminer',
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
anilist: {
refreshAnilistClientSecretStateIfEnabled: async (options) => {
calls.push(`anilist:${options.force}:${options.allowSetupPrompt}`);
},
refreshRetryQueueState: () => {
calls.push('retryQueue');
},
},
actions: {
initializeDiscordPresenceService: async () => {
calls.push('discord');
},
requestAppQuit: () => {},
},
logger: {
error: () => {},
},
});
runtime.runPostStartupInitialization();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['anilist:true:false', 'retryQueue', 'discord']);
});
test('startup sequence skips deferred startup side effects in minimal mode', async () => {
const calls: string[] = [];
const runtime = createStartupSequenceRuntime({
appState: {
initialArgs: { background: true } as never,
runtimeOptionsManager: null,
},
userDataPath: '/tmp/subminer',
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
anilist: {
refreshAnilistClientSecretStateIfEnabled: async () => {
calls.push('anilist');
},
refreshRetryQueueState: () => {
calls.push('retryQueue');
},
},
actions: {
initializeDiscordPresenceService: async () => {
calls.push('discord');
},
requestAppQuit: () => {},
},
logger: {
error: () => {},
},
getStartupModeFlags: () => ({
shouldUseMinimalStartup: true,
shouldSkipHeavyStartup: false,
}),
isAnilistTrackingEnabled: () => true,
});
runtime.runPostStartupInitialization();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,96 @@
import type { CliArgs } from '../cli/args';
import type { ResolvedConfig } from '../types';
import { isAnilistTrackingEnabled } from './runtime/domains/anilist';
import { getStartupModeFlags } from './startup-flags';
import { runHeadlessKnownWordRefresh } from './headless-known-word-refresh';
export interface StartupSequenceRuntimeInput {
appState: {
initialArgs: CliArgs | null | undefined;
runtimeOptionsManager: Parameters<
typeof runHeadlessKnownWordRefresh
>[0]['runtimeOptionsManager'];
};
userDataPath: string;
getResolvedConfig: () => ResolvedConfig;
anilist: {
refreshAnilistClientSecretStateIfEnabled: (options: {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
refreshRetryQueueState: () => void;
};
actions: {
initializeDiscordPresenceService: () => Promise<void>;
requestAppQuit: () => void;
};
logger: {
error: (message: string, error?: unknown) => void;
};
runHeadlessKnownWordRefresh?: typeof runHeadlessKnownWordRefresh;
getStartupModeFlags?: typeof getStartupModeFlags;
isAnilistTrackingEnabled?: typeof isAnilistTrackingEnabled;
}
export interface StartupSequenceRuntime {
runHeadlessInitialCommand: (input: { handleInitialArgs: () => void }) => Promise<void>;
runPostStartupInitialization: () => void;
}
export function createStartupSequenceRuntime(
input: StartupSequenceRuntimeInput,
): StartupSequenceRuntime {
const runKnownWordRefresh = input.runHeadlessKnownWordRefresh ?? runHeadlessKnownWordRefresh;
const resolveStartupModeFlags = input.getStartupModeFlags ?? getStartupModeFlags;
const isTrackingEnabled = input.isAnilistTrackingEnabled ?? isAnilistTrackingEnabled;
const shouldSkipDeferredStartup = (): boolean => {
if (!input.appState.initialArgs) {
return false;
}
const startupModeFlags = resolveStartupModeFlags(input.appState.initialArgs);
return startupModeFlags.shouldUseMinimalStartup || startupModeFlags.shouldSkipHeavyStartup;
};
return {
runHeadlessInitialCommand: async ({ handleInitialArgs }): Promise<void> => {
if (!input.appState.initialArgs?.refreshKnownWords) {
handleInitialArgs();
return;
}
await runKnownWordRefresh({
resolvedConfig: input.getResolvedConfig(),
runtimeOptionsManager: input.appState.runtimeOptionsManager,
userDataPath: input.userDataPath,
logger: input.logger,
requestAppQuit: input.actions.requestAppQuit,
});
},
runPostStartupInitialization: (): void => {
if (shouldSkipDeferredStartup()) {
return;
}
if (isTrackingEnabled(input.getResolvedConfig())) {
void input.anilist
.refreshAnilistClientSecretStateIfEnabled({
force: true,
allowSetupPrompt: false,
})
.catch((error) => {
input.logger.error(
'Failed to refresh AniList client secret state during startup',
error,
);
});
input.anilist.refreshRetryQueueState();
}
void input.actions.initializeDiscordPresenceService().catch((error) => {
input.logger.error('Failed to initialize Discord presence service during startup', error);
});
},
};
}

View File

@@ -0,0 +1,171 @@
import fs from 'node:fs';
import path from 'node:path';
import type { MpvIpcClient } from '../core/services/mpv';
import type {
JimakuLanguagePreference,
ResolvedConfig,
SecondarySubMode,
SubsyncManualPayload,
} from '../types';
import type { ConfigService } from '../config';
import type { RuntimeOptionsManager } from '../runtime-options';
import type { AppState } from './state';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import { createStartupSupportRuntime, type StartupSupportRuntime } from './startup-support-runtime';
export interface StartupSupportCoordinatorInput {
platform: NodeJS.Platform;
defaultImmersionDbPath: string;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
jellyfinLangPref: string;
getResolvedConfig: () => ResolvedConfig;
appState: AppState;
configService: Pick<ConfigService, 'reloadConfigStrict'>;
actions: {
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
showMpvOsd: (text: string) => void;
openSubsyncManualPicker: (payload: SubsyncManualPayload) => void;
refreshGlobalAndOverlayShortcuts: () => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, details: string) => void;
};
logger: StartupSupportRuntime['configHotReloadRuntime'] extends never
? never
: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
watch: Parameters<typeof createStartupSupportRuntime>[0]['watch'];
timers: Parameters<typeof createStartupSupportRuntime>[0]['timers'];
}
export interface StartupSupportFromMainStateInput {
platform: NodeJS.Platform;
defaultImmersionDbPath: string;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
jellyfinLangPref: string;
getResolvedConfig: () => ResolvedConfig;
appState: AppState;
configService: Pick<ConfigService, 'reloadConfigStrict'>;
overlay: {
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => void;
};
shortcuts: {
refreshGlobalAndOverlayShortcuts: () => void;
};
notifications: {
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, details: string) => void;
};
logger: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
mpv: {
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
showMpvOsd: (text: string) => void;
};
}
export function createStartupSupportCoordinator(
input: StartupSupportCoordinatorInput,
): StartupSupportRuntime {
return createStartupSupportRuntime({
platform: input.platform,
defaultImmersionDbPath: input.defaultImmersionDbPath,
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
jellyfinLangPref: input.jellyfinLangPref,
getResolvedConfig: () => input.getResolvedConfig(),
appState: {
immersionTracker: input.appState.immersionTracker,
mpvClient: input.appState.mpvClient,
currentMediaPath: input.appState.currentMediaPath,
currentMediaTitle: input.appState.currentMediaTitle,
runtimeOptionsManager: input.appState.runtimeOptionsManager as RuntimeOptionsManager | null,
subsyncInProgress: input.appState.subsyncInProgress,
keybindings: input.appState.keybindings,
ankiIntegration: input.appState.ankiIntegration,
},
mpv: {
sendMpvCommandRuntime: (client, command) =>
input.actions.sendMpvCommandRuntime(client as MpvIpcClient, command),
showMpvOsd: (text) => input.actions.showMpvOsd(text),
},
config: {
reloadConfigStrict: () => input.configService.reloadConfigStrict(),
},
subsync: {
openManualPicker: (payload) => input.actions.openSubsyncManualPicker(payload),
},
hotReload: {
setSecondarySubMode: (mode: SecondarySubMode) => {
input.appState.secondarySubMode = mode;
},
refreshGlobalAndOverlayShortcuts: () => input.actions.refreshGlobalAndOverlayShortcuts(),
broadcastToOverlayWindows: (channel, payload) =>
input.actions.broadcastToOverlayWindows(channel, payload),
},
notifications: {
showDesktopNotification: (title, options) =>
input.actions.showDesktopNotification(title, options),
showErrorBox: (title, details) => input.actions.showErrorBox(title, details),
},
logger: input.logger,
watch: input.watch,
timers: input.timers,
});
}
export function createStartupSupportFromMainState(
input: StartupSupportFromMainStateInput,
): StartupSupportRuntime {
return createStartupSupportCoordinator({
platform: input.platform,
defaultImmersionDbPath: input.defaultImmersionDbPath,
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
jellyfinLangPref: input.jellyfinLangPref,
getResolvedConfig: () => input.getResolvedConfig(),
appState: input.appState,
configService: input.configService,
actions: {
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
openSubsyncManualPicker: (payload) => {
input.overlay.sendToActiveOverlayWindow('subsync:open-manual', payload, {
restoreOnModalClose: 'subsync',
});
},
refreshGlobalAndOverlayShortcuts: () => {
input.shortcuts.refreshGlobalAndOverlayShortcuts();
},
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.broadcastToOverlayWindows(channel, payload);
},
showDesktopNotification: (title, options) =>
input.notifications.showDesktopNotification(title, options),
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
},
logger: input.logger,
watch: {
fileExists: (targetPath) => fs.existsSync(targetPath),
dirname: (targetPath) => path.dirname(targetPath),
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
},
timers: {
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
},
});
}

View File

@@ -0,0 +1,235 @@
import { createConfigHotReloadRuntime, type MpvIpcClient } from '../core/services';
import type { MpvRuntimeClientLike } from '../core/services/mpv';
import type {
ConfigHotReloadPayload,
ConfigValidationWarning,
JimakuLanguagePreference,
ResolvedConfig,
SecondarySubMode,
SubsyncManualPayload,
} from '../types';
import type { ReloadConfigStrictResult } from '../config';
import { RuntimeOptionsManager } from '../runtime-options';
import {
createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
createBuildGetDefaultSocketPathMainDepsHandler,
createGetDefaultSocketPathHandler,
} from './runtime/domains/jellyfin';
import {
createBuildConfigHotReloadAppliedMainDepsHandler,
createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadRuntimeMainDepsHandler,
createBuildWatchConfigPathMainDepsHandler,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
createWatchConfigPathHandler,
buildRestartRequiredConfigMessage,
} from './runtime/domains/overlay';
import {
createBuildConfigDerivedRuntimeMainDepsHandler,
createBuildImmersionMediaRuntimeMainDepsHandler,
createBuildMainSubsyncRuntimeMainDepsHandler,
createConfigDerivedRuntime,
createImmersionMediaRuntime,
createMainSubsyncRuntime,
} from './runtime/domains/startup';
import {
buildConfigWarningDialogDetails,
buildConfigWarningNotificationBody,
} from './config-validation';
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
};
type MpvClientLike = MpvIpcClient | null;
type JellyfinMpvClientLike = MpvRuntimeClientLike;
export interface StartupSupportRuntimeInput {
platform: NodeJS.Platform;
defaultImmersionDbPath: string;
defaultJimakuLanguagePreference: JimakuLanguagePreference;
defaultJimakuMaxEntryResults: number;
defaultJimakuApiBaseUrl: string;
jellyfinLangPref: string;
getResolvedConfig: () => ResolvedConfig;
appState: {
immersionTracker: ImmersionTrackerLike | null;
mpvClient: MpvClientLike;
currentMediaPath: string | null;
currentMediaTitle: string | null;
runtimeOptionsManager: RuntimeOptionsManager | null;
subsyncInProgress: boolean;
keybindings: ConfigHotReloadPayload['keybindings'];
ankiIntegration: {
applyRuntimeConfigPatch: (patch: {
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
}) => void;
} | null;
};
mpv: {
sendMpvCommandRuntime: (client: JellyfinMpvClientLike, command: (string | number)[]) => void;
showMpvOsd: (text: string) => void;
};
config: {
reloadConfigStrict: () => ReloadConfigStrictResult;
};
subsync: {
openManualPicker: (payload: SubsyncManualPayload) => void;
};
hotReload: {
setSecondarySubMode: (mode: SecondarySubMode) => void;
refreshGlobalAndOverlayShortcuts: () => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
};
notifications: {
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, details: string) => void;
};
logger: {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string, error?: unknown) => void;
};
watch: {
fileExists: (targetPath: string) => boolean;
dirname: (targetPath: string) => string;
watchPath: (
targetPath: string,
listener: (eventType: string, filename: string | null) => void,
) => { close: () => void };
};
timers: {
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
};
}
export interface StartupSupportRuntime {
applyJellyfinMpvDefaults: (client: JellyfinMpvClientLike) => void;
getDefaultSocketPath: () => string;
immersionMediaRuntime: ReturnType<typeof createImmersionMediaRuntime>;
configDerivedRuntime: ReturnType<typeof createConfigDerivedRuntime>;
subsyncRuntime: ReturnType<typeof createMainSubsyncRuntime>;
configHotReloadRuntime: ReturnType<typeof createConfigHotReloadRuntime>;
}
export function createStartupSupportRuntime(
input: StartupSupportRuntimeInput,
): StartupSupportRuntime {
const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler(
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
jellyfinLangPref: input.jellyfinLangPref,
})(),
);
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(
createBuildGetDefaultSocketPathMainDepsHandler({
platform: input.platform,
})(),
);
const immersionMediaRuntime = createImmersionMediaRuntime(
createBuildImmersionMediaRuntimeMainDepsHandler({
getResolvedConfig: () => input.getResolvedConfig(),
defaultImmersionDbPath: input.defaultImmersionDbPath,
getTracker: () => input.appState.immersionTracker,
getMpvClient: () => input.appState.mpvClient,
getCurrentMediaPath: () => input.appState.currentMediaPath,
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
logDebug: (message) => input.logger.debug(message),
logInfo: (message) => input.logger.info(message),
})(),
);
const configDerivedRuntime = createConfigDerivedRuntime(
createBuildConfigDerivedRuntimeMainDepsHandler({
getResolvedConfig: () => input.getResolvedConfig(),
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
})(),
);
const subsyncRuntime = createMainSubsyncRuntime(
createBuildMainSubsyncRuntimeMainDepsHandler({
getMpvClient: () => input.appState.mpvClient,
getResolvedConfig: () => input.getResolvedConfig(),
getSubsyncInProgress: () => input.appState.subsyncInProgress,
setSubsyncInProgress: (inProgress) => {
input.appState.subsyncInProgress = inProgress;
},
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
openManualPicker: (payload) => input.subsync.openManualPicker(payload),
})(),
);
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler(
createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.notifications.showDesktopNotification(title, options),
})(),
);
const watchConfigPathHandler = createWatchConfigPathHandler(
createBuildWatchConfigPathMainDepsHandler({
fileExists: (targetPath) => input.watch.fileExists(targetPath),
dirname: (targetPath) => input.watch.dirname(targetPath),
watchPath: (targetPath, listener) => input.watch.watchPath(targetPath, listener),
})(),
);
const configHotReloadRuntime = createConfigHotReloadRuntime(
createBuildConfigHotReloadRuntimeMainDepsHandler({
getCurrentConfig: () => input.getResolvedConfig(),
reloadConfigStrict: () => input.config.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
setTimeout: (callback, delayMs) => input.timers.setTimeout(callback, delayMs),
clearTimeout: (timeout) => input.timers.clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: createConfigHotReloadAppliedHandler(
createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: (keybindings) => {
input.appState.keybindings = keybindings;
},
refreshGlobalAndOverlayShortcuts: () => {
input.hotReload.refreshGlobalAndOverlayShortcuts();
},
setSecondarySubMode: (mode) => input.hotReload.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel, payload) =>
input.hotReload.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch) => {
input.appState.ankiIntegration?.applyRuntimeConfigPatch(patch);
},
})(),
),
onRestartRequired: (fields) =>
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
onInvalidConfig: notifyConfigHotReloadMessage,
onValidationWarnings: (configPath, warnings: ConfigValidationWarning[]) => {
input.notifications.showDesktopNotification('SubMiner', {
body: buildConfigWarningNotificationBody(configPath, warnings),
});
if (input.platform === 'darwin') {
input.notifications.showErrorBox(
'SubMiner config validation warning',
buildConfigWarningDialogDetails(configPath, warnings),
);
}
},
})(),
);
return {
applyJellyfinMpvDefaults: (client) => applyJellyfinMpvDefaultsHandler(client),
getDefaultSocketPath: () => getDefaultSocketPathHandler(),
immersionMediaRuntime,
configDerivedRuntime,
subsyncRuntime,
configHotReloadRuntime,
};
}

View File

@@ -0,0 +1,185 @@
import type { BrowserWindow } from 'electron';
import { shell } from 'electron';
import path from 'node:path';
import type { CliArgs, CliCommandSource } from '../cli/args';
import type { ResolvedConfig } from '../types';
import {
addYomitanNoteViaSearch,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
} from '../core/services';
import { createLogger } from '../logger';
import type { AppState } from './state';
import {
createStatsRuntimeBootstrap,
type StatsRuntime,
type StatsRuntimeBootstrap,
} from './stats-runtime';
import { registerStatsOverlayToggle, destroyStatsWindow } from '../core/services/stats-window.js';
export interface StatsRuntimeCoordinatorInput {
statsDaemonStatePath: string;
statsDistPath: string;
statsPreloadPath: string;
userDataPath: string;
appState: AppState;
getResolvedConfig: () => ResolvedConfig;
dictionarySupport: {
getConfiguredDbPath: () => string;
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
};
overlay: {
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
updateVisibleOverlayVisibility: () => void;
registerStatsOverlayToggle: StatsRuntimeBootstrap['stats'] extends never
? never
: Parameters<typeof createStatsRuntimeBootstrap>[0]['overlay']['registerStatsOverlayToggle'];
};
mpvRuntime: {
createMecabTokenizerAndCheck: () => Promise<void>;
};
actions: {
openExternal: (url: string) => Promise<unknown>;
requestAppQuit: () => void;
destroyStatsWindow: () => void;
};
logger: {
info: (message: string) => void;
warn: (message: string, error?: unknown) => void;
error: (message: string, error?: unknown) => void;
debug: (message: string, details?: unknown) => void;
};
}
export interface StatsRuntimeCoordinator {
statsBootstrap: StatsRuntimeBootstrap;
stats: StatsRuntime;
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
ensureImmersionTrackerStarted: () => void;
runStatsCliCommand: (
args: Pick<
CliArgs,
| 'statsResponsePath'
| 'statsBackground'
| 'statsStop'
| 'statsCleanup'
| 'statsCleanupVocab'
| 'statsCleanupLifetime'
>,
source: CliCommandSource,
) => Promise<void>;
}
export function createStatsRuntimeCoordinator(
input: StatsRuntimeCoordinatorInput,
): StatsRuntimeCoordinator {
const statsBootstrap = createStatsRuntimeBootstrap({
statsDaemonStatePath: input.statsDaemonStatePath,
statsDistPath: input.statsDistPath,
statsPreloadPath: input.statsPreloadPath,
userDataPath: input.userDataPath,
appState: input.appState,
getResolvedConfig: () => input.getResolvedConfig(),
dictionarySupport: input.dictionarySupport,
overlay: input.overlay,
createMecabTokenizerAndCheck: async () => {
await input.mpvRuntime.createMecabTokenizerAndCheck();
},
addYomitanNote: async (word: string) => {
const yomitanDeps = {
getYomitanExt: () => input.appState.yomitanExt,
getYomitanSession: () => input.appState.yomitanSession,
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
input.appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
input.appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
input.appState.yomitanParserInitPromise = promise;
},
};
const yomitanLogger = createLogger('main:yomitan-stats');
const ankiUrl = input.getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: true,
});
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
},
openExternal: input.actions.openExternal,
requestAppQuit: input.actions.requestAppQuit,
destroyStatsWindow: input.actions.destroyStatsWindow,
logger: input.logger,
});
const stats = statsBootstrap.stats;
return {
statsBootstrap,
stats,
ensureStatsServerStarted: () => stats.ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => stats.ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: async () => await stats.stopBackgroundStatsServer(),
ensureImmersionTrackerStarted: () => {
stats.ensureImmersionTrackerStarted();
},
runStatsCliCommand: async (args, source) => {
await stats.runStatsCliCommand(args, source);
},
};
}
export interface StatsRuntimeFromMainStateInput {
dirname: string;
userDataPath: string;
appState: AppState;
getResolvedConfig: () => ResolvedConfig;
dictionarySupport: StatsRuntimeCoordinatorInput['dictionarySupport'];
overlay: {
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
updateVisibleOverlayVisibility: () => void;
};
mpvRuntime: {
createMecabTokenizerAndCheck: () => Promise<void>;
};
actions: {
requestAppQuit: () => void;
};
logger: StatsRuntimeCoordinatorInput['logger'];
}
export function createStatsRuntimeFromMainState(
input: StatsRuntimeFromMainStateInput,
): StatsRuntimeCoordinator {
return createStatsRuntimeCoordinator({
statsDaemonStatePath: path.join(input.userDataPath, 'stats-daemon.json'),
statsDistPath: path.join(input.dirname, '..', 'stats', 'dist'),
statsPreloadPath: path.join(input.dirname, 'preload-stats.js'),
userDataPath: input.userDataPath,
appState: input.appState,
getResolvedConfig: () => input.getResolvedConfig(),
dictionarySupport: input.dictionarySupport,
overlay: {
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
updateVisibleOverlayVisibility: () => input.overlay.updateVisibleOverlayVisibility(),
registerStatsOverlayToggle,
},
mpvRuntime: {
createMecabTokenizerAndCheck: () => input.mpvRuntime.createMecabTokenizerAndCheck(),
},
actions: {
openExternal: (url) => shell.openExternal(url),
requestAppQuit: () => input.actions.requestAppQuit(),
destroyStatsWindow,
},
logger: input.logger,
});
}

View File

@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createStatsRuntime } from './stats-runtime';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
test('stats runtime removes stale daemon state', async () => {
await withTempDir(async (dir) => {
const statePath = path.join(dir, 'stats-daemon.json');
fs.writeFileSync(
statePath,
JSON.stringify({ pid: 99999, port: 6969, startedAtMs: 1_234 }, null, 2),
);
const runtime = createStatsRuntime({
statsDaemonStatePath: statePath,
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6969 },
}),
getImmersionTracker: () => ({}) as never,
ensureImmersionTrackerStartedCore: () => {},
startStatsServer: () => ({ close: () => {} }),
openExternal: async () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
getCurrentPid: () => 123,
isProcessAlive: () => false,
});
assert.equal(runtime.readLiveBackgroundStatsDaemonState(), null);
assert.equal(fs.existsSync(statePath), false);
});
});
test('stats runtime starts background server and writes owned daemon state', async () => {
await withTempDir(async (dir) => {
const statePath = path.join(dir, 'stats-daemon.json');
let startedPort: number | null = null;
const runtime = createStatsRuntime({
statsDaemonStatePath: statePath,
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6970 },
}),
getImmersionTracker: () => ({}) as never,
ensureImmersionTrackerStartedCore: () => {},
startStatsServer: (port) => {
startedPort = port;
return { close: () => {} };
},
openExternal: async () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
getCurrentPid: () => 456,
isProcessAlive: () => false,
now: () => 999,
});
const result = runtime.ensureBackgroundStatsServerStarted();
assert.deepEqual(result, {
url: 'http://127.0.0.1:6970',
runningInCurrentProcess: true,
});
assert.equal(startedPort, 6970);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
pid: 456,
port: 6970,
startedAtMs: 999,
});
});
});
test('stats runtime stops owned server and clears daemon state during quit cleanup', async () => {
await withTempDir(async (dir) => {
const statePath = path.join(dir, 'stats-daemon.json');
const calls: string[] = [];
const runtime = createStatsRuntime({
statsDaemonStatePath: statePath,
getResolvedConfig: () => ({
immersionTracking: { enabled: true },
stats: { serverPort: 6971 },
}),
getImmersionTracker: () => ({}) as never,
ensureImmersionTrackerStartedCore: () => {},
startStatsServer: () => ({
close: () => {
calls.push('close');
},
}),
openExternal: async () => {},
exitAppWithCode: () => {},
destroyStatsWindow: () => {
calls.push('destroy-window');
},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
getCurrentPid: () => 789,
isProcessAlive: () => true,
now: () => 500,
});
runtime.ensureBackgroundStatsServerStarted();
runtime.cleanupBeforeQuit();
assert.deepEqual(calls, ['destroy-window', 'close']);
assert.equal(fs.existsSync(statePath), false);
assert.equal(runtime.getStatsServer(), null);
});
});

469
src/main/stats-runtime.ts Normal file
View File

@@ -0,0 +1,469 @@
import path from 'node:path';
import {
isBackgroundStatsServerProcessAlive,
readBackgroundStatsServerState,
removeBackgroundStatsServerState,
resolveBackgroundStatsServerUrl,
writeBackgroundStatsServerState,
type BackgroundStatsServerState,
} from './runtime/stats-daemon';
import {
createRunStatsCliCommandHandler,
writeStatsCliCommandResponse,
} from './runtime/stats-cli-command';
import type { CliArgs, CliCommandSource } from '../cli/args';
import { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
import { startStatsServer as startStatsServerCore } from '../core/services/stats-server';
import { createLogger } from '../logger';
import { createCoverArtFetcher } from '../core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from '../core/services/anilist/rate-limiter';
import { resolveLegacyVocabularyPosFromTokens } from '../core/services/immersion-tracker/legacy-vocabulary-pos';
import type {
LifetimeRebuildSummary,
VocabularyCleanupSummary,
} from '../core/services/immersion-tracker/types';
import type { ResolvedConfig } from '../types';
import type { AppReadyImmersionInput } from './app-ready-runtime';
import { createImmersionTrackerStartupHandler } from './runtime/immersion-startup';
type StatsConfigLike = {
immersionTracking?: {
enabled?: boolean;
};
stats: {
serverPort: number;
autoOpenBrowser?: boolean;
};
};
type StatsServerLike = {
close: () => void;
};
type StatsTrackerLike = {
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
recordCardsMined?: (count: number, noteIds?: number[]) => void;
};
type StatsBootstrapAppState = {
mecabTokenizer: {
tokenize: (text: string) => Promise<unknown[] | null>;
} | null;
immersionTracker: ImmersionTrackerService | null;
mpvClient: unknown | null;
mpvSocketPath: string;
ankiIntegration: {
resolveCurrentNoteId: (noteId: number) => number;
} | null;
statsOverlayVisible: boolean;
};
export interface StatsRuntimeInput<
TConfig extends StatsConfigLike = StatsConfigLike,
TTracker extends StatsTrackerLike = StatsTrackerLike,
TServer extends StatsServerLike = StatsServerLike,
> {
statsDaemonStatePath: string;
getResolvedConfig: () => TConfig;
getImmersionTracker: () => TTracker | null;
ensureImmersionTrackerStartedCore: () => void;
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
startStatsServer: (port: number) => TServer;
openExternal: (url: string) => Promise<unknown>;
exitAppWithCode: (code: number) => void;
destroyStatsWindow?: () => void;
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
logError: (message: string, error: unknown) => void;
now?: () => number;
getCurrentPid?: () => number;
isProcessAlive?: (pid: number) => boolean;
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
wait?: (delayMs: number) => Promise<void>;
}
export interface StatsRuntime {
readLiveBackgroundStatsDaemonState: () => BackgroundStatsServerState | null;
ensureImmersionTrackerStarted: () => void;
ensureStatsServerStarted: () => string;
stopStatsServer: () => void;
ensureBackgroundStatsServerStarted: () => {
url: string;
runningInCurrentProcess: boolean;
};
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
runStatsCliCommand: (
args: Pick<
CliArgs,
| 'statsResponsePath'
| 'statsBackground'
| 'statsStop'
| 'statsCleanup'
| 'statsCleanupVocab'
| 'statsCleanupLifetime'
>,
source: CliCommandSource,
) => Promise<void>;
cleanupBeforeQuit: () => void;
getStatsServer: () => StatsServerLike | null;
isStatsStartupInProgress: () => boolean;
}
export interface StatsRuntimeBootstrapInput {
statsDaemonStatePath: string;
statsDistPath: string;
statsPreloadPath: string;
userDataPath: string;
appState: StatsBootstrapAppState;
getResolvedConfig: () => ResolvedConfig;
dictionarySupport: {
getConfiguredDbPath: () => string;
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
};
overlay: {
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
updateVisibleOverlayVisibility: () => void;
registerStatsOverlayToggle: (options: {
staticDir: string;
preloadPath: string;
getApiBaseUrl: () => string;
getToggleKey: () => string;
resolveBounds: () => Electron.Rectangle;
onVisibilityChanged: (visible: boolean) => void;
}) => void;
};
createMecabTokenizerAndCheck: () => Promise<void>;
addYomitanNote: (word: string) => Promise<number | null>;
openExternal: (url: string) => Promise<unknown>;
requestAppQuit: () => void;
destroyStatsWindow: () => void;
logger: {
info: (message: string) => void;
warn: (message: string, error?: unknown) => void;
error: (message: string, error?: unknown) => void;
debug: (message: string, details?: unknown) => void;
};
}
export interface StatsRuntimeBootstrap {
stats: StatsRuntime;
immersion: AppReadyImmersionInput;
recordTrackedCardsMined: (count: number, noteIds?: number[]) => void;
}
export function createStatsRuntime<
TConfig extends StatsConfigLike,
TTracker extends StatsTrackerLike,
TServer extends StatsServerLike,
>(input: StatsRuntimeInput<TConfig, TTracker, TServer>): StatsRuntime {
const now = input.now ?? Date.now;
const getCurrentPid = input.getCurrentPid ?? (() => process.pid);
const isProcessAlive = input.isProcessAlive ?? isBackgroundStatsServerProcessAlive;
const killProcess =
input.killProcess ??
((pid: number, signal: NodeJS.Signals) => {
process.kill(pid, signal);
});
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
});
let statsServer: TServer | null = null;
let statsStartupInProgress = false;
let hasAttemptedImmersionTrackerStartup = false;
const readLiveBackgroundStatsDaemonState = (): BackgroundStatsServerState | null => {
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return null;
}
if (state.pid === getCurrentPid() && !statsServer) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return null;
}
if (!isProcessAlive(state.pid)) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return null;
}
return state;
};
const clearOwnedBackgroundStatsDaemonState = (): void => {
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
if (state?.pid === getCurrentPid()) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
}
};
const stopStatsServer = (): void => {
if (!statsServer) {
return;
}
statsServer.close();
statsServer = null;
clearOwnedBackgroundStatsDaemonState();
};
const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || input.getImmersionTracker()) {
return;
}
hasAttemptedImmersionTrackerStartup = true;
statsStartupInProgress = true;
try {
input.ensureImmersionTrackerStartedCore();
} finally {
statsStartupInProgress = false;
}
};
const ensureStatsServerStarted = (): string => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
return resolveBackgroundStatsServerUrl(liveDaemon);
}
const tracker = input.getImmersionTracker();
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!statsServer) {
statsServer = input.startStatsServer(input.getResolvedConfig().stats.serverPort);
}
return `http://127.0.0.1:${input.getResolvedConfig().stats.serverPort}`;
};
const ensureBackgroundStatsServerStarted = (): {
url: string;
runningInCurrentProcess: boolean;
} => {
const liveDaemon = readLiveBackgroundStatsDaemonState();
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
return {
url: resolveBackgroundStatsServerUrl(liveDaemon),
runningInCurrentProcess: false,
};
}
ensureImmersionTrackerStarted();
const url = ensureStatsServerStarted();
writeBackgroundStatsServerState(input.statsDaemonStatePath, {
pid: getCurrentPid(),
port: input.getResolvedConfig().stats.serverPort,
startedAtMs: now(),
});
return {
url,
runningInCurrentProcess: true,
};
};
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
if (!state) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: true };
}
if (!isProcessAlive(state.pid)) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: true };
}
try {
killProcess(state.pid, 'SIGTERM');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: true };
}
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
throw new Error(
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
);
}
throw error;
}
const deadline = now() + 2_000;
while (now() < deadline) {
if (!isProcessAlive(state.pid)) {
removeBackgroundStatsServerState(input.statsDaemonStatePath);
return { ok: true, stale: false };
}
await wait(50);
}
throw new Error('Timed out stopping background stats server.');
};
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => input.getResolvedConfig(),
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
ensureVocabularyCleanupTokenizerReady: input.ensureVocabularyCleanupTokenizerReady,
getImmersionTracker: () => input.getImmersionTracker(),
ensureStatsServerStarted: () => ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
openExternal: async (url) => await input.openExternal(url),
writeResponse: (responsePath, payload) => {
writeStatsCliCommandResponse(responsePath, payload);
},
exitAppWithCode: (code) => input.exitAppWithCode(code),
logInfo: (message) => input.logInfo(message),
logWarn: (message, error) => input.logWarn(message, error),
logError: (message, error) => input.logError(message, error),
});
const cleanupBeforeQuit = (): void => {
input.destroyStatsWindow?.();
stopStatsServer();
};
return {
readLiveBackgroundStatsDaemonState,
ensureImmersionTrackerStarted,
ensureStatsServerStarted,
stopStatsServer,
ensureBackgroundStatsServerStarted,
stopBackgroundStatsServer,
runStatsCliCommand,
cleanupBeforeQuit,
getStatsServer: () => statsServer,
isStatsStartupInProgress: () => statsStartupInProgress,
};
}
export function createStatsRuntimeBootstrap(
input: StatsRuntimeBootstrapInput,
): StatsRuntimeBootstrap {
const statsCoverArtFetcher = createCoverArtFetcher(
createAnilistRateLimiter(),
createLogger('main:stats-cover-art'),
);
const resolveLegacyVocabularyPos = async (row: {
headword: string;
word: string;
reading: string | null;
}) => {
const tokenizer = input.appState.mecabTokenizer;
if (!tokenizer) {
return null;
}
const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])]
.map((value) => value.trim())
.filter((value) => value.length > 0);
for (const lookupText of lookupTexts) {
const tokens = await tokenizer.tokenize(lookupText);
if (!tokens) {
continue;
}
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens as never);
if (resolved) {
return resolved;
}
}
return null;
};
let stats: StatsRuntime | null = null;
const immersionMainDeps: Parameters<typeof createImmersionTrackerStartupHandler>[0] = {
getResolvedConfig: () => input.getResolvedConfig(),
getConfiguredDbPath: () => input.dictionarySupport.getConfiguredDbPath(),
createTrackerService: (params) =>
new ImmersionTrackerService({
...params,
resolveLegacyVocabularyPos,
}),
setTracker: (tracker) => {
const trackerHasChanged =
input.appState.immersionTracker !== null && input.appState.immersionTracker !== tracker;
if (trackerHasChanged && stats?.getStatsServer()) {
stats.stopStatsServer();
}
input.appState.immersionTracker = tracker as ImmersionTrackerService | null;
input.appState.immersionTracker?.setCoverArtFetcher?.(statsCoverArtFetcher);
if (!tracker) {
return;
}
if (!stats?.getStatsServer() && input.getResolvedConfig().stats.autoStartServer) {
stats?.ensureStatsServerStarted();
}
input.overlay.registerStatsOverlayToggle({
staticDir: input.statsDistPath,
preloadPath: input.statsPreloadPath,
getApiBaseUrl: () => stats!.ensureStatsServerStarted(),
getToggleKey: () => input.getResolvedConfig().stats.toggleKey,
resolveBounds: () => input.overlay.getOverlayGeometry().getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => {
input.appState.statsOverlayVisible = visible;
input.overlay.updateVisibleOverlayVisibility();
},
});
},
getMpvClient: () => input.appState.mpvClient as never,
shouldAutoConnectMpv: () => !stats?.isStatsStartupInProgress(),
seedTrackerFromCurrentMedia: () => {
void input.dictionarySupport.seedImmersionMediaFromCurrentMedia();
},
logInfo: (message) => input.logger.info(message),
logDebug: (message) => input.logger.debug(message),
logWarn: (message, details) => input.logger.warn(message, details),
};
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(immersionMainDeps);
stats = createStatsRuntime({
statsDaemonStatePath: input.statsDaemonStatePath,
getResolvedConfig: () => input.getResolvedConfig(),
getImmersionTracker: () => input.appState.immersionTracker,
ensureImmersionTrackerStartedCore: () => {
createImmersionTrackerStartup();
},
ensureVocabularyCleanupTokenizerReady: async () => {
await input.createMecabTokenizerAndCheck();
},
startStatsServer: (port) =>
startStatsServerCore({
port,
staticDir: input.statsDistPath,
tracker: input.appState.immersionTracker as ImmersionTrackerService,
knownWordCachePath: path.join(input.userDataPath, 'known-words-cache.json'),
mpvSocketPath: input.appState.mpvSocketPath,
ankiConnectConfig: input.getResolvedConfig().ankiConnect,
resolveAnkiNoteId: (noteId: number) =>
input.appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: (word: string) => input.addYomitanNote(word),
}),
openExternal: (url) => input.openExternal(url),
exitAppWithCode: (code) => {
process.exitCode = code;
input.requestAppQuit();
},
destroyStatsWindow: () => {
input.destroyStatsWindow();
},
logInfo: (message) => input.logger.info(message),
logWarn: (message, error) => input.logger.warn(message, error),
logError: (message, error) => input.logger.error(message, error),
});
return {
stats,
immersion: immersionMainDeps as AppReadyImmersionInput,
recordTrackedCardsMined: (count, noteIds) => {
stats.ensureImmersionTrackerStarted();
input.appState.immersionTracker?.recordCardsMined?.(count, noteIds);
},
};
}

View File

@@ -0,0 +1,451 @@
import * as path from 'node:path';
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type {
FrequencyDictionaryLookup,
ResolvedConfig,
SubtitleData,
SubtitlePosition,
} from '../types';
import {
deleteYomitanDictionaryByTitle,
getYomitanDictionaryInfo,
importYomitanDictionaryFromZip,
upsertYomitanDictionarySettings,
clearYomitanParserCachesForWindow,
} from '../core/services';
import type { YomitanParserRuntimeDeps } from './yomitan-runtime';
import {
createDictionarySupportRuntime,
type DictionarySupportRuntime,
} from './dictionary-support-runtime';
import {
createDictionarySupportRuntimeInput,
type DictionarySupportRuntimeInputBuilderInput,
} from './dictionary-support-runtime-input';
import {
createSubtitleRuntime,
type SubtitleRuntime,
type SubtitleRuntimeInput,
} from './subtitle-runtime';
import type { JlptLookup } from './jlpt-runtime';
import { formatSkippedYomitanWriteAction } from './runtime/yomitan-read-only-log';
type BrowserWindowLike = {
isDestroyed: () => boolean;
webContents: {
send: (channel: string, payload?: unknown) => void;
};
};
type ImmersionTrackerLike = {
handleMediaChange: (path: string, title: string | null) => void;
} | null;
type MpvClientLike = {
connected?: boolean;
currentSubStart?: number | null;
currentSubEnd?: number | null;
currentTimePos?: number | null;
currentVideoPath?: string | null;
requestProperty: (name: string) => Promise<unknown>;
} | null;
type OverlayUiLike = {
setVisibleOverlayVisible: (visible: boolean) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
} | null;
type OverlayManagerLike = {
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
getMainWindow: () => BrowserWindowLike | null;
getVisibleOverlayVisible: () => boolean;
};
type StartupOsdSequencerLike = NonNullable<
DictionarySupportRuntimeInputBuilderInput['startup']['startupOsdSequencer']
>;
export interface SubtitleDictionaryRuntimeInput {
env: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
configDir: string;
defaultImmersionDbPath: string;
};
appState: {
currentMediaPath: string | null;
currentMediaTitle: string | null;
currentSubText: string;
currentSubAssText: string;
mpvClient: MpvClientLike;
subtitlePosition: SubtitlePosition | null;
pendingSubtitlePosition: SubtitlePosition | null;
currentSubtitleData: SubtitleData | null;
activeParsedSubtitleCues: SubtitleCue[];
activeParsedSubtitleSource: string | null;
immersionTracker: ImmersionTrackerLike;
jlptLevelLookup: JlptLookup;
frequencyRankLookup: FrequencyDictionaryLookup;
yomitanParserWindow: BrowserWindowLike | null;
};
config: {
getResolvedConfig: () => ResolvedConfig;
};
services: {
subtitleWsService: SubtitleRuntimeInput['subtitleWsService'];
annotationSubtitleWsService: SubtitleRuntimeInput['annotationSubtitleWsService'];
overlayManager: OverlayManagerLike;
startupOsdSequencer: StartupOsdSequencerLike;
};
logging: {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, ...args: unknown[]) => void;
};
subtitle: {
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearSchedule: (timer: ReturnType<typeof setTimeout>) => void;
};
overlay: {
getOverlayUi: () => OverlayUiLike;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
};
playback: {
isRemoteMediaPath: (mediaPath: string) => boolean;
isYoutubePlaybackActive: (mediaPath: string | null, videoPath: string | null) => boolean;
waitForYomitanMutationReady: (mediaKey: string | null) => Promise<void>;
};
anilist: {
guessAnilistMediaInfo: (
mediaPath: string | null,
mediaTitle: string | null,
) => Promise<AnilistMediaGuess | null>;
};
yomitan: {
isCharacterDictionaryEnabled: () => boolean;
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
) => Promise<boolean>;
hasParserWindow: () => boolean;
clearParserCaches: () => void;
};
}
export interface SubtitleDictionaryRuntime {
subtitle: SubtitleRuntime;
dictionarySupport: DictionarySupportRuntime;
}
export interface SubtitleDictionaryRuntimeCoordinatorInput {
env: SubtitleDictionaryRuntimeInput['env'];
appState: SubtitleDictionaryRuntimeInput['appState'];
getResolvedConfig: () => ResolvedConfig;
services: SubtitleDictionaryRuntimeInput['services'];
logging: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
overlay: SubtitleDictionaryRuntimeInput['overlay'];
playback: SubtitleDictionaryRuntimeInput['playback'];
anilist: SubtitleDictionaryRuntimeInput['anilist'];
subtitle: {
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
};
yomitan: {
isCharacterDictionaryEnabled: () => boolean;
isExternalReadOnlyMode: () => boolean;
logSkippedWrite: (message: string) => void;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
getParserRuntimeDeps: () => YomitanParserRuntimeDeps;
};
}
export function createSubtitleDictionaryRuntime(
input: SubtitleDictionaryRuntimeInput,
): SubtitleDictionaryRuntime {
const subtitlePositionsDir = path.join(input.env.configDir, 'subtitle-positions');
const subtitle = createSubtitleRuntime({
getResolvedConfig: () => input.config.getResolvedConfig(),
getCurrentMediaPath: () => input.appState.currentMediaPath,
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
getCurrentSubText: () => input.appState.currentSubText,
getCurrentSubAssText: () => input.appState.currentSubAssText,
getMpvClient: () => input.appState.mpvClient,
subtitleWsService: input.services.subtitleWsService,
annotationSubtitleWsService: input.services.annotationSubtitleWsService,
broadcastToOverlayWindows: (channel, payload) =>
input.services.overlayManager.broadcastToOverlayWindows(channel, payload),
subtitlePositionsDir,
setSubtitlePosition: (position) => {
input.appState.subtitlePosition = position;
},
setPendingSubtitlePosition: (position) => {
input.appState.pendingSubtitlePosition = position;
},
clearPendingSubtitlePosition: () => {
input.appState.pendingSubtitlePosition = null;
},
setCurrentSubtitleData: (payload) => {
input.appState.currentSubtitleData = payload;
},
setActiveParsedSubtitleState: (cues, sourceKey) => {
input.appState.activeParsedSubtitleCues = cues;
input.appState.activeParsedSubtitleSource = sourceKey;
},
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
schedule: (callback, delayMs) => input.subtitle.schedule(callback, delayMs),
clearSchedule: (timer) => input.subtitle.clearSchedule(timer),
logDebug: (message) => input.logging.debug(message),
logInfo: (message) => input.logging.info(message),
logWarn: (message) => input.logging.warn(message),
});
const dictionarySupport = createDictionarySupportRuntime<OverlayHostedModal>(
createDictionarySupportRuntimeInput({
env: {
platform: input.env.platform,
dirname: input.env.dirname,
appPath: input.env.appPath,
resourcesPath: input.env.resourcesPath,
userDataPath: input.env.userDataPath,
appUserDataPath: input.env.appUserDataPath,
homeDir: input.env.homeDir,
appDataDir: input.env.appDataDir,
cwd: input.env.cwd,
subtitlePositionsDir,
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
},
config: {
getResolvedConfig: () => input.config.getResolvedConfig(),
},
dictionaryState: {
setJlptLevelLookup: (lookup) => {
input.appState.jlptLevelLookup = lookup;
},
setFrequencyRankLookup: (lookup) => {
input.appState.frequencyRankLookup = lookup;
},
},
logger: {
info: (message) => input.logging.info(message),
debug: (message) => input.logging.debug(message),
warn: (message) => input.logging.warn(message),
error: (message, ...args) => input.logging.error(message, ...args),
},
media: {
isRemoteMediaPath: (mediaPath) => input.playback.isRemoteMediaPath(mediaPath),
getCurrentMediaPath: () => input.appState.currentMediaPath,
setCurrentMediaPath: (mediaPath) => {
input.appState.currentMediaPath = mediaPath;
},
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
setCurrentMediaTitle: (title) => {
input.appState.currentMediaTitle = title;
},
getPendingSubtitlePosition: () => input.appState.pendingSubtitlePosition,
clearPendingSubtitlePosition: () => {
input.appState.pendingSubtitlePosition = null;
},
setSubtitlePosition: (position) => {
input.appState.subtitlePosition = position;
},
},
subtitle: {
loadSubtitlePosition: () => subtitle.loadSubtitlePosition(),
invalidateTokenizationCache: () => {
subtitle.invalidateTokenizationCache();
},
refreshSubtitlePrefetchFromActiveTrack: () => {
subtitle.refreshSubtitlePrefetchFromActiveTrack();
},
refreshCurrentSubtitle: (text) => {
subtitle.refreshCurrentSubtitle(text);
},
getCurrentSubtitleText: () => input.appState.currentSubText,
},
overlay: {
broadcastSubtitlePosition: (position) => {
input.services.overlayManager.broadcastToOverlayWindows('subtitle:position', position);
},
broadcastToOverlayWindows: (channel, payload) => {
input.services.overlayManager.broadcastToOverlayWindows(channel, payload);
},
getMainWindow: () => input.services.overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => input.services.overlayManager.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => {
input.overlay.getOverlayUi()?.setVisibleOverlayVisible(visible);
},
getRestoreVisibleOverlayOnModalClose: () =>
input.overlay.getOverlayUi()?.getRestoreVisibleOverlayOnModalClose() ??
new Set<OverlayHostedModal>(),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
input.overlay
.getOverlayUi()
?.sendToActiveOverlayWindow(channel, payload, runtimeOptions) ?? false,
},
tracker: {
getTracker: () => input.appState.immersionTracker,
getMpvClient: () => input.appState.mpvClient,
},
anilist: {
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
},
yomitan: {
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
deleteYomitanDictionary: (dictionaryTitle) =>
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
hasParserWindow: () => input.yomitan.hasParserWindow(),
clearParserCaches: () => input.yomitan.clearParserCaches(),
},
startup: {
getNotificationType: () =>
input.config.getResolvedConfig().ankiConnect.behavior.notificationType,
showMpvOsd: (message) => input.overlay.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.overlay.showDesktopNotification(title, options),
startupOsdSequencer: input.services.startupOsdSequencer,
},
playback: {
isYoutubePlaybackActiveNow: () =>
input.playback.isYoutubePlaybackActive(
input.appState.currentMediaPath,
input.appState.mpvClient?.currentVideoPath ?? null,
),
waitForYomitanMutationReady: () =>
input.playback.waitForYomitanMutationReady(
input.appState.currentMediaPath?.trim() ||
input.appState.mpvClient?.currentVideoPath?.trim() ||
null,
),
},
}),
);
return {
subtitle,
dictionarySupport,
};
}
export function createSubtitleDictionaryRuntimeCoordinator(
input: SubtitleDictionaryRuntimeCoordinatorInput,
): SubtitleDictionaryRuntime {
return createSubtitleDictionaryRuntime({
env: input.env,
appState: input.appState,
config: {
getResolvedConfig: () => input.getResolvedConfig(),
},
services: input.services,
logging: input.logging,
subtitle: {
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearSchedule: (timer) => clearTimeout(timer),
},
overlay: input.overlay,
playback: input.playback,
anilist: input.anilist,
yomitan: {
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
getYomitanDictionaryInfo: async () => {
await input.yomitan.ensureYomitanExtensionLoaded();
return await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
error: (message, ...args) => input.logging.error(message, ...args),
info: (message, ...args) => input.logging.info(message, ...args),
});
},
importYomitanDictionary: async (zipPath) => {
if (input.yomitan.isExternalReadOnlyMode()) {
input.yomitan.logSkippedWrite(
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
);
return false;
}
await input.yomitan.ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, input.yomitan.getParserRuntimeDeps(), {
error: (message, ...args) => input.logging.error(message, ...args),
info: (message, ...args) => input.logging.info(message, ...args),
});
},
deleteYomitanDictionary: async (dictionaryTitle) => {
if (input.yomitan.isExternalReadOnlyMode()) {
input.yomitan.logSkippedWrite(
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
);
return false;
}
await input.yomitan.ensureYomitanExtensionLoaded();
return await deleteYomitanDictionaryByTitle(
dictionaryTitle,
input.yomitan.getParserRuntimeDeps(),
{
error: (message, ...args) => input.logging.error(message, ...args),
info: (message, ...args) => input.logging.info(message, ...args),
},
);
},
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
if (input.yomitan.isExternalReadOnlyMode()) {
input.yomitan.logSkippedWrite(
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
);
return false;
}
await input.yomitan.ensureYomitanExtensionLoaded();
return await upsertYomitanDictionarySettings(
dictionaryTitle,
profileScope,
input.yomitan.getParserRuntimeDeps(),
{
error: (message, ...args) => input.logging.error(message, ...args),
info: (message, ...args) => input.logging.info(message, ...args),
},
);
},
hasParserWindow: () => Boolean(input.appState.yomitanParserWindow),
clearParserCaches: () => {
if (input.appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(input.appState.yomitanParserWindow as never);
}
},
},
});
}

View File

@@ -0,0 +1,131 @@
import * as fs from 'node:fs';
import { spawn } from 'node:child_process';
import * as os from 'node:os';
import * as path from 'node:path';
import { codecToExtension } from '../subsync/utils';
import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source';
export type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
codec?: unknown;
external?: unknown;
'ff-index'?: unknown;
'external-filename'?: unknown;
};
const DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS = 4000;
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}
function buildFfmpegSubtitleExtractionArgs(
videoPath: string,
ffIndex: number,
outputPath: string,
): string[] {
return [
'-hide_banner',
'-nostdin',
'-y',
'-loglevel',
'error',
'-an',
'-vn',
'-i',
videoPath,
'-map',
`0:${ffIndex}`,
'-f',
path.extname(outputPath).slice(1),
outputPath,
];
}
export function createSubtitleSourceLoader(options?: {
fetchImpl?: typeof fetch;
subtitleSourceFetchTimeoutMs?: number;
}): (source: string) => Promise<string> {
const fetchImpl = options?.fetchImpl ?? fetch;
const timeoutMs =
options?.subtitleSourceFetchTimeoutMs ?? DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS;
return async (source: string): Promise<string> => {
if (/^https?:\/\//i.test(source)) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(source, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to download subtitle source (${response.status})`);
}
return await response.text();
} finally {
clearTimeout(timeoutId);
}
}
const filePath = resolveSubtitleSourcePath(source);
return await fs.promises.readFile(filePath, 'utf8');
};
}
export function createExtractInternalSubtitleTrackToTempFileHandler() {
return async (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): Promise<{ path: string; cleanup: () => Promise<void> } | null> => {
const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined);
if (ffIndex === null || extension === null) {
return null;
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
try {
await new Promise<void>((resolve, reject) => {
const child = spawn(
ffmpegPath,
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
);
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
});
});
} catch (error) {
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return {
path: outputPath,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
};
};
}

View File

@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createSubtitleRuntime } from './subtitle-runtime';
function createResolvedConfig() {
return {
subtitleStyle: {
frequencyDictionary: {
enabled: true,
topX: 5,
mode: 'top',
},
},
subtitleSidebar: {
autoScroll: true,
pauseVideoOnHover: false,
maxWidth: 420,
opacity: 0.92,
backgroundColor: '#111111',
textColor: '#ffffff',
fontFamily: 'sans-serif',
fontSize: 24,
timestampColor: '#cccccc',
activeLineColor: '#ffffff',
activeLineBackgroundColor: '#222222',
hoverLineBackgroundColor: '#333333',
},
subtitlePosition: {
yPercent: 84,
},
subsync: {
defaultMode: 'auto' as const,
alass_path: 'alass',
ffmpeg_path: 'ffmpeg',
ffsubsync_path: 'ffsubsync',
replace: false,
},
} as never;
}
function createMpvClient(properties: Record<string, unknown>) {
return {
connected: true,
currentSubStart: 1.25,
currentSubEnd: 2.5,
currentTimePos: 12.5,
requestProperty: async (name: string) => properties[name],
};
}
function createRuntime(overrides: Partial<Parameters<typeof createSubtitleRuntime>[0]> = {}) {
const calls: string[] = [];
const config = createResolvedConfig();
let subtitlePosition: unknown = null;
let pendingSubtitlePosition: unknown = null;
const runtime = createSubtitleRuntime({
getResolvedConfig: () => config,
getCurrentMediaPath: () => '/media/episode.mkv',
getCurrentMediaTitle: () => 'Episode',
getCurrentSubText: () => 'current subtitle',
getCurrentSubAssText: () => '[Events]',
getMpvClient: () =>
createMpvClient({
'current-tracks/sub/external-filename': '/tmp/episode.ass',
'current-tracks/sub': {
type: 'sub',
id: 3,
external: true,
'external-filename': '/tmp/episode.ass',
},
'track-list': [
{
type: 'sub',
id: 3,
external: true,
'external-filename': '/tmp/episode.ass',
},
],
sid: 3,
path: '/media/episode.mkv',
}),
broadcastToOverlayWindows: (channel, payload) => {
calls.push(`${channel}:${JSON.stringify(payload)}`);
},
subtitleWsService: {
broadcast: () => calls.push('subtitle-ws'),
},
annotationSubtitleWsService: {
broadcast: () => calls.push('annotation-ws'),
},
subtitlePositionsDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-runtime-')),
setSubtitlePosition: (position) => {
subtitlePosition = position;
},
setPendingSubtitlePosition: (position) => {
pendingSubtitlePosition = position;
},
clearPendingSubtitlePosition: () => {
pendingSubtitlePosition = null;
},
parseSubtitleCues: (content) => [
{
startTime: 0,
endTime: 1,
text: content.trim(),
},
],
createSubtitlePrefetchService: ({ cues }) => ({
start: () => calls.push(`start:${cues.length}`),
stop: () => calls.push('stop'),
onSeek: (time) => calls.push(`seek:${time}`),
pause: () => calls.push('pause'),
resume: () => calls.push('resume'),
}),
logDebug: (message) => calls.push(`debug:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: (timer) => clearTimeout(timer),
...overrides,
});
return { runtime, calls, subtitlePosition, pendingSubtitlePosition };
}
test('subtitle runtime schedules and cancels subtitle prefetch refreshes', async () => {
const calls: string[] = [];
const { runtime } = createRuntime({
refreshSubtitlePrefetchFromActiveTrack: async () => {
calls.push('refresh');
},
});
runtime.scheduleSubtitlePrefetchRefresh(5);
runtime.clearScheduledSubtitlePrefetchRefresh();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(calls, []);
});
test('subtitle runtime times out remote subtitle source fetches', async () => {
const { runtime } = createRuntime({
fetchImpl: async (_url, init) => {
await new Promise<void>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
});
return new Response('');
},
subtitleSourceFetchTimeoutMs: 10,
});
await assert.rejects(
async () => await runtime.loadSubtitleSourceText('https://example.com/subtitles.srt'),
/aborted/,
);
});
test('subtitle runtime reuses cached sidebar cues for the same source key', async () => {
const subtitlePath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subtitle-cache-')),
'episode.ass',
);
fs.writeFileSync(
subtitlePath,
`1
00:00:01,000 --> 00:00:02,000
Hello`,
);
let loadCount = 0;
const { runtime } = createRuntime({
getMpvClient: () =>
createMpvClient({
'current-tracks/sub/external-filename': subtitlePath,
'current-tracks/sub': {
type: 'sub',
id: 3,
external: true,
'external-filename': subtitlePath,
},
'track-list': [
{
type: 'sub',
id: 3,
external: true,
'external-filename': subtitlePath,
},
],
sid: 3,
path: '/media/episode.mkv',
}),
loadSubtitleSourceText: async () => {
loadCount += 1;
return fs.readFileSync(subtitlePath, 'utf8');
},
});
const first = await runtime.getSubtitleSidebarSnapshot();
const second = await runtime.getSubtitleSidebarSnapshot();
assert.equal(loadCount, 1);
assert.deepEqual(second.cues, first.cues);
assert.equal(second.currentSubtitle.text, 'current subtitle');
});

View File

@@ -0,0 +1,423 @@
import { createSubtitleProcessingController } from '../core/services/subtitle-processing-controller';
import {
createSubtitlePrefetchService,
type SubtitlePrefetchService,
type SubtitlePrefetchServiceDeps,
} from '../core/services/subtitle-prefetch';
import type { SubtitleWebsocketFrequencyOptions } from '../core/services/subtitle-ws';
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
import {
loadSubtitlePosition as loadSubtitlePositionCore,
saveSubtitlePosition as saveSubtitlePositionCore,
} from '../core/services/subtitle-position';
import type {
ResolvedConfig,
SubtitleData,
SubtitlePosition,
SubtitleSidebarSnapshot,
} from '../types';
import {
createLoadSubtitlePositionHandler,
createSaveSubtitlePositionHandler,
} from './runtime/subtitle-position';
import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './runtime/subtitle-prefetch-runtime';
import { createSubtitlePrefetchInitController } from './runtime/subtitle-prefetch-init';
import {
createExtractInternalSubtitleTrackToTempFileHandler,
createSubtitleSourceLoader,
type MpvSubtitleTrackLike,
} from './subtitle-runtime-sources';
type SubtitleBroadcastService = {
broadcast: (payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions) => void;
};
type SubtitleRuntimeConfigLike = Pick<
ResolvedConfig,
'subtitleStyle' | 'subtitleSidebar' | 'subtitlePosition' | 'subsync'
>;
export interface SubtitleRuntimeInput {
getResolvedConfig: () => SubtitleRuntimeConfigLike;
getCurrentMediaPath: () => string | null;
getCurrentMediaTitle: () => string | null;
getCurrentSubText: () => string;
getCurrentSubAssText: () => string;
getMpvClient: () => {
connected?: boolean;
currentSubStart?: number | null;
currentSubEnd?: number | null;
currentTimePos?: number | null;
requestProperty: (name: string) => Promise<unknown>;
} | null;
subtitleWsService: SubtitleBroadcastService;
annotationSubtitleWsService: SubtitleBroadcastService;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
subtitlePositionsDir: string;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
setPendingSubtitlePosition: (position: SubtitlePosition | null) => void;
clearPendingSubtitlePosition: () => void;
setCurrentSubtitleData?: (payload: SubtitleData | null) => void;
setActiveParsedSubtitleState?: (cues: SubtitleCue[], sourceKey: string | null) => void;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
loadSubtitleSourceText?: (source: string) => Promise<string>;
refreshSubtitlePrefetchFromActiveTrack?: () => Promise<void>;
fetchImpl?: typeof fetch;
subtitleSourceFetchTimeoutMs?: number;
prefetchRefreshDelayMs?: number;
seekThresholdSeconds?: number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearSchedule: (timer: ReturnType<typeof setTimeout>) => void;
logDebug: (message: string) => void;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
}
export interface SubtitleRuntime {
setTokenizeSubtitleDeferred: (tokenize: ((text: string) => Promise<SubtitleData>) | null) => void;
emitSubtitlePayload: (payload: SubtitleData) => void;
refreshCurrentSubtitle: (textOverride?: string) => void;
invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
isCacheFull: () => boolean;
onSubtitleChange: (text: string) => void;
onCurrentMediaPathChange: (path: string | null) => void;
onTimePosUpdate: (time: number) => void;
getLastObservedTimePos: () => number;
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
cancelPendingSubtitlePrefetchInit: () => void;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
tokenizeCurrentSubtitle: () => Promise<SubtitleData>;
loadSubtitleSourceText: (source: string) => Promise<string>;
extractInternalSubtitleTrackToTempFile: (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
loadSubtitlePosition: () => SubtitlePosition | null;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getCurrentSubtitleData: () => SubtitleData | null;
getActiveParsedSubtitleCues: () => SubtitleCue[];
getActiveParsedSubtitleSource: () => string | null;
}
const DEFAULT_PREFETCH_REFRESH_DELAY_MS = 500;
const SEEK_THRESHOLD_SECONDS = 3;
export function createSubtitleRuntime(input: SubtitleRuntimeInput): SubtitleRuntime {
const loadSubtitleSourceText =
input.loadSubtitleSourceText ??
createSubtitleSourceLoader({
fetchImpl: input.fetchImpl,
subtitleSourceFetchTimeoutMs: input.subtitleSourceFetchTimeoutMs,
});
const extractInternalSubtitleTrackToTempFile =
createExtractInternalSubtitleTrackToTempFileHandler();
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
let currentSubtitleData: SubtitleData | null = null;
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0;
let activeParsedSubtitleCues: SubtitleCue[] = [];
let activeParsedSubtitleSource: string | null = null;
const setActiveParsedSubtitleState = (
cues: SubtitleCue[] | null,
sourceKey: string | null,
): void => {
activeParsedSubtitleCues = cues ?? [];
activeParsedSubtitleSource = sourceKey;
input.setActiveParsedSubtitleState?.(activeParsedSubtitleCues, activeParsedSubtitleSource);
};
const tokenizationController = createSubtitleProcessingController({
tokenizeSubtitle: async (text: string) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
emitSubtitle: (payload) => emitSubtitlePayload(payload),
logDebug: (message) => {
input.logDebug(`[subtitle-processing] ${message}`);
},
now: () => Date.now(),
});
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
getCurrentService: () => subtitlePrefetchService,
setCurrentService: (service) => {
subtitlePrefetchService = service;
},
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => input.parseSubtitleCues(content, filename),
createSubtitlePrefetchService: (deps) => input.createSubtitlePrefetchService(deps),
tokenizeSubtitle: async (text) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
preCacheTokenization: (text, data) => tokenizationController.preCacheTokenization(text, data),
isCacheFull: () => tokenizationController.isCacheFull(),
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
setActiveParsedSubtitleState(cues, sourceKey);
},
});
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler(
{
getFfmpegPath: () => input.getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
},
);
const refreshSubtitlePrefetchFromActiveTrackHandler =
input.refreshSubtitlePrefetchFromActiveTrack ??
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => input.getMpvClient(),
getLastObservedTimePos: () => lastObservedTimePos,
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (nextInput) =>
resolveActiveSubtitleSidebarSourceHandler(nextInput),
});
const loadSubtitlePosition = createLoadSubtitlePositionHandler({
loadSubtitlePositionCore: () =>
loadSubtitlePositionCore({
currentMediaPath: input.getCurrentMediaPath(),
fallbackPosition: input.getResolvedConfig().subtitlePosition,
subtitlePositionsDir: input.subtitlePositionsDir,
}),
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
});
const saveSubtitlePosition = createSaveSubtitlePositionHandler({
saveSubtitlePositionCore: (position) =>
saveSubtitlePositionCore({
position,
currentMediaPath: input.getCurrentMediaPath(),
subtitlePositionsDir: input.subtitlePositionsDir,
onQueuePending: (queued) => input.setPendingSubtitlePosition(queued),
onPersisted: () => input.clearPendingSubtitlePosition(),
}),
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
});
const getSubtitleBroadcastOptions = (): SubtitleWebsocketFrequencyOptions => {
const config = input.getResolvedConfig().subtitleStyle.frequencyDictionary;
return {
enabled: config.enabled,
topX: config.topX,
mode: config.mode,
};
};
const withCurrentSubtitleTiming = (payload: SubtitleData): SubtitleData => ({
...payload,
startTime: input.getMpvClient()?.currentSubStart ?? null,
endTime: input.getMpvClient()?.currentSubEnd ?? null,
});
const clearScheduledSubtitlePrefetchRefresh = (): void => {
if (subtitlePrefetchRefreshTimer) {
input.clearSchedule(subtitlePrefetchRefreshTimer);
subtitlePrefetchRefreshTimer = null;
}
};
const scheduleSubtitlePrefetchRefresh = (delayMs = 0): void => {
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRefreshTimer = input.schedule(() => {
subtitlePrefetchRefreshTimer = null;
void refreshSubtitlePrefetchFromActiveTrackHandler();
}, delayMs);
};
const refreshSubtitleSidebarFromSource = async (sourcePath: string): Promise<void> => {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
normalizedSourcePath,
);
};
const onCurrentMediaPathChange = (pathValue: string | null): void => {
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchInitController.cancelPendingInit();
if (pathValue) {
scheduleSubtitlePrefetchRefresh(
input.prefetchRefreshDelayMs ?? DEFAULT_PREFETCH_REFRESH_DELAY_MS,
);
}
};
const onTimePosUpdate = (time: number): void => {
const delta = time - lastObservedTimePos;
if (
subtitlePrefetchService &&
(delta > (input.seekThresholdSeconds ?? SEEK_THRESHOLD_SECONDS) || delta < 0)
) {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
};
const emitSubtitlePayload = (payload: SubtitleData): void => {
const timedPayload = withCurrentSubtitleTiming(payload);
currentSubtitleData = timedPayload;
input.setCurrentSubtitleData?.(timedPayload);
input.broadcastToOverlayWindows('subtitle:set', timedPayload);
const broadcastOptions = getSubtitleBroadcastOptions();
input.subtitleWsService.broadcast(timedPayload, broadcastOptions);
input.annotationSubtitleWsService.broadcast(timedPayload, broadcastOptions);
subtitlePrefetchService?.resume();
};
const tokenizeCurrentSubtitle = async (): Promise<SubtitleData> => {
const tokenized = await tokenizationController.consumeCachedSubtitle(input.getCurrentSubText());
if (tokenized) {
return withCurrentSubtitleTiming(tokenized);
}
const text = input.getCurrentSubText();
const deferred = tokenizeSubtitleDeferred
? await tokenizeSubtitleDeferred(text)
: { text, tokens: null };
return withCurrentSubtitleTiming(deferred);
};
return {
setTokenizeSubtitleDeferred: (tokenize) => {
tokenizeSubtitleDeferred = tokenize;
},
emitSubtitlePayload,
refreshCurrentSubtitle: (textOverride?: string) => {
tokenizationController.refreshCurrentSubtitle(textOverride);
},
invalidateTokenizationCache: () => {
tokenizationController.invalidateTokenizationCache();
},
preCacheTokenization: (text, data) => {
tokenizationController.preCacheTokenization(text, data);
},
consumeCachedSubtitle: (text) => tokenizationController.consumeCachedSubtitle(text),
isCacheFull: () => tokenizationController.isCacheFull(),
onSubtitleChange: (text) => {
subtitlePrefetchService?.pause();
tokenizationController.onSubtitleChange(text);
},
onCurrentMediaPathChange,
onTimePosUpdate,
getLastObservedTimePos: () => lastObservedTimePos,
refreshSubtitleSidebarFromSource,
refreshSubtitlePrefetchFromActiveTrack: async () => {
await refreshSubtitlePrefetchFromActiveTrackHandler();
},
cancelPendingSubtitlePrefetchInit: () => {
subtitlePrefetchInitController.cancelPendingInit();
},
scheduleSubtitlePrefetchRefresh,
clearScheduledSubtitlePrefetchRefresh,
getSubtitleSidebarSnapshot: async (): Promise<SubtitleSidebarSnapshot> => {
const currentSubtitle = {
text: input.getCurrentSubText(),
startTime: input.getMpvClient()?.currentSubStart ?? null,
endTime: input.getMpvClient()?.currentSubEnd ?? null,
};
const currentTimeSec = input.getMpvClient()?.currentTimePos ?? null;
const config = input.getResolvedConfig().subtitleSidebar;
const client = input.getMpvClient();
if (!client?.connected) {
return {
cues: activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('path'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
return {
cues: activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
});
if (!resolvedSource) {
return {
cues: activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
try {
if (activeParsedSubtitleSource === resolvedSource.sourceKey) {
return {
cues: activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = input.parseSubtitleCues(content, resolvedSource.path);
setActiveParsedSubtitleState(cues, resolvedSource.sourceKey);
return {
cues,
currentTimeSec,
currentSubtitle,
config,
};
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return {
cues: activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
},
tokenizeCurrentSubtitle,
loadSubtitleSourceText,
extractInternalSubtitleTrackToTempFile,
loadSubtitlePosition,
saveSubtitlePosition,
getCurrentSubtitleData: () => currentSubtitleData,
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
getActiveParsedSubtitleSource: () => activeParsedSubtitleSource,
};
}

View File

@@ -0,0 +1,101 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader';
import type { ResolvedConfig } from '../types';
import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy';
import { createYomitanRuntime, type YomitanRuntime } from './yomitan-runtime';
export interface YomitanRuntimeBootstrapInput {
userDataPath: string;
getResolvedConfig: () => ResolvedConfig;
appState: {
yomitanParserWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
yomitanExt: Extension | null;
yomitanSession: Session | null;
yomitanSettingsWindow: BrowserWindow | null;
};
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
getLoadInFlight: () => Promise<Extension | null> | null;
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
openYomitanSettingsWindow: (params: {
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
yomitanSession?: Session | null;
onWindowClosed?: () => void;
}) => void;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
}
export interface YomitanRuntimeBootstrap {
yomitan: YomitanRuntime;
yomitanProfilePolicy: ReturnType<typeof createYomitanProfilePolicy>;
}
export function createYomitanRuntimeBootstrap(
input: YomitanRuntimeBootstrapInput,
): YomitanRuntimeBootstrap {
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: input.getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => input.logInfo(message),
});
const yomitan = createYomitanRuntime({
userDataPath: input.userDataPath,
externalProfilePath: yomitanProfilePolicy.externalProfilePath,
loadYomitanExtensionCore: input.loadYomitanExtensionCore,
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
input.appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise) => {
input.appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise) => {
input.appState.yomitanParserInitPromise = promise;
},
getYomitanExtension: () => input.appState.yomitanExt,
setYomitanExtension: (extension) => {
input.appState.yomitanExt = extension;
},
getYomitanSession: () => input.appState.yomitanSession,
setYomitanSession: (session) => {
input.appState.yomitanSession = session;
},
getLoadInFlight: () => input.getLoadInFlight(),
setLoadInFlight: (promise) => {
input.setLoadInFlight(promise);
},
getResolvedConfig: () => input.getResolvedConfig(),
openYomitanSettingsWindow: (params) =>
input.openYomitanSettingsWindow({
yomitanExt: params.yomitanExt,
getExistingWindow: params.getExistingWindow,
setWindow: params.setWindow,
yomitanSession: params.yomitanSession,
onWindowClosed: params.onWindowClosed,
}),
getExistingSettingsWindow: () => input.appState.yomitanSettingsWindow,
setSettingsWindow: (window) => {
input.appState.yomitanSettingsWindow = window;
},
logInfo: (message) => input.logInfo(message),
logWarn: (message) => input.logWarn(message),
logError: (message, error) => input.logError(message, error),
showMpvOsd: (message) => input.showMpvOsd(message),
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
});
return {
yomitan,
yomitanProfilePolicy,
};
}

202
src/main/yomitan-runtime.ts Normal file
View File

@@ -0,0 +1,202 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import { clearYomitanParserCachesForWindow, syncYomitanDefaultAnkiServer } from '../core/services';
import type { YomitanExtensionLoaderDeps } from '../core/services/yomitan-extension-loader';
import type { ResolvedConfig } from '../types';
import { createYomitanExtensionRuntime } from './runtime/yomitan-extension-runtime';
import { createYomitanProfilePolicy } from './runtime/yomitan-profile-policy';
import { createYomitanSettingsRuntime } from './runtime/yomitan-settings-runtime';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './runtime/yomitan-anki-server';
export interface YomitanParserRuntimeDeps {
getYomitanExt: () => Extension | null;
getYomitanSession: () => Session | null;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
}
export interface YomitanRuntimeInput {
userDataPath: string;
externalProfilePath: string;
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
getYomitanParserWindow: () => BrowserWindow | null;
setYomitanParserWindow: (window: BrowserWindow | null) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
getYomitanExtension: () => Extension | null;
setYomitanExtension: (extension: Extension | null) => void;
getYomitanSession: () => Session | null;
setYomitanSession: (session: Session | null) => void;
getLoadInFlight: () => Promise<Extension | null> | null;
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
getResolvedConfig: () => ResolvedConfig;
openYomitanSettingsWindow: (params: {
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
yomitanSession?: Session | null;
onWindowClosed?: () => void;
}) => void;
getExistingSettingsWindow: () => BrowserWindow | null;
setSettingsWindow: (window: BrowserWindow | null) => void;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
}
export interface YomitanRuntime {
loadYomitanExtension: () => Promise<Extension | null>;
ensureYomitanExtensionLoaded: () => Promise<Extension | null>;
openYomitanSettings: () => boolean;
syncDefaultProfileAnkiServer: () => Promise<void>;
getParserRuntimeDeps: () => YomitanParserRuntimeDeps;
getPreferredAnkiServerUrl: () => string;
isExternalReadOnlyMode: () => boolean;
isCharacterDictionaryEnabled: () => boolean;
getCharacterDictionaryDisabledReason: () => string | null;
clearParserCachesForWindow: (window: BrowserWindow) => void;
}
export function createYomitanRuntime(input: YomitanRuntimeInput): YomitanRuntime {
const profilePolicy = createYomitanProfilePolicy({
externalProfilePath: input.externalProfilePath,
logInfo: (message) => input.logInfo(message),
});
const extensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: input.loadYomitanExtensionCore,
userDataPath: input.userDataPath,
externalProfilePath: profilePolicy.externalProfilePath,
getYomitanParserWindow: () => input.getYomitanParserWindow(),
setYomitanParserWindow: (window) => input.setYomitanParserWindow(window),
setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise),
setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise),
setYomitanExtension: (extension) => input.setYomitanExtension(extension),
setYomitanSession: (session) => input.setYomitanSession(session),
getYomitanExtension: () => input.getYomitanExtension(),
getLoadInFlight: () => input.getLoadInFlight(),
setLoadInFlight: (promise) => input.setLoadInFlight(promise),
});
const settingsRuntime = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: (params) =>
input.openYomitanSettingsWindow({
yomitanExt: params.yomitanExt as Extension | null,
getExistingWindow: () => params.getExistingWindow() as BrowserWindow | null,
setWindow: (window) => params.setWindow(window),
yomitanSession: (params.yomitanSession as Session | null | undefined) ?? null,
onWindowClosed: () => {
input.setSettingsWindow(null);
params.onWindowClosed?.();
},
}),
getExistingWindow: () => input.getExistingSettingsWindow(),
setWindow: (window) => input.setSettingsWindow(window as BrowserWindow | null),
getYomitanSession: () => input.getYomitanSession(),
logWarn: (message) => input.logWarn(message),
logError: (message, error) => input.logError(message, error),
});
let lastSyncedYomitanAnkiServer: string | null = null;
const getParserRuntimeDeps = (): YomitanParserRuntimeDeps => ({
getYomitanExt: () => input.getYomitanExtension(),
getYomitanSession: () => input.getYomitanSession(),
getYomitanParserWindow: () => input.getYomitanParserWindow(),
setYomitanParserWindow: (window) => input.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => input.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (promise) => input.setYomitanParserReadyPromise(promise),
getYomitanParserInitPromise: () => input.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (promise) => input.setYomitanParserInitPromise(promise),
});
const syncDefaultProfileAnkiServer = async (): Promise<void> => {
if (profilePolicy.isExternalReadOnlyMode()) {
return;
}
const targetUrl = getPreferredYomitanAnkiServerUrl(
input.getResolvedConfig().ankiConnect,
).trim();
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
return;
}
const synced = await syncYomitanDefaultAnkiServer(
targetUrl,
getParserRuntimeDeps(),
{
error: (message, ...args) => {
input.logError(message, args[0]);
},
info: (message, ...args) => {
input.logInfo([message, ...args].join(' '));
},
},
{
forceOverride: shouldForceOverrideYomitanAnkiServer(input.getResolvedConfig().ankiConnect),
},
);
if (synced) {
lastSyncedYomitanAnkiServer = targetUrl;
}
};
const loadYomitanExtension = async (): Promise<Extension | null> => {
const extension = await extensionRuntime.loadYomitanExtension();
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
await syncDefaultProfileAnkiServer();
}
return extension;
};
const ensureYomitanExtensionLoaded = async (): Promise<Extension | null> => {
const extension = await extensionRuntime.ensureYomitanExtensionLoaded();
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
await syncDefaultProfileAnkiServer();
}
return extension;
};
const openYomitanSettings = (): boolean => {
if (profilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
input.logWarn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
);
input.showDesktopNotification('SubMiner', { body: message });
input.showMpvOsd(message);
return false;
}
settingsRuntime.openYomitanSettings();
return true;
};
return {
loadYomitanExtension,
ensureYomitanExtensionLoaded,
openYomitanSettings,
syncDefaultProfileAnkiServer,
getParserRuntimeDeps,
getPreferredAnkiServerUrl: () =>
getPreferredYomitanAnkiServerUrl(input.getResolvedConfig().ankiConnect),
isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(),
isCharacterDictionaryEnabled: () => profilePolicy.isCharacterDictionaryEnabled(),
getCharacterDictionaryDisabledReason: () =>
profilePolicy.getCharacterDictionaryDisabledReason(),
clearParserCachesForWindow: (window) => clearYomitanParserCachesForWindow(window),
};
}

View File

@@ -0,0 +1,346 @@
import os from 'node:os';
import path from 'node:path';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { WindowGeometry } from '../types';
import type { YoutubeRuntimeInput } from './youtube-runtime';
import { createWaitForMpvConnectedHandler } from './runtime/jellyfin-remote-connection';
import { createPrepareYoutubePlaybackInMpvHandler } from './runtime/youtube-playback-launch';
import { openYoutubeTrackPicker } from './runtime/youtube-picker-open';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './runtime/windows-mpv-launch';
type MpvClientLike = {
connected: boolean;
currentVideoPath?: string | null;
connect: () => void;
requestProperty: (name: string) => Promise<unknown>;
send: (payload: { command: Array<string | boolean> }) => void;
};
type AnkiIntegrationLike = {
waitUntilReady: () => Promise<void>;
};
type WindowTrackerLike = {
getGeometry: () => WindowGeometry | null;
isTargetWindowFocused: () => boolean;
isTracking: () => boolean;
};
type OverlayMainWindowLike = {
isDestroyed: () => boolean;
isFocused: () => boolean;
focus: () => void;
setIgnoreMouseEvents: (ignore: boolean) => void;
webContents: {
isFocused: () => boolean;
focus: () => void;
};
};
type OverlayUiLike = {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
};
type OverlayGeometryLike = {
geometryMatches: (left: WindowGeometry | null, right: WindowGeometry | null) => boolean;
getLastOverlayWindowGeometry: () => WindowGeometry | null;
};
type SubtitleRuntimeLike = {
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
};
type TokenizationGateLike = {
waitUntilReady: (mediaPath: string | null) => Promise<void>;
};
export interface YoutubeRuntimeBootstrapInput {
appState: {
getMpvClient: () => MpvClientLike | null;
getCurrentMediaPath: () => string | null;
getPlaybackPaused: () => boolean | null;
getWindowTracker: () => WindowTrackerLike | null;
getAnkiIntegration: () => AnkiIntegrationLike | null;
};
overlay: {
getOverlayUi: () => OverlayUiLike | null;
getMainWindow: () => OverlayMainWindowLike | null;
getOverlayGeometry: () => OverlayGeometryLike;
broadcastYoutubePickerCancel: () => void;
};
getSubtitle: () => SubtitleRuntimeLike;
tokenization: {
startTokenizationWarmups: () => Promise<void>;
getGate: () => TokenizationGateLike;
};
appReady: {
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
};
services: {
probeYoutubeTracks: YoutubeRuntimeInput['flow']['probeYoutubeTracks'];
acquireYoutubeSubtitleTrack: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTrack'];
acquireYoutubeSubtitleTracks: YoutubeRuntimeInput['flow']['acquireYoutubeSubtitleTracks'];
resolveYoutubePlaybackUrl: YoutubeRuntimeInput['playback']['resolveYoutubePlaybackUrl'];
sendMpvCommand: YoutubeRuntimeInput['flow']['sendMpvCommand'];
showMpvOsd: YoutubeRuntimeInput['showMpvOsd'];
showDesktopNotification: YoutubeRuntimeInput['showDesktopNotification'];
showErrorBox: (title: string, content: string) => void;
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
logDebug: (message: string) => void;
};
config: {
platform: NodeJS.Platform;
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
logPath: string;
getSocketPath: () => string;
getNotificationType: () => YoutubeRuntimeInput['getNotificationType'] extends () => infer T
? T
: string;
getPrimarySubtitleLanguages: () => string[];
};
}
export function createYoutubeRuntimeInput(
input: YoutubeRuntimeBootstrapInput,
): YoutubeRuntimeInput {
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const client = input.appState.getMpvClient();
if (!client) return null;
const value = await client.requestProperty('path').catch(() => null);
return typeof value === 'string' ? value : null;
},
requestProperty: async (name) => {
const client = input.appState.getMpvClient();
if (!client) return null;
return await client.requestProperty(name);
},
sendMpvCommand: (command) => {
input.services.sendMpvCommand(command);
},
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => input.appState.getMpvClient(),
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
});
return {
flow: {
probeYoutubeTracks: (url) => input.services.probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (request) => input.services.acquireYoutubeSubtitleTrack(request),
acquireYoutubeSubtitleTracks: (request) =>
input.services.acquireYoutubeSubtitleTracks(request),
openPicker: async (payload) =>
await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
input.overlay
.getOverlayUi()
?.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions) ?? false,
waitForModalOpen: (modal, timeoutMs) =>
input.overlay.getOverlayUi()?.waitForModalOpen(modal, timeoutMs) ??
Promise.resolve(false),
logWarn: (message) => input.services.logWarn(message),
},
payload,
),
pauseMpv: () => {
input.services.sendMpvCommand(['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
input.services.sendMpvCommand(['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
input.services.sendMpvCommand(command);
},
requestMpvProperty: async (name) => {
const client = input.appState.getMpvClient();
if (!client) return null;
return await client.requestProperty(name);
},
refreshCurrentSubtitle: (text) => {
input.getSubtitle().refreshCurrentSubtitle(text);
},
refreshSubtitleSidebarSource: async (sourcePath) => {
await input.getSubtitle().refreshSubtitleSidebarFromSource(sourcePath);
},
startTokenizationWarmups: async () => {
await input.tokenization.startTokenizationWarmups();
},
waitForTokenizationReady: async () => {
const currentMediaPath =
input.appState.getCurrentMediaPath()?.trim() ||
input.appState.getMpvClient()?.currentVideoPath?.trim() ||
null;
await input.tokenization.getGate().waitUntilReady(currentMediaPath);
},
waitForAnkiReady: async () => {
const integration = input.appState.getAnkiIntegration();
if (!integration) {
return;
}
try {
await Promise.race([
integration.waitUntilReady(),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('Timed out waiting for AnkiConnect integration')),
2500,
);
}),
]);
} catch (error) {
input.services.logWarn(
'Continuing YouTube playback before AnkiConnect integration reported ready:',
error instanceof Error ? error.message : String(error),
);
}
},
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
waitForPlaybackWindowReady: async () => {
const deadline = Date.now() + 4000;
let stableGeometry: WindowGeometry | null = null;
let stableSinceMs = 0;
while (Date.now() < deadline) {
const tracker = input.appState.getWindowTracker();
const trackerGeometry = tracker?.getGeometry() ?? null;
const mediaPath =
input.appState.getCurrentMediaPath()?.trim() ||
input.appState.getMpvClient()?.currentVideoPath?.trim() ||
'';
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
if (
!input.overlay.getOverlayGeometry().geometryMatches(stableGeometry, trackerGeometry)
) {
stableGeometry = trackerGeometry;
stableSinceMs = Date.now();
} else if (Date.now() - stableSinceMs >= 200) {
return;
}
} else {
stableGeometry = null;
stableSinceMs = 0;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
input.services.logWarn(
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
);
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const trackerGeometry = input.appState.getWindowTracker()?.getGeometry() ?? null;
if (
trackerGeometry &&
input.overlay
.getOverlayGeometry()
.geometryMatches(
input.overlay.getOverlayGeometry().getLastOverlayWindowGeometry(),
trackerGeometry,
)
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
input.services.logWarn(
'Timed out waiting for overlay geometry to match tracked playback window.',
);
},
focusOverlayWindow: () => {
const mainWindow = input.overlay.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.setIgnoreMouseEvents(false);
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
if (!mainWindow.webContents.isFocused()) {
mainWindow.webContents.focus();
}
},
showMpvOsd: (text) => input.services.showMpvOsd(text),
warn: (message) => input.services.logWarn(message),
log: (message) => input.services.logInfo(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
},
playback: {
platform: input.config.platform,
directPlaybackFormat: input.config.directPlaybackFormat,
mpvYtdlFormat: input.config.mpvYtdlFormat,
autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs,
connectTimeoutMs: input.config.connectTimeoutMs,
getSocketPath: () => input.config.getSocketPath(),
getMpvConnected: () => Boolean(input.appState.getMpvClient()?.connected),
ensureYoutubePlaybackRuntimeReady: async () => {
await input.appReady.ensureYoutubePlaybackRuntimeReady();
},
resolveYoutubePlaybackUrl: (url, format) =>
input.services.resolveYoutubePlaybackUrl(url, format),
launchWindowsMpv: (playbackUrl, args) =>
launchWindowsMpv(
[playbackUrl],
createWindowsMpvLaunchDeps({
showError: (title, content) => input.services.showErrorBox(title, content),
}),
[...args, `--log-file=${input.config.logPath}`],
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
logInfo: (message) => input.services.logInfo(message),
logWarn: (message) => input.services.logWarn(message),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
},
autoplay: {
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null,
getPlaybackPaused: () => input.appState.getPlaybackPaused(),
getMpvClient: () => input.appState.getMpvClient(),
signalPluginAutoplayReady: () => {
input.services.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
logDebug: (message) => input.services.logDebug(message),
},
notification: {
getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(),
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearSchedule: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
},
getNotificationType: () => input.config.getNotificationType(),
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
getCurrentVideoPath: () => input.appState.getMpvClient()?.currentVideoPath ?? null,
showMpvOsd: (message) => input.services.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.services.showDesktopNotification(title, options),
broadcastYoutubePickerCancel: () => {
input.overlay.broadcastYoutubePickerCancel();
},
closeYoutubePickerModal: () => {
input.overlay.getOverlayUi()?.handleOverlayModalClosed('youtube-track-picker');
},
logWarn: (message) => input.services.logWarn(message),
};
}

View File

@@ -0,0 +1,238 @@
import { IPC_CHANNELS } from '../shared/ipc/contracts';
import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from '../core/services/youtube/generate';
import { resolveYoutubePlaybackUrl } from '../core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from '../core/services/youtube/track-probe';
import type { ResolvedConfig } from '../types';
import type { AppState } from './state';
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
import type { OverlayUiRuntime } from './overlay-ui-runtime';
import type { SubtitleRuntime } from './subtitle-runtime';
import { createYoutubeRuntime } from './youtube-runtime';
import { createYoutubeRuntimeInput } from './youtube-runtime-bootstrap';
export interface YoutubeRuntimeCoordinatorInput {
appState: {
getMpvClient: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['appState']['getMpvClient'] extends () => infer T
? T
: never;
getCurrentMediaPath: () => string | null;
getPlaybackPaused: () => boolean | null;
getWindowTracker: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['appState']['getWindowTracker'] extends () => infer T
? T
: never;
getAnkiIntegration: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['appState']['getAnkiIntegration'] extends () => infer T
? T
: never;
getSocketPath: () => string;
};
overlay: {
getOverlayUi: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['overlay']['getOverlayUi'] extends () => infer T
? T
: never;
getMainWindow: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['overlay']['getMainWindow'] extends () => infer T
? T
: never;
getOverlayGeometry: () => Parameters<
typeof createYoutubeRuntimeInput
>[0]['overlay']['getOverlayGeometry'] extends () => infer T
? T
: never;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
};
subtitle: {
getSubtitle: Parameters<typeof createYoutubeRuntimeInput>[0]['getSubtitle'];
};
tokenization: {
startTokenizationWarmups: () => Promise<void>;
getGate: Parameters<typeof createYoutubeRuntimeInput>[0]['tokenization']['getGate'];
};
appReady: {
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
};
services: {
sendMpvCommand: (command: (string | number)[]) => void;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, content: string) => void;
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
logDebug: (message: string) => void;
};
config: {
platform: NodeJS.Platform;
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
logPath: string;
getNotificationType: () => string;
getPrimarySubtitleLanguages: () => string[];
};
}
export function createYoutubeRuntimeCoordinator(input: YoutubeRuntimeCoordinatorInput) {
return createYoutubeRuntime(
createYoutubeRuntimeInput({
appState: {
getMpvClient: () => input.appState.getMpvClient(),
getCurrentMediaPath: () => input.appState.getCurrentMediaPath(),
getPlaybackPaused: () => input.appState.getPlaybackPaused(),
getWindowTracker: () => input.appState.getWindowTracker(),
getAnkiIntegration: () => input.appState.getAnkiIntegration(),
},
overlay: {
getOverlayUi: () => input.overlay.getOverlayUi(),
getMainWindow: () => input.overlay.getMainWindow(),
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
broadcastYoutubePickerCancel: () => {
input.overlay.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
},
},
getSubtitle: () => input.subtitle.getSubtitle(),
tokenization: {
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
getGate: () => input.tokenization.getGate(),
},
appReady: {
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
},
services: {
probeYoutubeTracks: (url) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (request) => acquireYoutubeSubtitleTrack(request),
acquireYoutubeSubtitleTracks: (request) => acquireYoutubeSubtitleTracks(request),
resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format),
sendMpvCommand: (command) => input.services.sendMpvCommand(command),
showMpvOsd: (message) => input.services.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.services.showDesktopNotification(title, options),
showErrorBox: (title, content) => input.services.showErrorBox(title, content),
logInfo: (message) => input.services.logInfo(message),
logWarn: (message, error) => input.services.logWarn(message, error),
logDebug: (message) => input.services.logDebug(message),
},
config: {
platform: input.config.platform,
directPlaybackFormat: input.config.directPlaybackFormat,
mpvYtdlFormat: input.config.mpvYtdlFormat,
autoLaunchTimeoutMs: input.config.autoLaunchTimeoutMs,
connectTimeoutMs: input.config.connectTimeoutMs,
logPath: input.config.logPath,
getSocketPath: () => input.appState.getSocketPath(),
getNotificationType: () => input.config.getNotificationType(),
getPrimarySubtitleLanguages: () => input.config.getPrimarySubtitleLanguages(),
},
}),
);
}
export interface YoutubeRuntimeFromMainStateInput {
platform: NodeJS.Platform;
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
logPath: string;
appState: Pick<
AppState,
| 'mpvClient'
| 'currentMediaPath'
| 'playbackPaused'
| 'windowTracker'
| 'ankiIntegration'
| 'mpvSocketPath'
>;
overlay: {
getOverlayUi: () => OverlayUiRuntime<Electron.BrowserWindow> | null;
getMainWindow: () => Electron.BrowserWindow | null;
getOverlayGeometry: () => OverlayGeometryRuntime<Electron.BrowserWindow>;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
};
subtitle: {
getSubtitle: () => SubtitleRuntime;
};
tokenization: {
startTokenizationWarmups: () => Promise<void>;
getGate: Parameters<typeof createYoutubeRuntimeInput>[0]['tokenization']['getGate'];
};
appReady: {
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
};
getResolvedConfig: () => ResolvedConfig;
notifications: {
showDesktopNotification: (title: string, options: { body?: string }) => void;
showErrorBox: (title: string, content: string) => void;
};
mpv: {
sendMpvCommand: (command: (string | number)[]) => void;
showMpvOsd: (message: string) => void;
};
logger: {
info: (message: string) => void;
warn: (message: string, error?: unknown) => void;
debug: (message: string) => void;
};
}
export function createYoutubeRuntimeFromMainState(input: YoutubeRuntimeFromMainStateInput) {
return createYoutubeRuntimeCoordinator({
appState: {
getMpvClient: () => input.appState.mpvClient,
getCurrentMediaPath: () => input.appState.currentMediaPath,
getPlaybackPaused: () => input.appState.playbackPaused,
getWindowTracker: () => input.appState.windowTracker,
getAnkiIntegration: () => input.appState.ankiIntegration,
getSocketPath: () => input.appState.mpvSocketPath,
},
overlay: {
getOverlayUi: () => input.overlay.getOverlayUi(),
getMainWindow: () => input.overlay.getMainWindow(),
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
broadcastToOverlayWindows: (channel, payload) => {
input.overlay.broadcastToOverlayWindows(channel, payload);
},
},
subtitle: {
getSubtitle: () => input.subtitle.getSubtitle(),
},
tokenization: {
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
getGate: () => input.tokenization.getGate(),
},
appReady: {
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
},
services: {
sendMpvCommand: (command) => input.mpv.sendMpvCommand(command),
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
showDesktopNotification: (title, options) =>
input.notifications.showDesktopNotification(title, options),
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
logInfo: (message) => input.logger.info(message),
logWarn: (message, error) => input.logger.warn(message, error),
logDebug: (message) => input.logger.debug(message),
},
config: {
platform: input.platform,
directPlaybackFormat: input.directPlaybackFormat,
mpvYtdlFormat: input.mpvYtdlFormat,
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
connectTimeoutMs: input.connectTimeoutMs,
logPath: input.logPath,
getNotificationType: () => input.getResolvedConfig().ankiConnect.behavior.notificationType,
getPrimarySubtitleLanguages: () => input.getResolvedConfig().youtube.primarySubLanguages,
},
});
}

View File

@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createYoutubeRuntime } from './youtube-runtime';
function createRuntime(overrides: Partial<Parameters<typeof createYoutubeRuntime>[0]> = {}) {
const calls: string[] = [];
const runtime = createYoutubeRuntime({
flow: {
probeYoutubeTracks: async () => ({ videoId: 'demo', title: 'Demo', tracks: [] }),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/primary.vtt' }),
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
openPicker: async () => true,
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: (message) => {
calls.push(`flow-osd:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
log: (message) => {
calls.push(`log:${message}`);
},
getYoutubeOutputDir: () => '/tmp',
},
playback: {
platform: 'linux',
directPlaybackFormat: 'b',
mpvYtdlFormat: 'best',
autoLaunchTimeoutMs: 1000,
connectTimeoutMs: 1000,
getSocketPath: () => '/tmp/mpv.sock',
getMpvConnected: () => true,
ensureYoutubePlaybackRuntimeReady: async () => {},
resolveYoutubePlaybackUrl: async (url) => url,
launchWindowsMpv: () => ({ ok: false }),
waitForYoutubeMpvConnected: async () => true,
prepareYoutubePlaybackInMpv: async () => true,
logInfo: () => {},
logWarn: () => {},
schedule: (callback) => setTimeout(callback, 0),
clearScheduled: (timer) => clearTimeout(timer),
},
autoplay: {
getCurrentMediaPath: () => null,
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () => null,
signalPluginAutoplayReady: () => {
calls.push('autoplay-ready');
},
schedule: (callback) => setTimeout(callback, 0),
logDebug: () => {},
},
notification: {
getPrimarySubtitleLanguages: () => ['ja'],
schedule: (callback) => setTimeout(callback, 0),
clearSchedule: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
},
getNotificationType: () => 'osd',
getCurrentMediaPath: () => null,
getCurrentVideoPath: () => null,
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (_title, options) => {
calls.push(`notify:${options.body}`);
},
broadcastYoutubePickerCancel: () => {
calls.push('picker-cancel');
},
closeYoutubePickerModal: () => {
calls.push('close-modal');
},
logWarn: (message) => {
calls.push(`warn:${message}`);
},
...overrides,
});
return {
runtime,
calls,
};
}
test('youtube runtime gates manual picker availability by playback context', async () => {
const inactive = createRuntime({
getCurrentMediaPath: () => '/tmp/video.mkv',
getCurrentVideoPath: () => null,
});
await inactive.runtime.openYoutubeTrackPickerFromPlayback();
assert.ok(
inactive.calls.includes(
'osd:YouTube subtitle picker is only available during YouTube playback.',
),
);
const active = createRuntime({
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=demo',
getCurrentVideoPath: () => null,
createFlowRuntime: () => ({
runYoutubePlaybackFlow: async () => {},
openManualPicker: async ({ url }) => {
active.calls.push(`manual-picker:${url}`);
},
resolveActivePicker: async () => ({ ok: true, message: 'resolved' }),
cancelActivePicker: () => true,
hasActiveSession: () => false,
}),
});
await active.runtime.openYoutubeTrackPickerFromPlayback();
assert.ok(active.calls.includes('manual-picker:https://www.youtube.com/watch?v=demo'));
});
test('youtube runtime cancels active picker on mpv disconnect', () => {
const harness = createRuntime({
createFlowRuntime: () => ({
runYoutubePlaybackFlow: async () => {},
openManualPicker: async () => {},
resolveActivePicker: async () => ({ ok: true, message: 'resolved' }),
cancelActivePicker: () => {
harness.calls.push('cancel-active');
return true;
},
hasActiveSession: () => true,
}),
});
harness.runtime.handleMpvConnectionChange(false);
assert.deepEqual(harness.calls, ['cancel-active', 'picker-cancel', 'close-modal']);
});
test('youtube runtime delegates picker resolution to flow runtime', async () => {
const harness = createRuntime({
createFlowRuntime: () => ({
runYoutubePlaybackFlow: async () => {},
openManualPicker: async () => {},
resolveActivePicker: async (request) => ({ request, ok: true, message: 'resolved' }),
cancelActivePicker: () => true,
hasActiveSession: () => false,
}),
});
const request = {
sessionId: 'session-1',
action: 'use-selected' as const,
primaryTrackId: 'ja',
secondaryTrackId: null,
};
const result = await harness.runtime.resolveActivePicker(request);
assert.deepEqual(result, { request, ok: true, message: 'resolved' });
});
test('youtube runtime routes subtitle failures through configured notification channels', () => {
const harness = createRuntime({
getNotificationType: () => 'both',
});
harness.runtime.reportYoutubeSubtitleFailure('Primary subtitles failed');
assert.ok(harness.calls.includes('osd:Primary subtitles failed'));
assert.ok(harness.calls.includes('notify:Primary subtitles failed'));
});

304
src/main/youtube-runtime.ts Normal file
View File

@@ -0,0 +1,304 @@
import type { CliArgs, CliCommandSource } from '../cli/args';
import type {
SubtitleData,
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../types';
import type {
YoutubeTrackOption,
YoutubeTrackProbeResult,
} from '../core/services/youtube/track-probe';
import { createAutoplayReadyGate } from './runtime/autoplay-ready-gate';
import { createYoutubeFlowRuntime } from './runtime/youtube-flow';
import { createYoutubePlaybackRuntime } from './runtime/youtube-playback-runtime';
import {
clearYoutubePrimarySubtitleNotificationTimer,
createYoutubePrimarySubtitleNotificationRuntime,
} from './runtime/youtube-primary-subtitle-notification';
import { isYoutubePlaybackActive } from './runtime/youtube-playback';
type YoutubeFlowRuntimeLike = {
runYoutubePlaybackFlow: (request: {
url: string;
mode: 'download' | 'generate';
}) => Promise<void>;
openManualPicker: (request: { url: string }) => Promise<void>;
resolveActivePicker: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
cancelActivePicker: () => boolean;
hasActiveSession: () => boolean;
};
type YoutubePlaybackRuntimeLike = {
clearYoutubePlayQuitOnDisconnectArmTimer: () => void;
getQuitOnDisconnectArmed: () => boolean;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
};
type YoutubeAutoplayGateLike = {
getAutoPlayReadySignalMediaPath: () => string | null;
invalidatePendingAutoplayReadyFallbacks: () => void;
maybeSignalPluginAutoplayReady: (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
) => void;
};
type YoutubePrimarySubtitleNotificationRuntimeLike = {
handleMediaPathChange: (path: string | null) => void;
handleSubtitleTrackChange: (sid: number | null) => void;
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
setAppOwnedFlowInFlight: (inFlight: boolean) => void;
isAppOwnedFlowInFlight: () => boolean;
};
export interface YoutubeFlowRuntimeInput {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
acquireYoutubeSubtitleTrack: (input: {
targetUrl: string;
outputDir: string;
track: YoutubeTrackOption;
}) => Promise<{ path: string }>;
acquireYoutubeSubtitleTracks: (input: {
targetUrl: string;
outputDir: string;
tracks: YoutubeTrackOption[];
}) => Promise<Map<string, string>>;
openPicker: (payload: YoutubePickerOpenPayload) => Promise<boolean>;
pauseMpv: () => void;
resumeMpv: () => void;
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
wait: (ms: number) => Promise<void>;
waitForPlaybackWindowReady: () => Promise<void>;
waitForOverlayGeometryReady: () => Promise<void>;
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
}
export interface YoutubePlaybackRuntimeInput {
platform: NodeJS.Platform;
directPlaybackFormat: string;
mpvYtdlFormat: string;
autoLaunchTimeoutMs: number;
connectTimeoutMs: number;
getSocketPath: () => string;
getMpvConnected: () => boolean;
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (
playbackUrl: string,
args: string[],
) => {
ok: boolean;
mpvPath?: string;
};
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
}
export interface YoutubeAutoplayRuntimeInput {
getCurrentMediaPath: () => string | null;
getCurrentVideoPath: () => string | null;
getPlaybackPaused: () => boolean | null;
getMpvClient: () => {
connected?: boolean;
requestProperty: (property: string) => Promise<unknown>;
send: (payload: { command: Array<string | boolean> }) => void;
} | null;
signalPluginAutoplayReady: () => void;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
}
export interface YoutubeNotificationRuntimeInput {
getPrimarySubtitleLanguages: () => string[];
schedule: (
callback: () => void,
delayMs: number,
) => ReturnType<typeof setTimeout> | { id: number };
clearSchedule: (timer: ReturnType<typeof setTimeout> | { id: number } | null) => void;
}
export interface YoutubeRuntimeInput {
flow: YoutubeFlowRuntimeInput;
playback: YoutubePlaybackRuntimeInput;
autoplay: YoutubeAutoplayRuntimeInput;
notification: YoutubeNotificationRuntimeInput;
getNotificationType: () => 'osd' | 'system' | 'both' | string;
getCurrentMediaPath: () => string | null;
getCurrentVideoPath: () => string | null;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
broadcastYoutubePickerCancel: () => void;
closeYoutubePickerModal: () => void;
logWarn: (message: string) => void;
createFlowRuntime?: (
input: YoutubeFlowRuntimeInput & { reportSubtitleFailure: (message: string) => void },
) => YoutubeFlowRuntimeLike;
createPlaybackRuntime?: (
input: YoutubePlaybackRuntimeInput & {
invalidatePendingAutoplayReadyFallbacks: () => void;
setAppOwnedFlowInFlight: (next: boolean) => void;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
}) => Promise<void>;
},
) => YoutubePlaybackRuntimeLike;
createAutoplayGate?: (
input: {
isAppOwnedFlowInFlight: () => boolean;
} & YoutubeAutoplayRuntimeInput,
) => YoutubeAutoplayGateLike;
createPrimarySubtitleNotificationRuntime?: (
input: {
notifyFailure: (message: string) => void;
} & YoutubeNotificationRuntimeInput,
) => YoutubePrimarySubtitleNotificationRuntimeLike;
}
export interface YoutubeRuntime {
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
openYoutubeTrackPickerFromPlayback: () => Promise<void>;
resolveActivePicker: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
handleMpvConnectionChange: (connected: boolean) => void;
reportYoutubeSubtitleFailure: (message: string) => void;
handleMediaPathChange: (path: string | null) => void;
handleSubtitleTrackChange: (sid: number | null) => void;
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
maybeSignalPluginAutoplayReady: (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
) => void;
invalidatePendingAutoplayReadyFallbacks: () => void;
getAutoPlayReadySignalMediaPath: () => string | null;
clearYoutubePlayQuitOnDisconnectArmTimer: () => void;
getQuitOnDisconnectArmed: () => boolean;
isAppOwnedFlowInFlight: () => boolean;
}
export function createYoutubeRuntime(input: YoutubeRuntimeInput): YoutubeRuntime {
const reportYoutubeSubtitleFailure = (message: string): void => {
const type = input.getNotificationType();
if (type === 'osd' || type === 'both') {
input.showMpvOsd(message);
}
if (type === 'system' || type === 'both') {
try {
input.showDesktopNotification('SubMiner', { body: message });
} catch {
input.logWarn(`Unable to show desktop notification: ${message}`);
}
}
};
const notificationRuntime = (
input.createPrimarySubtitleNotificationRuntime ??
((deps) => createYoutubePrimarySubtitleNotificationRuntime(deps))
)({
...input.notification,
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
});
const autoplayGate = (input.createAutoplayGate ?? ((deps) => createAutoplayReadyGate(deps)))({
...input.autoplay,
isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(),
});
const flowRuntime = (
input.createFlowRuntime ?? ((deps) => createYoutubeFlowRuntime(deps as never))
)({
...input.flow,
reportSubtitleFailure: (message) => reportYoutubeSubtitleFailure(message),
});
const playbackRuntime = (
input.createPlaybackRuntime ?? ((deps) => createYoutubePlaybackRuntime(deps))
)({
...input.playback,
invalidatePendingAutoplayReadyFallbacks: () =>
autoplayGate.invalidatePendingAutoplayReadyFallbacks(),
setAppOwnedFlowInFlight: (next) => {
notificationRuntime.setAppOwnedFlowInFlight(next);
},
runYoutubePlaybackFlow: (request) => flowRuntime.runYoutubePlaybackFlow(request),
});
const isYoutubePlaybackActiveNow = (): boolean =>
isYoutubePlaybackActive(input.getCurrentMediaPath(), input.getCurrentVideoPath());
const openYoutubeTrackPickerFromPlayback = async (): Promise<void> => {
if (flowRuntime.hasActiveSession()) {
input.showMpvOsd('YouTube subtitle flow already in progress.');
return;
}
const currentMediaPath =
input.getCurrentMediaPath()?.trim() || input.getCurrentVideoPath()?.trim() || '';
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
input.showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
return;
}
await flowRuntime.openManualPicker({
url: currentMediaPath,
});
};
const handleMpvConnectionChange = (connected: boolean): void => {
if (connected || !flowRuntime.hasActiveSession()) {
return;
}
flowRuntime.cancelActivePicker();
input.broadcastYoutubePickerCancel();
input.closeYoutubePickerModal();
};
return {
runYoutubePlaybackFlow: (request) => playbackRuntime.runYoutubePlaybackFlow(request),
openYoutubeTrackPickerFromPlayback,
resolveActivePicker: (request) => flowRuntime.resolveActivePicker(request),
handleMpvConnectionChange,
reportYoutubeSubtitleFailure,
handleMediaPathChange: (path) => notificationRuntime.handleMediaPathChange(path),
handleSubtitleTrackChange: (sid) => notificationRuntime.handleSubtitleTrackChange(sid),
handleSubtitleTrackListChange: (trackList) =>
notificationRuntime.handleSubtitleTrackListChange(trackList),
maybeSignalPluginAutoplayReady: (payload, options) =>
autoplayGate.maybeSignalPluginAutoplayReady(payload, options),
invalidatePendingAutoplayReadyFallbacks: () =>
autoplayGate.invalidatePendingAutoplayReadyFallbacks(),
getAutoPlayReadySignalMediaPath: () => autoplayGate.getAutoPlayReadySignalMediaPath(),
clearYoutubePlayQuitOnDisconnectArmTimer: () =>
playbackRuntime.clearYoutubePlayQuitOnDisconnectArmTimer(),
getQuitOnDisconnectArmed: () => playbackRuntime.getQuitOnDisconnectArmed(),
isAppOwnedFlowInFlight: () => notificationRuntime.isAppOwnedFlowInFlight(),
};
}
export { clearYoutubePrimarySubtitleNotificationTimer };