refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311)

This commit is contained in:
sudacode authored and GitHub committed 2026-11-01 17:59:52 -08:00
1 parent 655510cf2e
commit 696462a82e
33 files changed
+3719 -2101

No files matched your search

+33
View File
@@ -29,6 +29,7 @@ import {
} from '../runtime/domains/anilist';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createCreateAnilistSetupWindowHandler } from '../runtime/setup-window-factory';
import type { CliSlice, IpcSlice } from './slices';
import {
type AnilistMediaGuessRuntimeState,
type AppState,
@@ -507,7 +508,39 @@ export function createAnilistAssembly(deps: AnilistAssemblyDeps) {
);
};
const ipc: IpcSlice<
| 'getAnilistStatus'
| 'clearAnilistToken'
| 'openAnilistSetup'
| 'getAnilistQueueStatus'
| 'retryAnilistQueueNow'
| 'runAnilistPostWatchUpdateOnManualMark'
> = {
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetup: () => openAnilistSetupWindowHandler(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
};
const cli: CliSlice<
| 'getAnilistStatus'
| 'clearAnilistToken'
| 'openAnilistSetupWindow'
| 'getAnilistQueueStatus'
| 'processNextAnilistRetryUpdate'
> = {
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
clearAnilistToken: () => anilistStateRuntime.clearTokenState(),
openAnilistSetupWindow: () => openAnilistSetupWindowHandler(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
};
return {
ipc,
cli,
stateRuntime: anilistStateRuntime,
openSetupWindow: openAnilistSetupWindowHandler,
handleAnilistSetupProtocolUrl,
@@ -38,6 +38,7 @@ function makeDeps(): CharacterDictionaryAssemblyDeps {
refreshCurrentSubtitle: lazy('refreshCurrentSubtitle'),
refreshSubtitlePrefetch: lazy('refreshSubtitlePrefetch'),
refreshCurrentSubtitleAnnotations: lazy('refreshCurrentSubtitleAnnotations'),
resetAnilistMediaGuessState: lazy('resetAnilistMediaGuessState'),
};
}
@@ -46,3 +47,21 @@ test('createCharacterDictionaryAssembly does not call its deps while constructin
const characterDictionary = createCharacterDictionaryAssembly(makeDeps());
assert.equal(characterDictionary.autoSync.getCurrentMediaId(), null);
});
test('character dictionary generation from the CLI refuses while the dictionary is disabled', async () => {
const deps = makeDeps();
const characterDictionary = createCharacterDictionaryAssembly({
...deps,
getYomitanProfilePolicy: () => ({
isCharacterDictionaryEnabled: () => false,
getCharacterDictionaryDisabledReason: () => 'external profile is read-only',
isExternalReadOnlyMode: () => true,
logSkippedWrite: () => {},
}),
});
await assert.rejects(
characterDictionary.cli.generateCharacterDictionary(),
/external profile is read-only/,
);
});
+105 -3
View File
@@ -11,7 +11,14 @@ import type { ResolvedConfig } from '../../types';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { createCharacterDictionaryImageLookup } from '../character-dictionary-runtime/image-lookup';
import { createCharacterNameCandidateLookup } from '../character-dictionary-runtime/name-candidates';
import { createCharacterDictionaryAutoSyncRuntimeService } from '../runtime/character-dictionary-auto-sync';
import { applyCharacterDictionarySelection } from '../character-dictionary-selection';
import {
createCharacterDictionaryAutoSyncRuntimeService,
getCharacterDictionaryManagerSnapshot,
moveCharacterDictionaryManagedEntry,
removeCharacterDictionaryManagedEntry,
replaceCharacterDictionaryManagedEntry,
} from '../runtime/character-dictionary-auto-sync';
import { handleCharacterDictionaryAutoSyncComplete } from '../runtime/character-dictionary-auto-sync-completion';
import {
notifyCharacterDictionaryAutoSyncStatus,
@@ -22,6 +29,7 @@ import { assertYomitanDictionaryMutationSafe } from '../runtime/yomitan-dictiona
import type { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from '../runtime/yomitan-read-only-log';
import type { AppState } from '../state';
import type { CliSlice, IpcSlice } from './slices';
type YomitanParserRuntimeDeps = Parameters<typeof getYomitanDictionaryInfo>[0];
@@ -46,7 +54,10 @@ export interface CharacterDictionaryAssemblyDeps {
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
getYomitanProfilePolicy: () => Pick<
ReturnType<typeof createYomitanProfilePolicy>,
'isCharacterDictionaryEnabled' | 'isExternalReadOnlyMode' | 'logSkippedWrite'
| 'isCharacterDictionaryEnabled'
| 'getCharacterDictionaryDisabledReason'
| 'isExternalReadOnlyMode'
| 'logSkippedWrite'
>;
isYoutubePlaybackActive: () => boolean;
waitForMediaTokenizationReady: (mediaPath: string | null) => Promise<void>;
@@ -63,6 +74,8 @@ export interface CharacterDictionaryAssemblyDeps {
/** Re-runs subtitle prefetch from the current playback position. */
refreshSubtitlePrefetch: () => void;
refreshCurrentSubtitleAnnotations: () => void;
/** A manual selection invalidates AniList's guess for the current media. */
resetAnilistMediaGuessState: () => void;
}
export type CharacterDictionaryAssembly = ReturnType<typeof createCharacterDictionaryAssembly>;
@@ -243,5 +256,94 @@ export function createCharacterDictionaryAssembly(deps: CharacterDictionaryAssem
getCurrentMediaId: () => autoSync.getCurrentMediaId(),
});
return { runtime, autoSync, imageLookup, nameCandidates };
const selectionDeps = {
setManualSelection: (request: Parameters<typeof runtime.setManualSelection>[0]) =>
runtime.setManualSelection(request),
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
runSyncNow: () => autoSync.runSyncNow(),
warn: (message: string, error?: unknown) => logger.warn(message, error),
};
/** Manager edits that change the merged dictionary rebuild it; a failed rebuild is logged. */
async function rebuildAfterManagerChange<T extends { ok: boolean; rebuildRequired?: boolean }>(
result: T,
change: 'override' | 'removal' | 'reorder',
): Promise<T> {
if (result.ok && result.rebuildRequired) {
try {
await autoSync.runSyncNow();
} catch (error) {
logger.warn(`Failed to rebuild character dictionary after manager ${change}:`, error);
}
}
return result;
}
const ipc: IpcSlice<
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'getCharacterDictionaryManagerSnapshot'
| 'removeCharacterDictionaryManagedEntry'
| 'moveCharacterDictionaryManagedEntry'
> = {
getCharacterDictionarySelection: (searchTitle?: string) =>
runtime.getManualSelectionSnapshot(undefined, searchTitle),
setCharacterDictionarySelection: async (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) => {
if (replaceManagedMediaId !== undefined && mediaTitle) {
return rebuildAfterManagerChange(
replaceCharacterDictionaryManagedEntry(deps.userDataPath, replaceManagedMediaId, {
mediaId,
mediaTitle,
}),
'override',
);
}
return await applyCharacterDictionarySelection({ mediaId }, selectionDeps);
},
getCharacterDictionaryManagerSnapshot: async () =>
getCharacterDictionaryManagerSnapshot(deps.userDataPath, autoSync.getCurrentMediaId()),
removeCharacterDictionaryManagedEntry: async (mediaId: number) =>
rebuildAfterManagerChange(
removeCharacterDictionaryManagedEntry(
deps.userDataPath,
mediaId,
autoSync.getCurrentMediaId(),
),
'removal',
),
moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) =>
rebuildAfterManagerChange(
moveCharacterDictionaryManagedEntry(
deps.userDataPath,
mediaId,
direction,
autoSync.getCurrentMediaId(),
),
'reorder',
),
};
const cli: CliSlice<
| 'generateCharacterDictionary'
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
> = {
generateCharacterDictionary: async (targetPath?: string) => {
const disabledReason = deps.getYomitanProfilePolicy().getCharacterDictionaryDisabledReason();
if (disabledReason) {
throw new Error(disabledReason);
}
return await runtime.generateForCurrentMedia(targetPath);
},
getCharacterDictionarySelection: async (targetPath?: string) =>
runtime.getManualSelectionSnapshot(targetPath),
setCharacterDictionarySelection: async (request) =>
applyCharacterDictionarySelection(request, selectionDeps),
};
return { runtime, autoSync, imageLookup, nameCandidates, ipc, cli };
}
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleMiningContext } from '../../types';
import type { AppState } from '../state';
import { createMiningAssembly, type MiningAssemblyDeps } from './mining';
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
type MpvClientLike = NonNullable<AppState['mpvClient']>;
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<MiningAssemblyDeps> = {}): MiningAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
mpvClient: null,
currentMediaPath: null,
activeParsedSubtitleCues: [],
ankiIntegration: null,
subtitleTimingTracker: null,
fieldGroupingResolver: null,
fieldGroupingResolverSequence: 0,
},
getConfig: lazy('getConfig'),
logger: { warn: lazy('warn'), error: lazy('error') },
overlayManager: {
getMainWindow: lazy('getMainWindow'),
getModalWindow: lazy('getModalWindow'),
getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'),
},
overlayModalRuntime: {
getRestoreVisibleOverlayOnModalClose: lazy('getRestoreVisibleOverlayOnModalClose'),
waitForModalOpen: lazy('waitForModalOpen'),
handleOverlayModalClosed: lazy('handleOverlayModalClosed'),
sendToActiveOverlayWindow: lazy('sendToActiveOverlayWindow'),
},
youtube: {
getCachedMediaPathForCurrentPlayback: lazy('getCachedMediaPathForCurrentPlayback'),
shouldRequireMediaCacheForCurrentPlayback: lazy('shouldRequireMediaCacheForCurrentPlayback'),
},
clipboard: { readText: lazy('readText'), writeText: lazy('writeText') },
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayModals: lazy('getOverlayModals'),
setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'),
ensureOverlayStartupPrereqs: lazy('ensureOverlayStartupPrereqs'),
ensureOverlayWindowsReadyForVisibilityActions: lazy(
'ensureOverlayWindowsReadyForVisibilityActions',
),
ankiIntegrationCallbacks: {
recordCardsMined: lazy('recordCardsMined'),
getMiningProvenance: lazy('getMiningProvenance'),
onCardsReady: lazy('onCardsReady'),
onKnownWordCacheUpdated: lazy('onKnownWordCacheUpdated'),
generateSentenceFurigana: lazy('generateSentenceFurigana'),
},
...overrides,
};
}
const lookupContext: SubtitleMiningContext = {
source: 'overlay',
text: '今日はいい天気',
startTime: 3,
endTime: 5,
capturedAtMs: 1,
};
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createMiningAssembly does not call its deps while constructing', () => {
const mining = createMiningAssembly(makeDeps());
assert.equal(typeof mining.mineSentenceCard, 'function');
assert.equal(typeof mining.attachToAnkiIntegration, 'function');
});
test('a recorded lookup context reaches exactly one mined card', () => {
let consumeContext: (() => SubtitleMiningContext | null) | null = null;
const setters: string[] = [];
const integration = new Proxy(
{},
{
get: (_target, name: string) => (callback: unknown) => {
setters.push(name);
if (name === 'setSubtitleMiningContextConsumer') {
consumeContext = callback as () => SubtitleMiningContext | null;
}
},
},
) as AnkiIntegrationLike;
const mining = createMiningAssembly(makeDeps());
mining.attachToAnkiIntegration(integration);
mining.recordSubtitleMiningContext(lookupContext);
assert.equal(setters.length, 7);
assert.ok(consumeContext);
const consume = consumeContext as () => SubtitleMiningContext | null;
assert.deepEqual(consume(), lookupContext);
assert.equal(consume(), null);
});
test('mining without parsed cues falls back to the live mpv line on the video clock', () => {
const mpvClient = {
currentSubText: 'こんにちは',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0.5,
currentTimePos: 11,
} as MpvClientLike;
const mining = createMiningAssembly(
makeDeps({ appState: { ...makeDeps().appState, mpvClient } }),
);
const context = mining.captureCurrentPrimarySubtitleMiningContext();
assert.equal(context?.text, 'こんにちは');
assert.equal(context?.startTime, 10.5);
assert.equal(context?.endTime, 12.5);
});
+406
View File
@@ -0,0 +1,406 @@
import { resolveMediaGenerationInput } from '../../anki-integration/media-source';
import {
captureLiveSubtitleMiningContext,
copyCurrentSubtitle as copyCurrentSubtitleCore,
createFieldGroupingOverlayRuntime,
handleMineSentenceDigit as handleMineSentenceDigitCore,
handleMultiCopyDigit as handleMultiCopyDigitCore,
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
mineSentenceCard as mineSentenceCardCore,
shiftSubtitleRangeToVideoClock,
toSubtitleClock,
triggerFieldGrouping as triggerFieldGroupingCore,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
type createOverlayManager,
} from '../../core/services';
import { createMediaTimingFrameExtractor } from '../../core/services/media-timing-frame';
import { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
import { generateSpeechWaveform } from '../../core/services/media-timing-waveform';
import { getSharedRemoteMediaWindowCache } from '../../core/services/remote-media-window-cache';
import type { Logger } from '../../logger';
import type { PluginMinedNote, PluginMiningProvenance } from '../../plugin-api';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import type { ResolvedConfig, SubtitleMiningContext } from '../../types';
import type { OverlayModalRuntime } from '../overlay-runtime';
import {
createCopyCurrentSubtitleHandler,
createHandleMineSentenceDigitHandler,
createHandleMultiCopyDigitHandler,
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createUpdateLastCardFromClipboardHandler,
} from '../runtime/domains/mining';
import {
createGetFieldGroupingResolverHandler,
createSetFieldGroupingResolverHandler,
} from '../runtime/domains/overlay';
import {
collectMediaTimingContextLines,
createMediaTimingReviewRuntime,
} from '../runtime/media-timing-review';
import { openMediaTimingReviewModal } from '../runtime/media-timing-review-open';
import { findMpvExecutablePath } from '../runtime/mpv-process';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { resolvePrimarySubtitle } from '../runtime/primary-subtitle-text';
import type { AppState } from '../state';
import type { OverlayModalsAssembly } from './overlay-modals';
import type { CliSlice, IpcSlice } from './slices';
import type { YoutubeAssembly } from './youtube';
type OverlayManager = ReturnType<typeof createOverlayManager>;
type AnkiIntegrationLike = NonNullable<AppState['ankiIntegration']>;
/**
* What card mining needs from the rest of the app. Siblings built after this assembly in
* `main.ts` are reached through callbacks.
*/
export interface MiningAssemblyDeps {
appState: Pick<
AppState,
| 'mpvClient'
| 'currentMediaPath'
| 'activeParsedSubtitleCues'
| 'ankiIntegration'
| 'subtitleTimingTracker'
| 'fieldGroupingResolver'
| 'fieldGroupingResolverSequence'
>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'warn' | 'error'>;
overlayManager: Pick<
OverlayManager,
'getMainWindow' | 'getModalWindow' | 'getVisibleOverlayVisible'
>;
overlayModalRuntime: Pick<
OverlayModalRuntime,
| 'getRestoreVisibleOverlayOnModalClose'
| 'waitForModalOpen'
| 'handleOverlayModalClosed'
| 'sendToActiveOverlayWindow'
>;
youtube: Pick<
YoutubeAssembly,
'getCachedMediaPathForCurrentPlayback' | 'shouldRequireMediaCacheForCurrentPlayback'
>;
/** Electron's clipboard, injected so the assembly loads under `bun test`. */
clipboard: { readText: () => string; writeText: (text: string) => void };
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification' | 'showConfiguredPlaybackFeedback'
>;
getOverlayModals: () => Pick<OverlayModalsAssembly, 'createOverlayHostedModalOpenDeps'>;
setVisibleOverlayVisible: (visible: boolean) => void;
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
/** Callbacks the Anki integration reports back through once mining finishes. */
ankiIntegrationCallbacks: {
recordCardsMined: (count: number, noteIds?: number[]) => void;
getMiningProvenance: () => PluginMiningProvenance;
onCardsReady: (noteIds: number[], notes: PluginMinedNote[]) => void;
onKnownWordCacheUpdated: () => void;
generateSentenceFurigana: (text: string, highlightedText?: string) => Promise<string | null>;
};
}
export type MiningAssembly = ReturnType<typeof createMiningAssembly>;
/**
* Owns card mining: the subtitle context captured for the next card, the mine/copy/update
* handlers behind shortcuts, CLI and session actions, field grouping, and the media timing
* review the Anki integration opens before writing media.
*/
export function createMiningAssembly(deps: MiningAssemblyDeps) {
const { appState, logger, overlayManager, overlayModalRuntime } = deps;
const showStatus = (text: string) =>
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
const showPlaybackFeedback = (text: string) =>
deps.getOverlayNotifications().showConfiguredPlaybackFeedback(text);
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
// event, so a finished caption row lingering beside a fresh line would end up on the
// card. The parsed view also carries the cue's own timings for the clip range.
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable resolved span must fall
// back rather than hand mining an empty line or an inverted range.
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
if (
!resolved ||
!resolvedText ||
!Number.isFinite(resolved.startTime) ||
!Number.isFinite(resolved.endTime) ||
resolved.endTime <= resolved.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
{
source: 'overlay',
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
capturedAtMs: Date.now(),
},
subDelay,
);
}
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
pendingSubtitleMiningContext = context;
};
/** Hands the recorded context to exactly one card, then forgets it. */
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
const context = pendingSubtitleMiningContext;
pendingSubtitleMiningContext = null;
return context;
};
const getFieldGroupingResolver = createGetFieldGroupingResolverHandler({
getResolver: () => appState.fieldGroupingResolver,
});
const setFieldGroupingResolver = createSetFieldGroupingResolverHandler({
setResolver: (resolver) => {
appState.fieldGroupingResolver = resolver;
},
nextSequence: () => {
appState.fieldGroupingResolverSequence += 1;
return appState.fieldGroupingResolverSequence;
},
getSequence: () => appState.fieldGroupingResolverSequence,
});
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>({
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
getResolver: () => getFieldGroupingResolver(),
setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () =>
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
// These are optional on the runtime options, so a missing forward compiles silently, but
// dropping them leaves the field grouping modal with no modal-open ack/retry, no teardown on
// failure, and no warn logging.
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
ensureOverlayWindowsReadyForVisibilityActions: () =>
deps.ensureOverlayWindowsReadyForVisibilityActions(),
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
const sendToOverlayWindows = (channel: string, payload: unknown) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(channel, payload);
}
}
};
const getMediaGenerationOptions = () => ({
getCachedMediaPath: (currentVideoPath: string, kind: 'audio' | 'video') =>
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
remoteCacheMode: deps.youtube.shouldRequireMediaCacheForCurrentPlayback()
? ('required' as const)
: ('optional' as const),
});
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
getMpvExecutablePath: () => {
const configuredPath = deps.getConfig().mpv.executablePath;
return findMpvExecutablePath(configuredPath) || configuredPath;
},
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
clearFrameCache: () => mediaTimingFrameExtractor.clear(),
resolveVideoSource: () =>
resolveMediaGenerationInput(appState.mpvClient, 'video', getMediaGenerationOptions()),
resolveMediaSource: async () => {
const resolved = await resolveMediaGenerationInput(
appState.mpvClient,
'audio',
getMediaGenerationOptions(),
);
return resolved
? {
path: resolved.path,
...(resolved.inputOptions ? { inputOptions: resolved.inputOptions } : {}),
singleResolvedStream: resolved.singleResolvedStream,
}
: null;
},
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({
// Review ranges and tracker entries are on the video clock; parsed cues are not.
cues: appState.activeParsedSubtitleCues.map((cue) =>
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
),
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
startTime: range.startTime,
endTime: range.endTime,
}),
openModal: (payload, signal) =>
openMediaTimingReviewModal(
deps.getOverlayModals().createOverlayHostedModalOpenDeps(),
payload,
signal,
),
onPreviewEnded: (reviewId) => {
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
},
onPreviewPosition: (position) => {
sendToOverlayWindows(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
},
showStatus: (message) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
readClipboardText: () => deps.clipboard.readText(),
showMpvOsd: showStatus,
updateLastCardFromClipboardCore,
});
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () => appState.ankiIntegration,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: showStatus,
triggerFieldGroupingCore,
});
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: showStatus,
markLastCardAsAudioCardCore,
});
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => appState.ankiIntegration,
getMpvClient: () => appState.mpvClient,
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
showMpvOsd: showStatus,
mineSentenceCardCore,
});
const copySubtitleCount = createHandleMultiCopyDigitHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => deps.clipboard.writeText(text),
showMpvOsd: showPlaybackFeedback,
handleMultiCopyDigitCore,
});
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => deps.clipboard.writeText(text),
showMpvOsd: showStatus,
copyCurrentSubtitleCore,
});
const mineSentenceCount = createHandleMineSentenceDigitHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getAnkiIntegration: () => appState.ankiIntegration,
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
showMpvOsd: showStatus,
logError: (message, err) => {
logger.error(message, err);
},
handleMineSentenceDigitCore,
});
/** Points a freshly created Anki integration at mining, stats, plugins and furigana. */
function attachToAnkiIntegration(integration: AnkiIntegrationLike): void {
const callbacks = deps.ankiIntegrationCallbacks;
integration.setRecordCardsMinedCallback(callbacks.recordCardsMined);
integration.setMiningProvenanceProvider(callbacks.getMiningProvenance);
integration.setCardsReadyCallback(callbacks.onCardsReady);
integration.setKnownWordCacheUpdatedCallback(callbacks.onKnownWordCacheUpdated);
integration.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
integration.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
integration.setSentenceFuriganaGenerator(callbacks.generateSentenceFurigana);
}
const ipc: IpcSlice<
| 'recordSubtitleMiningContext'
| 'previewMediaTimingReview'
| 'getMediaTimingReviewFrame'
| 'getMediaTimingReviewWaveform'
| 'stopMediaTimingReviewPreview'
| 'resolveMediaTimingReview'
> = {
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext(
context
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
: captureCurrentPrimarySubtitleMiningContext(),
),
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
getMediaTimingReviewFrame: (request) => mediaTimingReviewRuntime.getFrame(request),
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
};
const cli: CliSlice<
| 'copyCurrentSubtitle'
| 'mineSentenceCard'
| 'updateLastCardFromClipboard'
| 'refreshKnownWordCache'
| 'triggerFieldGrouping'
| 'markLastCardAsAudioCard'
> = {
copyCurrentSubtitle: () => copyCurrentSubtitle(),
mineSentenceCard: () => mineSentenceCard(),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
refreshKnownWordCache: () => refreshKnownWordCache(),
triggerFieldGrouping: () => triggerFieldGrouping(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
};
return {
ipc,
cli,
captureCurrentPrimarySubtitleMiningContext,
recordSubtitleMiningContext,
getFieldGroupingResolver,
setFieldGroupingResolver,
createFieldGroupingCallback: fieldGroupingOverlayRuntime.createFieldGroupingCallback,
mediaTimingReviewRuntime,
attachToAnkiIntegration,
updateLastCardFromClipboard,
refreshKnownWordCache,
triggerFieldGrouping,
markLastCardAsAudioCard,
mineSentenceCard,
mineSentenceCount,
copyCurrentSubtitle,
copySubtitleCount,
};
}
+86 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import type { BrowserWindow } from 'electron';
import type { HyprlandPlacementStatus } from '../../core/services/hyprland-window-placement';
import type { WindowGeometry } from '../../types';
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
import { createAppState } from '../state';
import { createOverlayAssembly, type OverlayAssemblyDeps } from './overlay';
@@ -31,8 +31,15 @@ type FixtureState = {
trackedGeometry: WindowGeometry | null;
/** Window returned by window creation; null makes creation throw after recording options. */
window: BrowserWindow | null;
/** The overlay manager's current main window. */
mainWindow: BrowserWindow | null;
/** Whether the measurement store accepts a reported content measurement. */
measurementAccepted: boolean;
/** Whether the plugin panel recognizes a closed panel id as the one showing. */
pluginPanelCurrent: boolean;
};
const ACCEPTED_MEASUREMENT = {} as OverlayContentMeasurement;
const WINDOW_CREATION_STOPPED = 'window creation stopped by test';
const NO_PLACEMENT: HyprlandPlacementStatus = {
applicable: false,
@@ -56,7 +63,7 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
warn: spy('logger.warn', noop),
},
overlayManager: {
getMainWindow: spy('overlay.getMainWindow', () => null),
getMainWindow: spy('overlay.getMainWindow', () => state.mainWindow),
setMainWindow: spy('overlay.setMainWindow', noop),
getModalWindow: spy('overlay.getModalWindow', () => null),
setModalWindow: spy('overlay.setModalWindow', noop),
@@ -68,6 +75,15 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
setModalWindowBounds: spy('overlay.setModalWindowBounds', () => NO_PLACEMENT),
},
overlayModalInputState: { getModalInputExclusive: spy('modal.getInputExclusive', () => false) },
overlayModalRuntime: {
notifyOverlayModalOpened: spy('modal.notifyOpened', noop),
handleOverlayModalClosed: spy('modal.handleClosed', noop),
},
overlayContentMeasurementStore: {
report: spy('measurement.report', () =>
state.measurementAccepted ? ACCEPTED_MEASUREMENT : null,
),
},
autoplayReadyGate: {
markCurrentMediaAutoplayReady: spy('autoplay.markCurrentMediaReady', noop),
flushPendingAutoplayReadySignal: spy('autoplay.flushPending', noop),
@@ -89,6 +105,14 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
getLinuxOverlayInputShapeActive: spy('interaction.getLinuxInputShapeActive', () => false),
getMacOSVisibleOverlayForegroundProbeActive: spy('interaction.getMacProbe', () => false),
getVisibleOverlayInteractionActive: spy('interaction.getActive', () => false),
setVisibleOverlayInteractionActive: spy('interaction.setActive', noop),
setLinuxOverlayInteractiveHint: spy('interaction.setLinuxInteractiveHint', noop),
applyLinuxOverlayInputShapeFromLatestMeasurement: spy(
'interaction.applyLinuxShape',
() => false,
),
tickLinuxOverlayPointerInteractionNow: spy('interaction.tickLinuxNow', noop),
tickWindowsOverlayPointerInteractionNow: spy('interaction.tickWindowsNow', noop),
getWindowsNativeWindowHandleNumber: spy('interaction.getWindowsHandle', () => 0),
primeLinuxOverlayPointerInteractionAfterFirstMeasurement: spy(
'interaction.primePointerAfterFirstMeasurement',
@@ -141,7 +165,13 @@ function makeDeps(recorder: Recorder, state: FixtureState) {
getAutoplaySubtitlePriming: spy('getAutoplaySubtitlePriming', () => ({
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.cancelFirstPaint', noop),
primeCurrentSubtitleForVisibleOverlay: spy('priming.prime', resolved),
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint: spy('priming.scheduleFirstPaint', noop),
})),
getPluginPanel: spy('getPluginPanel', () => ({
handleModalClosed: spy('pluginPanel.handleModalClosed', () => state.pluginPanelCurrent),
})),
setSubtitleSidebarRequestedOpen: spy('setSubtitleSidebarRequestedOpen', noop),
stealAppFocus: spy('stealAppFocus', noop),
ensureOverlayMpvSubtitlesHidden: spy('ensureOverlayMpvSubtitlesHidden', resolved),
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
initializeOverlayRuntime: spy('initializeOverlayRuntime', noop),
@@ -161,6 +191,9 @@ function setup(t: TestContext, options: { platform?: NodeJS.Platform; visible?:
visible: options.visible ?? false,
trackedGeometry: null,
window: null,
mainWindow: null,
measurementAccepted: true,
pluginPanelCurrent: true,
};
const { deps, windowOptions } = makeDeps(recorder, state);
const overlay = createOverlayAssembly(deps);
@@ -329,3 +362,54 @@ for (const trackedGeometry of [null, { x: 10, y: 20, width: 1280, height: 720 }]
assert.ok(!names.includes('geometry.getCurrentOverlayGeometry'));
});
}
test('an accepted content measurement refreshes pointer input, then releases autoplay, then schedules the first-paint refresh', (t) => {
const { recorder, overlay } = setup(t);
overlay.ipc.reportOverlayContentBounds({ layer: 'visible' });
assertCalledInOrder(recorder.names(), [
'measurement.report',
'interaction.tickLinuxNow',
'interaction.tickWindowsNow',
'interaction.primePointerAfterFirstMeasurement',
'autoplay.flushPending',
'priming.scheduleFirstPaint',
]);
});
test('a rejected content measurement changes nothing', (t) => {
const { recorder, state, overlay } = setup(t);
state.measurementAccepted = false;
overlay.ipc.reportOverlayContentBounds({ layer: 'visible' });
assert.deepEqual(recorder.names(), ['measurement.report']);
});
test('only the main overlay window opens and closes the subtitle sidebar state', (t) => {
const { recorder, state, overlay } = setup(t);
const mainWindow = { isDestroyed: () => false } as BrowserWindow;
const otherWindow = { isDestroyed: () => false } as BrowserWindow;
state.mainWindow = mainWindow;
const sidebarStates = () =>
recorder.calls
.filter((call) => call.name === 'setSubtitleSidebarRequestedOpen')
.map((call) => call.args[0]);
overlay.ipc.onOverlayModalOpened('subtitle-sidebar', otherWindow);
overlay.ipc.onOverlayModalOpened('subtitle-sidebar', mainWindow);
overlay.ipc.onOverlayModalClosed('subtitle-sidebar', otherWindow);
overlay.ipc.onOverlayModalClosed('subtitle-sidebar', mainWindow);
assert.deepEqual(sidebarStates(), [true, false]);
});
test('a late close for a replaced plugin panel leaves the showing panel open', (t) => {
const { recorder, state, overlay } = setup(t);
state.pluginPanelCurrent = false;
overlay.ipc.onOverlayModalClosed('plugin-panel', null, 7);
assert.equal(recorder.names().includes('modal.handleClosed'), false);
});
+142 -1
View File
@@ -1,14 +1,17 @@
import type { BrowserWindow, Screen, Session } from 'electron';
import {
type createOverlayContentMeasurementStore,
type createOverlayWindow as createOverlayWindowCoreFn,
sendMpvCommandRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
type createOverlayManager,
} from '../../core/services';
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
import type { Logger } from '../../logger';
import type { WindowGeometry } from '../../types';
import { ensureWindowsOverlayTransparency } from '../../window-trackers/windows-helper';
import type { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime';
import type { OverlayModalRuntime } from '../overlay-runtime';
import { createOverlayVisibilityRuntimeService } from '../overlay-visibility-runtime';
import type { createAutoplayReadyGate } from '../runtime/autoplay-ready-gate';
import type { composeOverlayVisibilityRuntime } from '../runtime/composers';
@@ -20,6 +23,7 @@ import {
} from '../runtime/linux-mpv-fullscreen-overlay-refresh';
import { createLinuxOverlayModeRuntime } from '../runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from '../runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from '../runtime/macos-overlay-window-focus';
import type { createMediaTimingReviewRuntime } from '../runtime/media-timing-review';
import type { createOverlayModalInputState } from '../runtime/overlay-modal-input-state';
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
@@ -28,6 +32,7 @@ import { createOverlayWindowRuntimeHandlers } from '../runtime/overlay-window-ru
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
import type { AppState } from '../state';
import type { PlaybackAssembly } from './playback';
import type { IpcSlice } from './slices';
type OverlayManager = ReturnType<typeof createOverlayManager>;
@@ -54,6 +59,14 @@ export interface OverlayAssemblyDeps {
ReturnType<typeof createOverlayModalInputState>,
'getModalInputExclusive'
>;
overlayModalRuntime: Pick<
OverlayModalRuntime,
'notifyOverlayModalOpened' | 'handleOverlayModalClosed'
>;
overlayContentMeasurementStore: Pick<
ReturnType<typeof createOverlayContentMeasurementStore>,
'report'
>;
autoplayReadyGate: Pick<
ReturnType<typeof createAutoplayReadyGate>,
'markCurrentMediaAutoplayReady' | 'flushPendingAutoplayReadySignal'
@@ -69,6 +82,11 @@ export interface OverlayAssemblyDeps {
| 'getLinuxOverlayInputShapeActive'
| 'getMacOSVisibleOverlayForegroundProbeActive'
| 'getVisibleOverlayInteractionActive'
| 'setVisibleOverlayInteractionActive'
| 'setLinuxOverlayInteractiveHint'
| 'applyLinuxOverlayInputShapeFromLatestMeasurement'
| 'tickLinuxOverlayPointerInteractionNow'
| 'tickWindowsOverlayPointerInteractionNow'
| 'getWindowsNativeWindowHandleNumber'
| 'primeLinuxOverlayPointerInteractionAfterFirstMeasurement'
| 'requestLinuxOverlayZOrderFollow'
@@ -113,8 +131,16 @@ export interface OverlayAssemblyDeps {
>;
getAutoplaySubtitlePriming: () => Pick<
PlaybackAssembly['autoplaySubtitlePrimingRuntime'],
'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint' | 'primeCurrentSubtitleForVisibleOverlay'
| 'cancelVisibleOverlaySubtitleRefreshAfterFirstPaint'
| 'primeCurrentSubtitleForVisibleOverlay'
| 'scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint'
>;
/** A late close for a replaced plugin panel reports false and must be ignored. */
getPluginPanel: () => { handleModalClosed: (panelId: number) => boolean };
/** The renderer reports the subtitle sidebar as a modal; playback remembers its state. */
setSubtitleSidebarRequestedOpen: (open: boolean) => void;
/** Brings SubMiner to the foreground (`app.focus({ steal: true })`). */
stealAppFocus: () => void;
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
syncOverlayMpvSubtitleSuppression: () => void;
initializeOverlayRuntime: () => void;
@@ -426,7 +452,122 @@ export function createOverlayAssembly(deps: OverlayAssemblyDeps) {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
}
/** Renderer reports about overlay windows: focus, modal lifecycle, pointer and measurement. */
const ipc: IpcSlice<
| 'getMainWindow'
| 'focusMainWindow'
| 'activatePlaybackWindowForOverlayInteraction'
| 'onOverlayModalClosed'
| 'onOverlayModalOpened'
| 'onOverlayMouseInteractionChanged'
| 'onOverlayInteractiveHint'
| 'reportOverlayContentBounds'
> = {
getMainWindow: () => overlayManager.getMainWindow(),
focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'darwin') {
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => mainWindow,
stealAppFocus: () => deps.stealAppFocus(),
warn: (message, details) => logger.warn(message, details),
});
return;
}
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
},
activatePlaybackWindowForOverlayInteraction: async () => {
try {
const raised = (await appState.windowTracker?.raiseTargetWindow?.()) ?? false;
deps.getOverlayGeometry().enforceOverlayLayerOrder();
return raised;
} catch (error) {
logger.debug(
'Failed to raise tracked mpv window for overlay interaction:',
error instanceof Error ? error.message : String(error),
);
deps.getOverlayGeometry().enforceOverlayLayerOrder();
return false;
}
},
onOverlayModalClosed: (modal, senderWindow, instanceId) => {
// A late close for a replaced plugin panel must not hide the panel now showing.
if (
modal === 'plugin-panel' &&
instanceId !== undefined &&
!deps.getPluginPanel().handleModalClosed(instanceId)
) {
return;
}
if (modal === 'media-timing-review') {
void deps.getMediaTimingReviewRuntime().dispose();
}
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
deps.setSubtitleSidebarRequestedOpen(false);
}
const modalWindow = overlayManager.getModalWindow();
if (
senderWindow &&
modalWindow &&
senderWindow === modalWindow &&
!senderWindow.isDestroyed()
) {
applyOverlayClickThrough(senderWindow);
senderWindow.hide();
}
deps.overlayModalRuntime.handleOverlayModalClosed(modal);
},
onOverlayModalOpened: (modal, senderWindow) => {
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
deps.setSubtitleSidebarRequestedOpen(true);
}
deps.overlayModalRuntime.notifyOverlayModalOpened(modal);
},
onOverlayMouseInteractionChanged: (active, senderWindow) => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
const interaction = deps.getVisibleOverlayInteraction();
const previousActive = interaction.getVisibleOverlayInteractionActive();
interaction.setVisibleOverlayInteractionActive(active);
if (previousActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
return;
}
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
onOverlayInteractiveHint: (interactive, senderWindow) => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
const interaction = deps.getVisibleOverlayInteraction();
interaction.setLinuxOverlayInteractiveHint(interactive);
interaction.applyLinuxOverlayInputShapeFromLatestMeasurement();
},
// An accepted measurement refreshes pointer passthrough at once, then releases autoplay,
// then schedules the first-paint subtitle refresh.
reportOverlayContentBounds: (payload: unknown) => {
if (deps.overlayContentMeasurementStore.report(payload)) {
const interaction = deps.getVisibleOverlayInteraction();
interaction.tickLinuxOverlayPointerInteractionNow();
interaction.tickWindowsOverlayPointerInteractionNow();
interaction.primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
deps.autoplayReadyGate.flushPendingAutoplayReadySignal();
deps.getAutoplaySubtitlePriming().scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
}
},
};
return {
ipc,
linuxOverlayModeRuntime,
overlayVisibilityRuntime,
createMainWindow,
+53 -3
View File
@@ -88,10 +88,13 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
},
overlayNotificationsRuntime: {
showConfiguredStatusNotification: spy('notifications.showStatus', noop),
showConfiguredPlaybackFeedback: spy('notifications.showPlaybackFeedback', noop),
getConfiguredStatusNotificationType: spy('notifications.getType', () => 'osd' as const),
},
overlayModalRuntime: { handleOverlayModalClosed: spy('overlayModal.handleClosed', noop) },
youtube: {
isPlaybackActiveNow: spy('youtube.isPlaybackActiveNow', () => false),
shouldQuitWhenMpvCloses: spy('youtube.shouldQuitWhenMpvCloses', () => false),
playback: { getQuitOnDisconnectArmed: spy('youtube.getQuitOnDisconnectArmed', () => false) },
primarySubtitleNotification: {
handleMediaPathChange: spy('youtube.notification.handleMediaPathChange', noop),
@@ -99,8 +102,16 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
handleSubtitleTrackListChange: spy('youtube.notification.handleTrackListChange', noop),
},
mediaCachePlayback: { handleMediaPathChange: spy('youtube.mediaCache', resolved) },
browserPlayback: { handleMediaPathChange: spy('youtube.browser', noop) },
flow: { handleMediaPathChange: spy('youtube.flow', noop) },
browserPlayback: {
handleMediaPathChange: spy('youtube.browser', noop),
handleMpvDisconnected: spy('youtube.browser.handleMpvDisconnected', noop),
},
flow: {
handleMediaPathChange: spy('youtube.flow', noop),
cancelWhisperGeneration: spy('youtube.flow.cancelWhisperGeneration', noop),
hasActiveSession: spy('youtube.flow.hasActiveSession', () => false),
cancelActivePicker: spy('youtube.flow.cancelActivePicker', () => false),
},
},
jellyfin: {
getQuitOnDisconnectArmed: spy('jellyfin.getQuitOnDisconnectArmed', () => false),
@@ -133,6 +144,7 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
getSubtitleGenerationRuntime: spy('getSubtitleGenerationRuntime', () => ({
handleMediaPathChange: noop,
})),
getPluginHost: spy('getPluginHost', () => ({ attachMpvClient: noop })),
isCharacterDictionaryEnabled: spy('isCharacterDictionaryEnabled', () => false),
hasTray: spy('hasTray', () => false),
loadSubtitleSourceText: spy('loadSubtitleSourceText', async () => ''),
@@ -145,7 +157,6 @@ function makeDeps(recorder: Recorder): PlaybackAssemblyDeps {
refreshMpvSessionBindings: spy('refreshMpvSessionBindings', resolved),
ensureImmersionTrackerStarted: spy('ensureImmersionTrackerStarted', noop),
restoreOverlayMpvSubtitles: spy('restoreOverlayMpvSubtitles', noop),
resetSubtitleSidebarEmbeddedLayoutRuntime: spy('resetSubtitleSidebarLayout', noop),
syncOverlayMpvSubtitleSuppression: spy('syncOverlayMpvSubtitleSuppression', noop),
setOverlayVisible: spy('setOverlayVisible', noop),
ensureYomitanExtensionLoaded: spy('ensureYomitanExtensionLoaded', async () => null),
@@ -393,3 +404,42 @@ test('warm tokenization releases autoplay with the current subtitle when it is s
const signals = recorder.calls.filter((call) => call.name === 'autoplay.maybeSignal');
assert.deepEqual(signals.at(-1)?.args, [current, { forceWhilePaused: true }]);
});
/** An mpv client that answers the sidebar's property reads from a table. */
function fakeSidebarMpvClient(properties: Record<string, unknown>) {
return {
connected: true,
requestProperty: async (name: string) => properties[name] ?? null,
} as unknown as NonNullable<ReturnType<typeof setup>['appState']['mpvClient']>;
}
test('sidebar snapshot keeps cached YouTube cues instead of re-resolving the subtitle source', async () => {
const { appState, playback, recorder } = setup();
const cues = [{ startTime: 1, endTime: 2, text: '字幕' }];
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleMediaPath = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
appState.mpvClient = fakeSidebarMpvClient({
path: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30',
// Without the YouTube cache check this source would be re-read and re-parsed.
'current-tracks/sub/external-filename': '/tmp/youtube-subs/dQw4w9WgXcQ.ja.vtt',
'track-list': [],
sid: 1,
});
const snapshot = await playback.ipc.getSubtitleSidebarSnapshot();
assert.equal(snapshot.cues, cues);
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
});
test('sidebar snapshot falls back to cached cues while mpv is disconnected', async () => {
const { appState, playback, recorder } = setup();
const cues = [{ startTime: 1, endTime: 2, text: '字幕' }];
appState.activeParsedSubtitleCues = cues;
const snapshot = await playback.ipc.getSubtitleSidebarSnapshot();
assert.equal(snapshot.cues, cues);
assert.equal(snapshot.config, DEFAULT_CONFIG.subtitleSidebar);
assert.equal(recorder.names().includes('loadSubtitleSourceText'), false);
});
+202 -7
View File
@@ -9,6 +9,10 @@ import {
tokenizeSubtitle as tokenizeSubtitleCore,
} from '../../core/services';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata';
import { createAniSkipRuntime } from '../runtime/aniskip-runtime';
import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot';
import { resolveFreshPlaybackPaused } from '../runtime/playback-paused-state';
import { createAutoplaySubtitlePrimingRuntime } from '../runtime/autoplay-subtitle-priming-runtime';
import { createAutoplayTokenizationWarmRelease } from '../runtime/autoplay-tokenization-warm-release';
import { composeMpvRuntimeHandlers } from '../runtime/composers';
@@ -28,8 +32,13 @@ import {
createResolveActiveSubtitleSidebarSourceHandler,
} from '../runtime/subtitle-prefetch-runtime';
import { createPrefetchAwareSubtitleProcessingController } from '../runtime/subtitle-processing-runtime';
import { isSameYoutubeMediaPath, isYoutubeMediaPath } from '../runtime/youtube-playback';
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
shouldUseCachedYoutubeParsedCues,
} from '../runtime/youtube-playback';
import { MecabTokenizer } from '../../mecab-tokenizer';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type {
MpvSubtitleRenderMetrics,
SubtitleCue,
@@ -56,11 +65,13 @@ import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notif
import type { createStartupOsdSequencer } from '../runtime/startup-osd-sequencer';
import type { createSubtitleGenerationRuntime } from '../runtime/subtitle-generation-runtime';
import type { RuntimeOptionId } from '../../types/runtime-options';
import type { OverlayModalRuntime } from '../overlay-runtime';
import type { AppState } from '../state';
import type { AnilistAssembly } from './anilist';
import type { CharacterDictionaryAssembly } from './character-dictionary';
import type { DictionariesAssembly } from './dictionaries';
import type { JellyfinAssembly } from './jellyfin';
import type { IpcSlice } from './slices';
import type { YoutubeAssembly } from './youtube';
type SubtitleWsBroadcaster = Pick<SubtitleWebSocket, 'broadcast'>;
@@ -128,17 +139,29 @@ export interface PlaybackAssemblyDeps {
>;
overlayNotificationsRuntime: Pick<
OverlayNotificationsRuntime,
'showConfiguredStatusNotification' | 'getConfiguredStatusNotificationType'
| 'showConfiguredStatusNotification'
| 'showConfiguredPlaybackFeedback'
| 'getConfiguredStatusNotificationType'
>;
youtube: Pick<YoutubeAssembly, 'isPlaybackActiveNow'> & {
overlayModalRuntime: Pick<OverlayModalRuntime, 'handleOverlayModalClosed'>;
youtube: Pick<YoutubeAssembly, 'isPlaybackActiveNow' | 'shouldQuitWhenMpvCloses'> & {
playback: Pick<YoutubeAssembly['playback'], 'getQuitOnDisconnectArmed'>;
primarySubtitleNotification: Pick<
YoutubeAssembly['primarySubtitleNotification'],
'handleMediaPathChange' | 'handleSubtitleTrackChange' | 'handleSubtitleTrackListChange'
>;
mediaCachePlayback: Pick<YoutubeAssembly['mediaCachePlayback'], 'handleMediaPathChange'>;
browserPlayback: Pick<YoutubeAssembly['browserPlayback'], 'handleMediaPathChange'>;
flow: Pick<YoutubeAssembly['flow'], 'handleMediaPathChange'>;
browserPlayback: Pick<
YoutubeAssembly['browserPlayback'],
'handleMediaPathChange' | 'handleMpvDisconnected'
>;
flow: Pick<
YoutubeAssembly['flow'],
| 'handleMediaPathChange'
| 'cancelWhisperGeneration'
| 'hasActiveSession'
| 'cancelActivePicker'
>;
};
jellyfin: Pick<
JellyfinAssembly,
@@ -171,6 +194,8 @@ export interface PlaybackAssemblyDeps {
ReturnType<typeof createSubtitleGenerationRuntime>,
'handleMediaPathChange'
>;
/** Plugins observe every mpv client the app creates. */
getPluginHost: () => { attachMpvClient: (client: MpvIpcClient) => void };
isCharacterDictionaryEnabled: () => boolean;
/** Whether the tray icon exists; mpv shutdown keeps the app alive when it does. */
hasTray: () => boolean;
@@ -184,7 +209,6 @@ export interface PlaybackAssemblyDeps {
refreshMpvSessionBindings: () => Promise<void>;
ensureImmersionTrackerStarted: () => void;
restoreOverlayMpvSubtitles: () => void;
resetSubtitleSidebarEmbeddedLayoutRuntime: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
setOverlayVisible: (visible: boolean) => void;
ensureYomitanExtensionLoaded: () => Promise<unknown>;
@@ -229,6 +253,13 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
} = deps;
let backgroundWarmupsStarted = false;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
// Set from the renderer's modal open/close reports; replacement overlay windows restore it.
let subtitleSidebarRequestedOpen = false;
function resetSubtitleSidebarEmbeddedLayout(): void {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
}
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
@@ -664,7 +695,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
deps.restoreOverlayMpvSubtitles();
},
resetSubtitleSidebarEmbeddedLayout: () => {
deps.resetSubtitleSidebarEmbeddedLayoutRuntime();
resetSubtitleSidebarEmbeddedLayout();
},
getCurrentAnilistMediaKey: () => anilist.getCurrentAnilistMediaKey(),
resetAnilistMediaTracking: (mediaKey) => {
@@ -955,7 +986,171 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
const aniSkipRuntime = createAniSkipRuntime({
getAniSkipConfig: () => ({
aniskipEnabled: configService.getConfig().mpv.aniskipEnabled,
aniskipButtonKey: configService.getConfig().mpv.aniskipButtonKey,
}),
resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath),
sendMpvCommand: (command) => {
appState.mpvClient?.send({ command });
},
requestMpvProperty: (name) => {
if (!appState.mpvClient) {
return Promise.reject(new Error('MPV not connected'));
}
return appState.mpvClient.requestProperty(name);
},
isMpvConnected: () => appState.mpvClient?.connected === true,
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
showMpvOsd: (text, durationMs) => {
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
},
showPlaybackFeedback: (text) =>
overlayNotificationsRuntime.showConfiguredPlaybackFeedback(text),
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
});
/** Creates the app's mpv client with YouTube, AniSkip and plugin listeners attached. */
function createMpvClient(): MpvIpcClient {
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
client.on('connection-change', ({ connected }) => {
if (connected) {
return;
}
youtube.browserPlayback.handleMpvDisconnected();
youtube.flow.cancelWhisperGeneration();
deps.getSubtitleGenerationRuntime().handleMediaPathChange(null);
if (youtube.shouldQuitWhenMpvCloses()) {
deps.requestAppQuit();
return;
}
if (!youtube.flow.hasActiveSession()) {
return;
}
youtube.flow.cancelActivePicker();
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
deps.overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
});
client.on('connection-change', aniSkipRuntime.handleConnectionChange);
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
client.on('client-message', aniSkipRuntime.handleClientMessage);
deps.getPluginHost().attachMpvClient(client);
return client;
}
const ipc: IpcSlice<
| 'tokenizeCurrentSubtitle'
| 'getCurrentSubtitleRaw'
| 'getSubtitleSidebarOpen'
| 'getSubtitleSidebarPlaybackState'
| 'getSubtitleSidebarSnapshot'
| 'getPlaybackPaused'
| 'getMpvClient'
> = {
tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({
currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
tokenizeUncached: false,
tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text)
: undefined,
onResolvedSubtitle: (payload) => {
appState.currentSubtitleData = payload;
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
},
});
},
getCurrentSubtitleRaw: () => appState.currentSubText,
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(),
// Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed.
// The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence.
getSubtitleSidebarSnapshot: async () => {
const config = configService.getConfig().subtitleSidebar;
const cachedSnapshot = () => ({
...getSubtitleSidebarPlaybackState(),
cues: appState.activeParsedSubtitleCues,
config,
});
const client = appState.mpvClient;
if (!client?.connected) {
return cachedSnapshot();
}
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 cachedSnapshot();
}
if (
shouldUseCachedYoutubeParsedCues({
videoPath,
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
cachedCueCount: appState.activeParsedSubtitleCues.length,
})
) {
return cachedSnapshot();
}
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPath,
});
if (!resolvedSource) {
return cachedSnapshot();
}
try {
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
return cachedSnapshot();
}
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = parseSubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return cachedSnapshot();
} finally {
await resolvedSource.cleanup?.();
}
} catch {
return cachedSnapshot();
}
},
getPlaybackPaused: () =>
resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => appState.playbackPaused,
getMpvClient: () => appState.mpvClient,
}),
getMpvClient: () => appState.mpvClient,
};
return {
ipc,
createMpvClient,
aniSkipRuntime,
setSubtitleSidebarRequestedOpen: (open: boolean): void => {
subtitleSidebarRequestedOpen = open;
},
subtitleProcessingController,
autoplaySubtitlePrimingRuntime,
secondarySubtitleTrackController,
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { GlobalShortcut } from 'electron';
import { DEFAULT_CONFIG } from '../../config/definitions';
import { createShortcutsAssembly, type ShortcutsAssemblyDeps } from './shortcuts';
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<ShortcutsAssemblyDeps> = {}): ShortcutsAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
shortcutsRegistered: false,
overlayRuntimeInitialized: false,
windowTracker: null,
keybindings: [],
mpvClient: null,
sessionBindings: [],
sessionBindingsInitialized: false,
},
getConfig: lazy('getConfig'),
logger: { warn: lazy('warn') },
configDir: '/tmp/subminer-shortcuts-assembly-test',
isDev: false,
overlayManager: {
getMainWindow: lazy('getMainWindow'),
getVisibleOverlayVisible: lazy('getVisibleOverlayVisible'),
broadcastToOverlayWindows: lazy('broadcastToOverlayWindows'),
},
globalShortcut: new Proxy({}, { get: (_target, name: string) => lazy(name) }) as GlobalShortcut,
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayModals: lazy('getOverlayModals'),
getMining: lazy('getMining'),
triggerSubsyncFromConfig: lazy('triggerSubsyncFromConfig'),
cycleSecondarySubMode: lazy('cycleSecondarySubMode'),
toggleVisibleOverlay: lazy('toggleVisibleOverlay'),
openDictionarySettings: lazy('openDictionarySettings'),
...overrides,
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createShortcutsAssembly does not call its deps while constructing', () => {
const shortcuts = createShortcutsAssembly(makeDeps());
assert.equal(typeof shortcuts.registerGlobalShortcuts, 'function');
assert.equal(typeof shortcuts.syncOverlayShortcutsForModal, 'function');
});
test('opening a modal releases the registered overlay shortcuts', () => {
const deps = makeDeps({ getConfig: () => DEFAULT_CONFIG });
deps.appState.shortcutsRegistered = true;
const shortcuts = createShortcutsAssembly(deps);
shortcuts.syncOverlayShortcutsForModal(true);
assert.equal(deps.appState.shortcutsRegistered, false);
});
+289
View File
@@ -0,0 +1,289 @@
import type { GlobalShortcut } from 'electron';
import { DEFAULT_CONFIG } from '../../config/definitions';
import {
registerGlobalShortcuts as registerGlobalShortcutsCore,
type createOverlayManager,
} from '../../core/services';
import { resolveConfiguredShortcuts } from '../../core/utils';
import type { Logger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { ResolvedConfig } from '../../types';
import { createOverlayShortcutsRuntimeService } from '../overlay-shortcuts-runtime';
import { composeShortcutRuntimes } from '../runtime/composers';
import { tryBeginVisibleOverlayNumericSelection } from '../runtime/overlay-numeric-selection';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { readMpvInputBindings } from '../runtime/mpv-input-bindings';
import { createSessionBindingsRuntime } from '../runtime/session-bindings-runtime';
import type { AppState } from '../state';
import type { MiningAssembly } from './mining';
import type { OverlayModalsAssembly } from './overlay-modals';
import type { CliSlice, IpcSlice } from './slices';
type OverlayManager = ReturnType<typeof createOverlayManager>;
/**
* What shortcut handling needs from the rest of the app. Siblings built after this assembly
* in `main.ts` are reached through callbacks.
*/
export interface ShortcutsAssemblyDeps {
appState: Pick<
AppState,
| 'shortcutsRegistered'
| 'overlayRuntimeInitialized'
| 'windowTracker'
| 'keybindings'
| 'mpvClient'
| 'sessionBindings'
| 'sessionBindingsInitialized'
>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'warn'>;
configDir: string;
isDev: boolean;
overlayManager: Pick<
OverlayManager,
'getMainWindow' | 'getVisibleOverlayVisible' | 'broadcastToOverlayWindows'
>;
/** Electron's globalShortcut, injected so the assembly loads under `bun test`. */
globalShortcut: GlobalShortcut;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification' | 'showOverlayNotification'
>;
getOverlayModals: () => Pick<
OverlayModalsAssembly,
| 'openRuntimeOptionsPalette'
| 'openCharacterDictionaryManagerOverlay'
| 'openJimakuOverlay'
| 'openTsukihimeOverlay'
>;
getMining: () => Pick<
MiningAssembly,
| 'markLastCardAsAudioCard'
| 'copyCurrentSubtitle'
| 'copySubtitleCount'
| 'updateLastCardFromClipboard'
| 'triggerFieldGrouping'
| 'mineSentenceCard'
| 'mineSentenceCount'
>;
triggerSubsyncFromConfig: () => Promise<void>;
cycleSecondarySubMode: () => void;
toggleVisibleOverlay: () => void;
openDictionarySettings: () => void;
}
export type ShortcutsAssembly = ReturnType<typeof createShortcutsAssembly>;
/**
* Owns keyboard input outside the renderer: the overlay's local shortcuts, global shortcuts,
* the numeric multi-copy/multi-mine sessions, and the session bindings shared with mpv.
*/
export function createShortcutsAssembly(deps: ShortcutsAssemblyDeps) {
const { appState, overlayManager } = deps;
const showStatus = (text: string) =>
deps.getOverlayNotifications().showConfiguredStatusNotification(text);
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
setShortcutsRegistered: (registered: boolean) => {
appState.shortcutsRegistered = registered;
},
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
isOverlayShortcutContextActive: () => {
if (process.platform !== 'win32') {
return true;
}
if (!overlayManager.getVisibleOverlayVisible()) {
return false;
}
const windowTracker = appState.windowTracker;
if (!windowTracker || !windowTracker.isTracking()) {
return false;
}
return windowTracker.isTargetWindowFocused();
},
showMpvOsd: showStatus,
openRuntimeOptionsPalette: () => {
deps.getOverlayModals().openRuntimeOptionsPalette();
},
openCharacterDictionaryManager: () => {
deps.getOverlayModals().openCharacterDictionaryManagerOverlay();
},
openJimaku: () => {
deps.getOverlayModals().openJimakuOverlay();
},
openTsukihime: () => {
deps.getOverlayModals().openTsukihimeOverlay();
},
markAudioCard: () => deps.getMining().markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
deps.getMining().copyCurrentSubtitle();
},
toggleSecondarySubMode: () => deps.cycleSecondarySubMode(),
updateLastCardFromClipboard: () => deps.getMining().updateLastCardFromClipboard(),
triggerFieldGrouping: () => deps.getMining().triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
mineSentenceCard: () => deps.getMining().mineSentenceCard(),
mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs);
},
cancelPendingMultiCopy: () => {
cancelPendingMultiCopy();
},
cancelPendingMineSentenceMultiple: () => {
cancelPendingMineSentenceMultiple();
},
});
const {
getConfiguredShortcuts,
registerGlobalShortcuts,
refreshGlobalAndOverlayShortcuts,
cancelPendingMultiCopy,
startPendingMultiCopy,
cancelPendingMineSentenceMultiple,
startPendingMineSentenceMultiple,
syncOverlayShortcuts,
refreshOverlayShortcuts,
} = composeShortcutRuntimes({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => deps.getConfig(),
defaultConfig: DEFAULT_CONFIG,
resolveConfiguredShortcuts,
},
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore,
onToggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
onOpenYomitanSettings: () => deps.openDictionarySettings(),
isDev: deps.isDev,
getMainWindow: () => overlayManager.getMainWindow(),
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({
unregisterAllGlobalShortcuts: () => deps.globalShortcut.unregisterAll(),
registerGlobalShortcuts: () => registerGlobalShortcutsHandler(),
syncOverlayShortcuts: () => syncOverlayShortcuts(),
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: deps.globalShortcut,
showMpvOsd: showStatus,
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: (count) => deps.getMining().copySubtitleCount(count),
onMineSentenceDigit: (count) => deps.getMining().mineSentenceCount(count),
tryBeginMultiCopyOverlaySelection: (timeoutMs) =>
tryBeginVisibleOverlayNumericSelection({
actionId: 'copySubtitleMultiple',
timeoutMs,
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
}),
tryBeginMineSentenceOverlaySelection: (timeoutMs) =>
tryBeginVisibleOverlayNumericSelection({
actionId: 'mineSentenceMultiple',
timeoutMs,
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
}),
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime,
},
});
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
createSessionBindingsRuntime({
configDir: deps.configDir,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => deps.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => deps.logger.warn(message),
onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(
IPC_CHANNELS.event.sessionBindingsChanged,
bindings,
),
onMpvInputBindingsChanged: () =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.mpvInputBindingsChanged),
onWarning: (warning) => {
if (warning.kind !== 'conflict') return;
deps.getOverlayNotifications().showOverlayNotification({
id: `session-binding-conflict:${warning.path}`,
title: 'Shortcut conflict',
body: warning.message,
variant: 'warning',
});
},
});
const ipc: IpcSlice<'getMpvInputBindings' | 'getSessionBindings' | 'getConfiguredShortcuts'> = {
getMpvInputBindings: async () => {
await refreshMpvSessionBindings();
return readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => deps.getConfig().keybindings ?? [],
platform:
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'win32'
: 'linux',
});
},
getSessionBindings: async () => {
await refreshMpvSessionBindings();
return appState.sessionBindings;
},
getConfiguredShortcuts: () => getConfiguredShortcuts(),
};
const cli: CliSlice<
'startPendingMultiCopy' | 'startPendingMineSentenceMultiple' | 'getMultiCopyTimeoutMs'
> = {
startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs),
startPendingMineSentenceMultiple: (timeoutMs) => startPendingMineSentenceMultiple(timeoutMs),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
};
return {
ipc,
cli,
overlayShortcutsRuntime,
getConfiguredShortcuts,
registerGlobalShortcuts,
refreshGlobalAndOverlayShortcuts,
startPendingMultiCopy,
startPendingMineSentenceMultiple,
refreshOverlayShortcuts,
persistSessionBindings,
refreshCurrentSessionBindings,
refreshMpvSessionBindings,
/** Overlay shortcuts are released while a modal owns keyboard input. */
syncOverlayShortcutsForModal: (isActive: boolean): void => {
if (isActive) {
overlayShortcutsRuntime.unregisterOverlayShortcuts();
} else {
overlayShortcutsRuntime.syncOverlayShortcuts();
}
},
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
import type { CliCommandContextMainDeps } from '../runtime/cli-command-context-factory';
/** Renderer IPC handlers, minus the two the IPC composer builds itself. */
export type IpcHandlerDeps = Omit<
RegisterIpcRuntimeServicesParams['mainDeps'],
'handleMpvCommand' | 'runSubsyncManual'
>;
/**
* The IPC handlers one assembly provides. `Required` because most handlers are optional on
* the registration params, and a slice must not drop one silently.
*/
export type IpcSlice<K extends keyof IpcHandlerDeps> = Required<Pick<IpcHandlerDeps, K>>;
/** The CLI command handlers one assembly provides. */
export type CliSlice<K extends keyof CliCommandContextMainDeps> = Required<
Pick<CliCommandContextMainDeps, K>
>;
+181
View File
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import { createAppState } from '../state';
import { createStartupAssembly, type StartupAssemblyDeps } from './startup';
type MpvClient = NonNullable<ReturnType<typeof createAppState>['mpvClient']>;
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function lazy(name: string) {
return (): never => {
throw new Error(`${name} called unexpectedly`);
};
}
/** Builds deps whose every function throws unless a test overrides it. */
function makeDeps(overrides: Partial<StartupAssemblyDeps> = {}): StartupAssemblyDeps {
const thrower = <T extends object>(name: string): T =>
new Proxy({}, { get: (_target, key: string) => lazy(`${name}.${key}`) }) as T;
return {
appState: createAppState({ mpvSocketPath: '/tmp/subminer-startup.sock', texthookerPort: 0 }),
configService: thrower('configService'),
logger: thrower('logger'),
logConfigWarning: lazy('logConfigWarning'),
configDir: '/tmp/subminer-startup-assembly-test',
userDataPath: '/tmp/subminer-startup-assembly-test',
defaultTexthookerPort: 5174,
showErrorBox: lazy('showErrorBox'),
reportFatalError: lazy('reportFatalError'),
requestAppQuit: lazy('requestAppQuit'),
logging: {
setLogLevel: lazy('setLogLevel'),
setLogRotation: lazy('setLogRotation'),
setLogFileToggles: lazy('setLogFileToggles'),
},
overlayManager: thrower('overlayManager'),
overlayModalRuntime: thrower('overlayModalRuntime'),
subtitleWsService: thrower('subtitleWsService'),
annotationSubtitleWsService: thrower('annotationSubtitleWsService'),
texthookerService: thrower('texthookerService'),
configDerivedRuntime: thrower('configDerivedRuntime'),
hasMpvWebsocketPlugin: lazy('hasMpvWebsocketPlugin'),
loadSubtitlePosition: lazy('loadSubtitlePosition'),
setSecondarySubMode: lazy('setSecondarySubMode'),
syncOverlayMpvSubtitleSuppression: lazy('syncOverlayMpvSubtitleSuppression'),
yomitan: thrower('yomitan'),
stats: { ensureImmersionTrackerStarted: lazy('ensureImmersionTrackerStarted') } as never,
firstRunSetup: thrower('firstRunSetup'),
youtube: thrower('youtube'),
overlay: thrower('overlay'),
shortcuts: thrower('shortcuts'),
settings: { hotReload: thrower('settings.hotReload') },
mining: thrower('mining'),
getAnilist: lazy('getAnilist'),
getJellyfin: lazy('getJellyfin'),
getPlayback: lazy('getPlayback'),
getPluginHost: lazy('getPluginHost'),
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayVisibilityComposer: lazy('getOverlayVisibilityComposer'),
getVisibleOverlayInteraction: lazy('getVisibleOverlayInteraction'),
getOverlayGeometry: lazy('getOverlayGeometry'),
handleInitialArgs: lazy('handleInitialArgs'),
...overrides,
};
}
/** Deps for the overlay prerequisites, recording what each step does. */
function makePrereqDeps(calls: string[]): Partial<StartupAssemblyDeps> {
const record = (name: string) => () => {
calls.push(name);
};
return {
configService: { getConfig: () => DEFAULT_CONFIG } as StartupAssemblyDeps['configService'],
loadSubtitlePosition: record('loadSubtitlePosition'),
shortcuts: {
refreshCurrentSessionBindings: record('refreshCurrentSessionBindings'),
refreshOverlayShortcuts: record('refreshOverlayShortcuts'),
} as unknown as StartupAssemblyDeps['shortcuts'],
getPlayback: () =>
({
createMpvClient: () => {
calls.push('createMpvClient');
return {} as MpvClient;
},
subtitleProcessingController: {
invalidateTokenizationCache: record('invalidateTokenizationCache'),
},
getSubtitlePrefetchService: () => ({ onSeek: record('prefetch.onSeek') }),
getLastObservedTimePos: () => 12,
}) as unknown as ReturnType<StartupAssemblyDeps['getPlayback']>,
getOverlayVisibilityComposer: () => ({
broadcastRuntimeOptionsChanged: record('broadcastRuntimeOptionsChanged'),
}),
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createStartupAssembly does not call its deps while constructing', () => {
const startup = createStartupAssembly(makeDeps());
assert.equal(typeof startup.runAppReady, 'function');
});
test('overlay startup prerequisites are created once', () => {
const calls: string[] = [];
const deps = makeDeps(makePrereqDeps(calls));
const startup = createStartupAssembly(deps);
startup.ensureOverlayStartupPrereqs();
deps.appState.subtitlePosition = { yPercent: 10 };
startup.ensureOverlayStartupPrereqs();
assert.deepEqual(calls, [
'loadSubtitlePosition',
'refreshCurrentSessionBindings',
'createMpvClient',
'refreshCurrentSessionBindings',
]);
assert.ok(deps.appState.runtimeOptionsManager);
assert.ok(deps.appState.subtitleTimingTracker);
});
test('a runtime option change refreshes tokenization, prefetch, overlay state and shortcuts', () => {
const calls: string[] = [];
const deps = makeDeps(makePrereqDeps(calls));
createStartupAssembly(deps).ensureOverlayStartupPrereqs();
calls.length = 0;
const manager = deps.appState.runtimeOptionsManager;
assert.ok(manager);
manager.setOptionValue('subtitle.annotation.jlpt', true);
assert.deepEqual(calls, [
'invalidateTokenizationCache',
'prefetch.onSeek',
'broadcastRuntimeOptionsChanged',
'refreshOverlayShortcuts',
]);
});
test('a headless launch without --refresh-known-words runs the initial CLI command', async () => {
let handled = 0;
const deps = makeDeps({
handleInitialArgs: () => {
handled += 1;
},
});
await createStartupAssembly(deps).runHeadlessInitialCommand();
assert.equal(handled, 1);
});
test('a headless known-word refresh fails and quits when AnkiConnect is disabled', async (t) => {
const previousExitCode = process.exitCode;
t.after(() => {
process.exitCode = previousExitCode ?? 0;
});
const config: ResolvedConfig = {
...DEFAULT_CONFIG,
ankiConnect: { ...DEFAULT_CONFIG.ankiConnect, enabled: false },
};
const errors: string[] = [];
let quits = 0;
const deps = makeDeps({
configService: { getConfig: () => config } as StartupAssemblyDeps['configService'],
logger: { error: (message: string) => errors.push(message) } as never,
requestAppQuit: () => {
quits += 1;
},
});
deps.appState.initialArgs = { refreshKnownWords: true } as never;
await createStartupAssembly(deps).runHeadlessInitialCommand();
assert.deepEqual(errors, [
'Headless known-word refresh failed: AnkiConnect integration not enabled',
]);
assert.equal(process.exitCode, 1);
assert.equal(quits, 1);
});
+518
View File
@@ -0,0 +1,518 @@
import path from 'node:path';
import { AnkiIntegration } from '../../anki-integration';
import { isHeadlessInitialCommand } from '../../cli/args';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate } from '../../config';
import type { ConfigService } from '../../config/service';
import {
initializeOverlayRuntime as initializeOverlayRuntimeCore,
type createOverlayManager,
type SubtitleWebSocket,
type Texthooker,
} from '../../core/services';
import { resolveKeybindings, showDesktopNotification } from '../../core/utils';
import type { Logger } from '../../logger';
import { RuntimeOptionsManager } from '../../runtime-options';
import { ensureDefaultConfigBootstrap, getDefaultConfigFilePaths } from '../../shared/setup-state';
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
import type { SecondarySubMode, WindowGeometry } from '../../types';
import { mergeAiConfig } from '../../ai/config';
import type { OverlayModalRuntime } from '../overlay-runtime';
import {
composeAppReadyRuntime,
type AppReadyComposerOptions,
type composeOverlayVisibilityRuntime,
} from '../runtime/composers';
import type { createConfigDerivedRuntime } from '../runtime/domains/startup';
import { shouldAutoOpenFirstRunSetup } from '../runtime/first-run-setup-service';
import type { createOverlayGeometryRuntime } from '../runtime/overlay-geometry-runtime';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createOverlayRuntimeBootstrapHandlers } from '../runtime/overlay-runtime-bootstrap-handlers';
import {
getStartupModeFlags,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload,
} from '../runtime/startup-mode-flags';
import type { VisibleOverlayInteractionRuntime } from '../runtime/visible-overlay-interaction-runtime';
import type { AppState } from '../state';
import type { AnilistAssembly } from './anilist';
import type { FirstRunSetupAssembly } from './first-run-setup';
import type { JellyfinAssembly } from './jellyfin';
import type { MiningAssembly } from './mining';
import type { OverlayAssembly } from './overlay';
import type { PlaybackAssembly } from './playback';
import type { SettingsAssembly } from './settings';
import type { ShortcutsAssembly } from './shortcuts';
import type { StatsAssembly } from './stats';
import type { YomitanAssembly } from './yomitan';
import type { YoutubeAssembly } from './youtube';
type AppReadyMainDeps = AppReadyComposerOptions['appReadyRuntimeMainDeps'];
type OverlayManager = ReturnType<typeof createOverlayManager>;
type FatalErrorReporter = (error: unknown, options: { title: string; context: string }) => void;
/**
* What startup needs from the rest of the app. It touches nearly every domain, so most deps are
* sibling assemblies; those built after this one are reached through getters.
*/
export interface StartupAssemblyDeps {
appState: AppState;
configService: Pick<
ConfigService,
'getConfig' | 'getConfigPath' | 'getWarnings' | 'reloadConfigStrict'
>;
logger: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>;
logConfigWarning: AppReadyMainDeps['logConfigWarning'];
configDir: string;
userDataPath: string;
defaultTexthookerPort: number;
/** Electron's `dialog.showErrorBox`, injected so the assembly loads under `bun test`. */
showErrorBox: (title: string, content: string) => void;
reportFatalError: FatalErrorReporter;
requestAppQuit: () => void;
logging: Pick<AppReadyMainDeps, 'setLogLevel' | 'setLogRotation' | 'setLogFileToggles'>;
overlayManager: Pick<OverlayManager, 'getVisibleOverlayVisible' | 'getOverlayWindows'>;
overlayModalRuntime: Pick<OverlayModalRuntime, 'primeModalWindow'>;
subtitleWsService: Pick<SubtitleWebSocket, 'start'>;
annotationSubtitleWsService: Pick<SubtitleWebSocket, 'start'>;
texthookerService: Pick<Texthooker, 'isRunning' | 'start'>;
configDerivedRuntime: Pick<
ReturnType<typeof createConfigDerivedRuntime>,
'shouldAutoInitializeOverlayRuntimeFromConfig'
>;
hasMpvWebsocketPlugin: () => boolean;
loadSubtitlePosition: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
syncOverlayMpvSubtitleSuppression: () => void;
yomitan: Pick<YomitanAssembly, 'loadExtension' | 'ensureExtensionLoaded'>;
stats: Pick<StatsAssembly, 'ensureImmersionTrackerStarted' | 'immersionTrackerStartupDeps'>;
firstRunSetup: Pick<FirstRunSetupAssembly, 'service' | 'openWindow'>;
youtube: Pick<
YoutubeAssembly,
| 'getCachedMediaPathForCurrentPlayback'
| 'shouldRequireMediaCacheForCurrentPlayback'
| 'getCurrentMediaCacheSourceUrl'
>;
overlay: Pick<
OverlayAssembly,
| 'overlayVisibilityRuntime'
| 'createMainWindow'
| 'setVisibleOverlayVisible'
| 'ensureOverlayWindowsReadyForVisibilityActions'
>;
shortcuts: Pick<
ShortcutsAssembly,
| 'overlayShortcutsRuntime'
| 'refreshCurrentSessionBindings'
| 'registerGlobalShortcuts'
| 'refreshOverlayShortcuts'
>;
settings: { hotReload: Pick<SettingsAssembly['hotReload'], 'start'> };
mining: Pick<MiningAssembly, 'attachToAnkiIntegration' | 'createFieldGroupingCallback'>;
getAnilist: () => Pick<AnilistAssembly, 'refreshClientSecretStateIfEnabled'>;
getJellyfin: () => Pick<JellyfinAssembly, 'startJellyfinRemoteSession'>;
getPlayback: () => Pick<
PlaybackAssembly,
| 'createMpvClient'
| 'createMecabTokenizerAndCheck'
| 'prewarmSubtitleDictionaries'
| 'startBackgroundWarmups'
| 'subtitleProcessingController'
| 'getSubtitlePrefetchService'
| 'getLastObservedTimePos'
>;
getPluginHost: () => { loadAll: () => Promise<unknown> };
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showOverlayNotification' | 'dismissOverlayNotification'
>;
getOverlayVisibilityComposer: () => Pick<
ReturnType<typeof composeOverlayVisibilityRuntime>,
'broadcastRuntimeOptionsChanged'
>;
/** Pointer interaction and geometry stay in `main.ts`: both import Electron at module load. */
getVisibleOverlayInteraction: () => Pick<
VisibleOverlayInteractionRuntime,
'createOverlayWindowTracker' | 'bindVisibleOverlayOwner' | 'releaseVisibleOverlayOwner'
>;
getOverlayGeometry: () => Pick<
ReturnType<typeof createOverlayGeometryRuntime>,
'updateVisibleOverlayBounds'
>;
/** Runs the CLI command the app was launched with. */
handleInitialArgs: () => void;
}
export type StartupAssembly = ReturnType<typeof createStartupAssembly>;
/**
* Owns how the app starts: the app-ready sequence, the headless known-word refresh, and bringing
* up the overlay runtime and its prerequisites on demand (CLI commands, YouTube playback, the
* tray). `main.ts` hands `runAppReady` to the app lifecycle.
*/
export function createStartupAssembly(deps: StartupAssemblyDeps) {
const { appState, configService, logger } = deps;
const isHeadlessLaunch = (): boolean =>
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs));
const knownWordCacheStatePath = path.join(deps.userDataPath, 'known-words-cache.json');
function createRuntimeOptionsManager(): RuntimeOptionsManager {
return new RuntimeOptionsManager(() => configService.getConfig().ankiConnect, {
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
const playback = deps.getPlayback();
playback.subtitleProcessingController.invalidateTokenizationCache();
playback.getSubtitlePrefetchService()?.onSeek(playback.getLastObservedTimePos());
deps.getOverlayVisibilityComposer().broadcastRuntimeOptionsChanged();
deps.shortcuts.refreshOverlayShortcuts();
},
});
}
function resolveSessionKeybindings(): void {
appState.keybindings = resolveKeybindings(configService.getConfig(), DEFAULT_KEYBINDINGS);
deps.shortcuts.refreshCurrentSessionBindings();
}
/** What the subtitle websockets send a client that connects mid-line. */
function getCurrentSubtitleForWebsocket() {
return (
appState.currentSubtitleData ??
(appState.currentSubText
? {
text: appState.currentSubText,
tokens: null,
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
}
: null)
);
}
function getFrequencyOptionsForWebsocket() {
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
return {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
}
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: {
appState,
overlayManager: {
getVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () =>
deps.overlay.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
},
refreshCurrentSubtitle: () => {
deps
.getPlayback()
.subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => deps.shortcuts.overlayShortcutsRuntime.syncOverlayShortcuts(),
},
createMainWindow: () => {
if (isHeadlessLaunch()) {
return;
}
deps.overlay.createMainWindow();
},
registerGlobalShortcuts: () => {
if (isHeadlessLaunch()) {
return;
}
deps.shortcuts.registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) =>
deps
.getVisibleOverlayInteraction()
.createOverlayWindowTracker(override, targetMpvSocketPath),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.getOverlayGeometry().updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => deps.getVisibleOverlayInteraction().bindVisibleOverlayOwner(),
releaseOverlayOwner: () => deps.getVisibleOverlayInteraction().releaseVisibleOverlayOwner(),
getOverlayWindows: () => deps.overlayManager.getOverlayWindows(),
getResolvedConfig: () => configService.getConfig(),
showDesktopNotification,
showOverlayNotification: (payload) =>
deps.getOverlayNotifications().showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
deps.getOverlayNotifications().dismissOverlayNotification(id),
createFieldGroupingCallback: () => deps.mining.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => knownWordCacheStatePath,
getCachedMediaPath: (currentVideoPath, kind) =>
deps.youtube.getCachedMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () =>
deps.youtube.shouldRequireMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => deps.youtube.getCurrentMediaCacheSourceUrl(),
shouldStartAnkiIntegration: () => !isHeadlessLaunch(),
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore,
setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => {
if (isHeadlessLaunch()) {
return;
}
deps.getPlayback().startBackgroundWarmups();
},
},
});
/** Creates the overlay windows and runtime once, then wires the Anki integration it built. */
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!isHeadlessLaunch()) {
deps.overlayModalRuntime.primeModalWindow();
}
if (appState.ankiIntegration) {
deps.mining.attachToAnkiIntegration(appState.ankiIntegration);
}
deps.syncOverlayMpvSubtitleSuppression();
}
/** Creates whatever overlay commands need that app-ready skipped on a deferred start. */
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
deps.loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
resolveSessionKeybindings();
} else if (!appState.sessionBindingsInitialized) {
deps.shortcuts.refreshCurrentSessionBindings();
}
if (!appState.mpvClient) {
appState.mpvClient = deps.getPlayback().createMpvClient();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = createRuntimeOptionsManager();
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
}
async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayStartupPrereqs();
await deps.yomitan.ensureExtensionLoaded();
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
return;
}
deps.overlay.ensureOverlayWindowsReadyForVisibilityActions();
}
/** `--refresh-known-words` without the overlay: refresh the cache, then quit. */
async function runHeadlessInitialCommand(): Promise<void> {
if (!appState.initialArgs?.refreshKnownWords) {
deps.handleInitialArgs();
return;
}
const resolvedConfig = configService.getConfig();
if (resolvedConfig.ankiConnect.enabled !== true) {
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
process.exitCode = 1;
deps.requestAppQuit();
return;
}
const effectiveAnkiConfig =
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig,
new SubtitleTimingTracker(),
{ send: () => undefined } as never,
undefined,
undefined,
async () => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: false,
cancelled: true,
}),
knownWordCacheStatePath,
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
);
try {
await integration.refreshKnownWordCache();
} catch (error) {
logger.error('Headless known-word refresh failed:', error);
process.exitCode = 1;
} finally {
integration.stop();
deps.requestAppQuit();
}
}
const failHandlers = {
logError: (details: string) => logger.error(details),
showErrorBox: (title: string, details: string) => deps.showErrorBox(title, details),
quit: () => deps.requestAppQuit(),
};
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarning: (message) => logger.warn(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => deps.settings.hotReload.start(),
shouldRefreshAnilistClientSecretState: () =>
shouldRefreshAnilistOnConfigReload(appState.initialArgs),
refreshAnilistClientSecretState: (options) =>
deps.getAnilist().refreshClientSecretStateIfEnabled(options),
failHandlers,
},
criticalConfigErrorMainDeps: {
getConfigPath: () => configService.getConfigPath(),
failHandlers,
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {
ensureDefaultConfigBootstrap({
configDir: deps.configDir,
configFilePaths: getDefaultConfigFilePaths(deps.configDir),
generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG),
});
},
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
resolveKeybindings: () => resolveSessionKeybindings(),
createMpvClient: () => {
appState.mpvClient = deps.getPlayback().createMpvClient();
},
startPlugins: () => {
void deps.getPluginHost().loadAll();
},
getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => deps.logConfigWarning(warning),
...deps.logging,
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = createRuntimeOptionsManager();
},
setSecondarySubMode: (mode: SecondarySubMode) => {
deps.setSecondarySubMode(mode);
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
defaultTexthookerPort: deps.defaultTexthookerPort,
hasMpvWebsocketPlugin: () => deps.hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
deps.subtitleWsService.start(
port,
getCurrentSubtitleForWebsocket,
getFrequencyOptionsForWebsocket,
);
},
startAnnotationWebsocket: (port: number) => {
deps.annotationSubtitleWsService.start(
port,
getCurrentSubtitleForWebsocket,
getFrequencyOptionsForWebsocket,
);
},
startTexthooker: (port: number, websocketUrl?: string) => {
if (!deps.texthookerService.isRunning()) {
deps.texthookerService.start(port, websocketUrl);
}
},
log: (message) => logger.info(message),
createMecabTokenizerAndCheck: async () => {
await deps.getPlayback().createMecabTokenizerAndCheck();
},
createSubtitleTimingTracker: () => {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
},
loadYomitanExtension: async () => {
await deps.yomitan.loadExtension();
},
ensureYomitanExtensionLoaded: async () => {
await deps.yomitan.ensureExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await deps.firstRunSetup.service.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
const args = appState.initialArgs;
if (args && shouldAutoOpenFirstRunSetup(args)) {
const force = Boolean(args.setup);
if (force || snapshot.state.status !== 'completed') {
deps.firstRunSetup.openWindow(force);
}
}
},
startJellyfinRemoteSession: async () => {
await deps.getJellyfin().startJellyfinRemoteSession();
},
prewarmSubtitleDictionaries: async () => {
await deps.getPlayback().prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
deps.getPlayback().startBackgroundWarmups();
},
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode
? false
: deps.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible: boolean) =>
deps.overlay.setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
handleInitialArgs: () => deps.handleInitialArgs(),
shouldRunHeadlessInitialCommand: () => isHeadlessLaunch(),
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
createImmersionTracker: () => {
deps.stats.ensureImmersionTrackerStarted();
},
logDebug: (message: string) => {
logger.debug(message);
},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: deps.stats.immersionTrackerStartupDeps,
});
/** The app-ready sequence; a failure is reported as fatal and quits the app. */
async function runAppReady(): Promise<void> {
try {
await appReadyRuntimeRunner();
} catch (error) {
deps.reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed during app-ready startup.',
});
process.exitCode = 1;
deps.requestAppQuit();
}
}
return {
runAppReady,
runHeadlessInitialCommand,
initializeOverlayRuntime,
ensureOverlayStartupPrereqs,
ensureYoutubePlaybackRuntimeReady,
};
}
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import type { AppState } from '../state';
import { createStatsAssembly, type StatsAssemblyDeps } from './stats';
const trackingDisabledConfig: ResolvedConfig = {
...DEFAULT_CONFIG,
immersionTracking: { ...DEFAULT_CONFIG.immersionTracking, enabled: false },
};
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<StatsAssemblyDeps> = {}): StatsAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: {
immersionTracker: null,
statsServer: null,
statsStartupInProgress: false,
mpvClient: null,
mpvSocketPath: '/tmp/subminer-stats-test.sock',
mecabTokenizer: null,
ankiIntegration: null,
currentMediaPath: null,
currentMediaTitle: null,
},
getConfig: lazy('getConfig'),
logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') },
userDataPath: '/tmp/subminer-stats-assembly-test',
mainDirname: '/tmp/subminer-stats-assembly-test/dist',
defaultImmersionDbPath: '/tmp/subminer-stats-assembly-test/immersion.sqlite',
anilistRateLimiter: { acquire: lazy('acquire'), recordResponse: lazy('recordResponse') },
statsWindow: { registerToggle: lazy('registerToggle'), toggle: lazy('toggle') },
yomitanParserRuntimeDeps: {
getYomitanExt: lazy('getYomitanExt'),
getYomitanSession: lazy('getYomitanSession'),
getYomitanParserWindow: lazy('getYomitanParserWindow'),
setYomitanParserWindow: lazy('setYomitanParserWindow'),
getYomitanParserReadyPromise: lazy('getYomitanParserReadyPromise'),
setYomitanParserReadyPromise: lazy('setYomitanParserReadyPromise'),
getYomitanParserInitPromise: lazy('getYomitanParserInitPromise'),
setYomitanParserInitPromise: lazy('setYomitanParserInitPromise'),
},
getYomitanAnkiDeckName: lazy('getYomitanAnkiDeckName'),
getTokenizeSubtitle: lazy('getTokenizeSubtitle'),
createMecabTokenizerAndCheck: lazy('createMecabTokenizerAndCheck'),
getOverlayNotifications: lazy('getOverlayNotifications'),
getOverlayBounds: lazy('getOverlayBounds'),
onStatsOverlayVisibilityChanged: lazy('onStatsOverlayVisibilityChanged'),
runAnilistPostWatchUpdate: lazy('runAnilistPostWatchUpdate'),
openExternal: lazy('openExternal'),
requestAppQuit: lazy('requestAppQuit'),
...overrides,
};
}
function withTracker(
deps: StatsAssemblyDeps,
markActiveVideoWatched: () => Promise<boolean>,
): StatsAssemblyDeps {
const tracker = { markActiveVideoWatched } as unknown as NonNullable<
AppState['immersionTracker']
>;
return { ...deps, appState: { ...deps.appState, immersionTracker: tracker } };
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createStatsAssembly does not call its deps while constructing', () => {
const stats = createStatsAssembly(makeDeps());
assert.equal(typeof stats.ensureImmersionTrackerStarted, 'function');
assert.equal(typeof stats.toggleStatsOverlay, 'function');
});
test('a manual watched mark tries to start the immersion tracker first', async () => {
const infoLogs: string[] = [];
const stats = createStatsAssembly(
makeDeps({
getConfig: () => trackingDisabledConfig,
logger: {
info: (message: string) => {
infoLogs.push(message);
},
warn: () => {},
error: () => {},
debug: () => {},
},
}),
);
// With tracking disabled, the startup attempt logs and leaves no tracker to mark.
assert.equal(await stats.markActiveVideoWatched(), false);
assert.deepEqual(infoLogs, ['Immersion tracking disabled in config']);
});
for (const c of [
{ name: 'runs the AniList update after a successful mark', marked: true, anilistRuns: 1 },
{ name: 'skips the AniList update when nothing was marked', marked: false, anilistRuns: 0 },
]) {
test(`a manual watched mark ${c.name}`, async () => {
let anilistRuns = 0;
const stats = createStatsAssembly(
withTracker(
makeDeps({
runAnilistPostWatchUpdate: async () => {
anilistRuns += 1;
},
}),
async () => c.marked,
),
);
assert.equal(await stats.markActiveVideoWatched(), c.marked);
assert.equal(anilistRuns, c.anilistRuns);
});
}
test('a failed AniList update does not undo a manual watched mark', async () => {
const warnings: string[] = [];
const stats = createStatsAssembly(
withTracker(
makeDeps({
logger: {
info: () => {},
warn: (message: string) => {
warnings.push(message);
},
error: () => {},
debug: () => {},
},
runAnilistPostWatchUpdate: async () => {
throw new Error('AniList unavailable');
},
}),
async () => true,
),
);
assert.equal(await stats.markActiveVideoWatched(), true);
assert.deepEqual(warnings, [
'Failed to run AniList post-watch update after manual watched mark:',
]);
});
+353
View File
@@ -0,0 +1,353 @@
import path from 'node:path';
import { ImmersionTrackerService } from '../../core/services';
import { createCoverArtFetcher } from '../../core/services/anilist/cover-art-fetcher';
import type { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
import { resolveLegacyVocabularyPosFromTokens } from '../../core/services/immersion-tracker/legacy-vocabulary-pos';
import type { StatsWindowOptions } from '../../core/services/stats-window';
import { readBundledTmdbApiKey } from '../../core/services/tmdb/bundled-api-key';
import { createLiveActionMetadataResolver } from '../../core/services/tmdb/live-action-resolver';
import { createTmdbApiKeyResolver, createTmdbClient } from '../../core/services/tmdb/tmdb-client';
import { createLogger, type Logger } from '../../logger';
import type { ResolvedConfig } from '../../types';
import { createEnsureBackgroundStatsServerHandler } from '../runtime/background-stats-startup';
import { createImmersionMediaRuntime } from '../runtime/domains/startup';
import {
createImmersionTrackerStartupHandler,
type ImmersionTrackerStartupDeps,
} from '../runtime/immersion-startup';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import {
createRunStatsCliCommandHandler,
writeStatsCliCommandResponse,
} from '../runtime/stats-cli-command';
import {
createStatsServerRuntime,
type StatsServerRuntimeDeps,
} from '../runtime/stats-server-runtime';
import type { AppState } from '../state';
import type { PlaybackAssembly } from './playback';
import type { CliSlice, IpcSlice } from './slices';
import type { YomitanAssembly } from './yomitan';
/**
* What the stats domain needs from the rest of the app. Siblings built after this assembly in
* `main.ts` are reached through callbacks.
*/
export interface StatsAssemblyDeps {
appState: Pick<
AppState,
| 'immersionTracker'
| 'statsServer'
| 'statsStartupInProgress'
| 'mpvClient'
| 'mpvSocketPath'
| 'mecabTokenizer'
| 'ankiIntegration'
| 'currentMediaPath'
| 'currentMediaTitle'
>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'info' | 'warn' | 'error' | 'debug'>;
userDataPath: string;
/** Compiled main-process directory; the stats bundle, preload and TMDB key sit beside it. */
mainDirname: string;
defaultImmersionDbPath: string;
anilistRateLimiter: ReturnType<typeof createAnilistRateLimiter>;
/** The stats window module reaches Electron's `dialog`, so it is injected. */
statsWindow: {
registerToggle: (options: StatsWindowOptions) => void;
toggle: (options: StatsWindowOptions) => Promise<void>;
};
yomitanParserRuntimeDeps: ReturnType<YomitanAssembly['getParserRuntimeDeps']>;
getYomitanAnkiDeckName: () => Promise<string>;
getTokenizeSubtitle: PlaybackAssembly['getTokenizeSubtitleDeferred'];
createMecabTokenizerAndCheck: () => Promise<void>;
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification'
>;
getOverlayBounds: () => ReturnType<StatsWindowOptions['resolveBounds']>;
onStatsOverlayVisibilityChanged: (visible: boolean) => void;
/** Forced AniList post-watch update after a manual watched mark. */
runAnilistPostWatchUpdate: () => Promise<void>;
openExternal: (url: string) => Promise<void>;
requestAppQuit: () => void;
}
export type StatsAssembly = ReturnType<typeof createStatsAssembly>;
/**
* Owns the immersion tracker's startup, the stats HTTP server (in-process or background), the
* stats overlay window options, and the `--stats` CLI command.
*/
export function createStatsAssembly(deps: StatsAssemblyDeps) {
const { appState, logger } = deps;
// Release builds stage a project TMDB key next to the compiled main process.
const bundledTmdbApiKey = readBundledTmdbApiKey(deps.mainDirname);
let hasAttemptedImmersionTrackerStartup = false;
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
const fallback = term.trim() ? [term.trim()] : [];
try {
const tokenizeSubtitleDeferred = deps.getTokenizeSubtitle();
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
const tokens = tokenized?.tokens ?? [];
if (tokens.length === 0) return fallback;
const seen = new Set<string>();
const headwords: string[] = [];
for (const token of tokens) {
const headword = (token.headword || token.surface).trim();
if (!headword || seen.has(headword)) continue;
seen.add(headword);
headwords.push(headword);
}
return headwords.length > 0 ? headwords : fallback;
} catch (error) {
logger.debug(
'Failed to resolve sentence-search headwords:',
error instanceof Error ? error.message : String(error),
);
return fallback;
}
}
const statsServerRuntime = createStatsServerRuntime({
userDataPath: deps.userDataPath,
statsDistPath: path.join(deps.mainDirname, '..', 'stats', 'dist'),
getResolvedConfig: () => deps.getConfig(),
getImmersionTracker: () => appState.immersionTracker,
setAppStateStatsServer: (server) => {
appState.statsServer = server;
},
getMpvSocketPath: () => appState.mpvSocketPath,
...(deps.yomitanParserRuntimeDeps satisfies Pick<
StatsServerRuntimeDeps,
keyof ReturnType<YomitanAssembly['getParserRuntimeDeps']>
>),
getYomitanAnkiDeckName: () => deps.getYomitanAnkiDeckName(),
getAnilistRateLimiter: () => deps.anilistRateLimiter,
getBundledTmdbApiKey: () => bundledTmdbApiKey,
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
},
resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term),
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
setStatsStartupInProgress: (inProgress) => {
appState.statsStartupInProgress = inProgress;
},
});
const {
stopStatsServer,
ensureStatsServerStarted,
ensureBackgroundStatsServerStarted,
stopBackgroundStatsServer,
} = statsServerRuntime;
const immersionMediaRuntime = createImmersionMediaRuntime({
getResolvedConfig: () => deps.getConfig(),
defaultImmersionDbPath: deps.defaultImmersionDbPath,
getTracker: () => appState.immersionTracker,
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
logDebug: (message) => logger.debug(message),
logInfo: (message) => logger.info(message),
});
const coverArtFetcher = createCoverArtFetcher(
deps.anilistRateLimiter,
createLogger('main:stats-cover-art'),
{
liveAction: createLiveActionMetadataResolver(
createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => deps.getConfig().tmdb,
() => bundledTmdbApiKey,
),
}),
createLogger('main:tmdb'),
),
},
);
// One options object serves the overlay's IPC toggle and the session-action toggle.
const statsOverlayOptions: StatsWindowOptions = {
preloadPath: path.join(deps.mainDirname, 'preload-stats.js'),
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
onStartupError: (error) =>
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(
`Stats server startup failed: ${error instanceof Error ? error.message : String(error)}`,
{ title: 'Stats' },
),
getToggleKey: () => deps.getConfig().stats.toggleKey,
resolveBounds: () => deps.getOverlayBounds(),
onVisibilityChanged: (visible) => {
deps.onStatsOverlayVisibilityChanged(visible);
},
};
const resolveLegacyVocabularyPos = async (row: {
headword: string;
word: string;
reading: string | null;
}) => {
const tokenizer = 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);
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens);
if (resolved) {
return resolved;
}
}
return null;
};
const immersionTrackerStartupDeps: ImmersionTrackerStartupDeps = {
getResolvedConfig: () => deps.getConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) =>
new ImmersionTrackerService({
...params,
resolveLegacyVocabularyPos,
}),
setTracker: (tracker) => {
const trackerHasChanged =
appState.immersionTracker !== null && appState.immersionTracker !== tracker;
if (trackerHasChanged && appState.statsServer) {
void stopStatsServer().catch((error: unknown) => {
logger.warn('Failed to stop stats server while replacing immersion tracker.', error);
});
appState.statsServer = null;
}
appState.immersionTracker = tracker as ImmersionTrackerService | null;
appState.immersionTracker?.setCoverArtFetcher(coverArtFetcher);
if (tracker) {
// Start HTTP stats server
if (!appState.statsServer) {
const config = deps.getConfig();
if (config.stats.autoStartServer) {
void ensureStatsServerStarted().catch((error: unknown) => {
logger.warn('Failed to auto-start stats server.', error);
});
}
}
// Register stats overlay toggle IPC handler (idempotent)
deps.statsWindow.registerToggle(statsOverlayOptions);
}
},
getMpvClient: () => appState.mpvClient,
shouldAutoConnectMpv: () => !appState.statsStartupInProgress,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
logInfo: (message) => logger.info(message),
logDebug: (message) => logger.debug(message),
logWarn: (message, details) => logger.warn(message, details),
};
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
immersionTrackerStartupDeps,
);
/** Starts the tracker at most once per process; later calls are no-ops. */
function ensureImmersionTrackerStarted(): void {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
return;
}
hasAttemptedImmersionTrackerStartup = true;
createImmersionTrackerStartup();
}
// Stats startup must not auto-connect mpv, so the tracker starts under this flag.
function ensureImmersionTrackerStartedForStats(): void {
appState.statsStartupInProgress = true;
try {
ensureImmersionTrackerStarted();
} finally {
appState.statsStartupInProgress = false;
}
}
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
isStatsAutoStartEnabled: () => deps.getConfig().stats.autoStartServer,
isImmersionTrackingEnabled: () => deps.getConfig().immersionTracking?.enabled !== false,
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
});
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => deps.getConfig(),
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStartedForStats(),
ensureVocabularyCleanupTokenizerReady: async () => {
await deps.createMecabTokenizerAndCheck();
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: async () => (await ensureStatsServerStarted()).url,
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
openExternal: (url: string) => deps.openExternal(url),
writeResponse: (responsePath, payload) => {
writeStatsCliCommandResponse(responsePath, payload);
},
exitAppWithCode: (code) => {
process.exitCode = code;
deps.requestAppQuit();
},
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
logError: (message, error) => logger.error(message, error),
});
const ipc: IpcSlice<'getImmersionTracker'> = {
getImmersionTracker: () => appState.immersionTracker,
};
const cli: CliSlice<'runStatsCommand' | 'ensureBackgroundStatsServer'> = {
runStatsCommand: (args, source) => runStatsCliCommand(args, source),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
};
return {
ipc,
cli,
immersionMediaRuntime,
immersionTrackerStartupDeps,
ensureImmersionTrackerStarted,
stopStatsServer,
ensureBackgroundStatsServer,
runStatsCliCommand,
toggleStatsOverlay: () => deps.statsWindow.toggle(statsOverlayOptions),
recordCardsMined: (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
},
/** Marks the active video watched, then forces the AniList post-watch update. */
markActiveVideoWatched: async (): Promise<boolean> => {
ensureImmersionTrackerStarted();
const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false;
if (marked) {
try {
await deps.runAnilistPostWatchUpdate();
} catch (error) {
logger.warn('Failed to run AniList post-watch update after manual watched mark:', error);
}
}
return marked;
},
};
}
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Menu, NativeImage, Tray } from 'electron';
import { createTrayAssembly, type TrayAssemblyDeps, type TrayMenuActions } from './tray';
const MENU_ACTIONS = [
'openSessionHelpModal',
'openChangelogModal',
'openTexthookerInBrowser',
'showTexthookerPage',
'showFirstRunSetup',
'openFirstRunSetupWindow',
'getDictionaryBackend',
'openHachidoriSettings',
'openYomitanSettings',
'openConfigSettingsWindow',
'openSyncUiWindow',
'openYoutubeBrowserWindow',
'exportLogs',
'openJellyfinSetupWindow',
'isJellyfinConfigured',
'isJellyfinDiscoveryActive',
'toggleJellyfinDiscovery',
'openAnilistSetupWindow',
'checkForUpdates',
'quitApp',
] satisfies Array<keyof TrayMenuActions>;
function menuActions(makeAction: (name: string) => () => unknown): TrayMenuActions {
return Object.fromEntries(
MENU_ACTIONS.map((name) => [name, makeAction(name)]),
) as unknown as TrayMenuActions;
}
// Every callback throws, so a test fails if the assembly calls a dep it should only call later.
function makeDeps(overrides: Partial<TrayAssemblyDeps> = {}): TrayAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
return {
appState: { overlayRuntimeInitialized: false },
logger: { warn: lazy('warn') },
iconRoots: { resourcesPath: '/missing', appPath: '/missing', mainDirname: '/missing' },
electron: {
createTray: lazy('createTray'),
createImageFromPath: lazy('createImageFromPath'),
createEmptyImage: lazy('createEmptyImage'),
buildMenuFromTemplate: lazy('buildMenuFromTemplate'),
},
menu: menuActions(lazy),
initializeOverlayRuntime: lazy('initializeOverlayRuntime'),
setVisibleOverlayVisible: lazy('setVisibleOverlayVisible'),
...overrides,
};
}
// main.ts builds the assembly before most siblings exist, so construction must stay lazy.
test('createTrayAssembly does not call its deps while constructing', () => {
const tray = createTrayAssembly(makeDeps());
assert.equal(tray.hasTray(), false);
});
test('refreshMenu rebuilds the menu only once the tray exists', () => {
let menusBuilt = 0;
let menusSet = 0;
const fakeTray = {
setToolTip: () => {},
setContextMenu: () => {
menusSet += 1;
},
on: () => {},
} as unknown as Tray;
const tray = createTrayAssembly(
makeDeps({
logger: { warn: () => {} },
electron: {
createTray: () => fakeTray,
createImageFromPath: () => ({ isEmpty: () => true }) as NativeImage,
createEmptyImage: () => ({ isEmpty: () => true }) as NativeImage,
buildMenuFromTemplate: () => {
menusBuilt += 1;
return {} as Menu;
},
},
menu: menuActions(() => () => false),
}),
);
tray.refreshMenu();
assert.equal(menusBuilt, 0);
tray.ensureTray();
assert.equal(tray.hasTray(), true);
const menusAfterCreate = menusSet;
tray.refreshMenu();
assert.equal(menusSet, menusAfterCreate + 1);
assert.equal(menusBuilt, menusSet);
});
+107
View File
@@ -0,0 +1,107 @@
import path from 'node:path';
import fs from 'node:fs';
import type { Menu, MenuItemConstructorOptions, NativeImage, Tray } from 'electron';
import type { Logger } from '../../logger';
import {
buildTrayMenuTemplateRuntime,
resolveTrayIconPathRuntime,
} from '../runtime/domains/overlay';
import type { createBuildTrayMenuTemplateHandler } from '../runtime/tray-main-actions';
import { createTrayRuntimeHandlers } from '../runtime/tray-runtime-handlers';
import type { AppState } from '../state';
type TrayMenuTemplateDeps = Parameters<
typeof createBuildTrayMenuTemplateHandler<MenuItemConstructorOptions>
>[0];
/** The tray menu's entries; each one calls into the domain that owns it. */
export type TrayMenuActions = Omit<
TrayMenuTemplateDeps,
| 'buildTrayMenuTemplateRuntime'
| 'platform'
| 'initializeOverlayRuntime'
| 'isOverlayRuntimeInitialized'
| 'showWindowsMpvLauncherSetup'
>;
export interface TrayAssemblyDeps {
appState: Pick<AppState, 'overlayRuntimeInitialized'>;
logger: Pick<Logger, 'warn'>;
/** Where the packaged and dev builds keep the tray icon. */
iconRoots: { resourcesPath: string; appPath: string; mainDirname: string };
/** Electron tray primitives, injected so the assembly loads under `bun test`. */
electron: {
createTray: (icon: NativeImage) => Tray;
createImageFromPath: (iconPath: string) => NativeImage;
createEmptyImage: () => NativeImage;
buildMenuFromTemplate: (template: MenuItemConstructorOptions[]) => Menu;
};
menu: TrayMenuActions;
initializeOverlayRuntime: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
}
export type TrayAssembly = ReturnType<typeof createTrayAssembly>;
/**
* Owns the system tray icon and its menu. The tray exists in background mode and for managed
* playback; clicking it shows the overlay.
*/
export function createTrayAssembly(deps: TrayAssemblyDeps) {
const { appState } = deps;
let tray: Tray | null = null;
const { ensureTray, destroyTray } = createTrayRuntimeHandlers({
resolveTrayIconPathDeps: {
resolveTrayIconPathRuntime,
platform: process.platform,
resourcesPath: deps.iconRoots.resourcesPath,
appPath: deps.iconRoots.appPath,
dirname: deps.iconRoots.mainDirname,
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
},
buildTrayMenuTemplateDeps: {
...deps.menu,
buildTrayMenuTemplateRuntime,
platform: process.platform,
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
},
ensureTrayDeps: {
getTray: () => tray,
setTray: (nextTray) => {
tray = nextTray as Tray | null;
},
createImageFromPath: (iconPath) => deps.electron.createImageFromPath(iconPath),
createEmptyImage: () => deps.electron.createEmptyImage(),
createTray: (icon) => deps.electron.createTray(icon as NativeImage),
trayTooltip: 'SubMiner',
platform: process.platform,
logWarn: (message) => deps.logger.warn(message),
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
setVisibleOverlayVisible: (visible) => deps.setVisibleOverlayVisible(visible),
},
destroyTrayDeps: {
getTray: () => tray,
setTray: (nextTray) => {
tray = nextTray as Tray | null;
},
},
buildMenuFromTemplate: (template) => deps.electron.buildMenuFromTemplate(template),
});
return {
ensureTray,
destroyTray,
hasTray: (): boolean => Boolean(tray),
/** Rebuilds the menu so state-dependent entries (setup, Jellyfin) stay current. */
refreshMenu: (): void => {
if (tray) {
ensureTray();
}
},
};
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
import { DEFAULT_CONFIG } from '../../config/definitions';
import type { ResolvedConfig } from '../../types';
import type { DictionaryBackend } from '../../types/config';
import { createYomitanAssembly, type YomitanAssemblyDeps } from './yomitan';
type InvokeHandler = (event: IpcMainInvokeEvent, value: unknown) => Promise<void>;
function configWithExternalProfile(externalProfilePath: string): ResolvedConfig {
return {
...DEFAULT_CONFIG,
yomitan: { ...DEFAULT_CONFIG.yomitan, externalProfilePath },
};
}
// Every callback except getConfig throws, so a test fails if the assembly calls a dep it
// should only call later. getConfig is read once during construction for the profile policy.
function makeDeps(
overrides: Partial<YomitanAssemblyDeps> & { backend?: DictionaryBackend } = {},
): YomitanAssemblyDeps {
const lazy = (name: string) => (): never => {
throw new Error(`${name} called unexpectedly`);
};
const { backend = 'yomitan', ...rest } = overrides;
return {
appState: {
yomitanExt: null,
yomitanSession: null,
yomitanParserWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
yomitanSettingsWindow: null,
},
getConfig: () => DEFAULT_CONFIG,
logger: { info: lazy('info'), warn: lazy('warn'), error: lazy('error'), debug: lazy('debug') },
userDataPath: '/tmp/subminer-yomitan-assembly-test',
dictionaryBackend: backend,
hachidori: { ensureLoaded: lazy('hachidori.ensureLoaded'), getSession: lazy('getSession') },
getOverlayWindows: lazy('getOverlayWindows'),
getOverlayNotifications: lazy('getOverlayNotifications'),
ipcMain: { handle: lazy('ipcMain.handle') },
openExternal: lazy('openExternal'),
...rest,
};
}
test('createYomitanAssembly only reads config while constructing', () => {
const yomitan = createYomitanAssembly(makeDeps());
assert.equal(typeof yomitan.ensureExtensionLoaded, 'function');
assert.equal(yomitan.profilePolicy.isExternalReadOnlyMode(), false);
});
test('an external Yomitan profile is read-only for the Yomitan backend only', () => {
const getConfig = () => configWithExternalProfile('/profiles/main');
assert.equal(
createYomitanAssembly(makeDeps({ getConfig })).profilePolicy.isExternalReadOnlyMode(),
true,
);
assert.equal(
createYomitanAssembly(
makeDeps({ getConfig, backend: 'hachidori' }),
).profilePolicy.isExternalReadOnlyMode(),
false,
);
});
test('Yomitan settings refuse to open in read-only external-profile mode', () => {
const notifications: string[] = [];
const yomitan = createYomitanAssembly(
makeDeps({
getConfig: () => configWithExternalProfile('/profiles/main'),
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
getOverlayNotifications: () => ({
showConfiguredStatusNotification: (message: string) => {
notifications.push(message);
},
}),
}),
);
assert.equal(yomitan.openDictionarySettings(), false);
assert.deepEqual(notifications, [
'Yomitan settings unavailable while using read-only external-profile mode.',
]);
});
test('dictionary links open only from the main frame of an overlay window', async () => {
const overlaySender = { mainFrame: 'overlay-main-frame' };
const overlayWindow = { webContents: overlaySender } as unknown as BrowserWindow;
let handler: InvokeHandler | null = null;
const opened: string[] = [];
const yomitan = createYomitanAssembly(
makeDeps({
backend: 'hachidori',
getOverlayWindows: () => [overlayWindow],
ipcMain: {
handle: (_channel: string, listener: InvokeHandler) => {
handler = listener;
},
} as YomitanAssemblyDeps['ipcMain'],
openExternal: async (url) => {
opened.push(url);
},
}),
);
yomitan.registerDictionaryExternalLinkHandler();
assert.ok(handler);
const invoke = handler as InvokeHandler;
const event = (sender: unknown, senderFrame: unknown) =>
({ sender, senderFrame }) as unknown as IpcMainInvokeEvent;
await invoke(event(overlaySender, 'overlay-main-frame'), 'https://example.com/entry');
await assert.rejects(invoke(event(overlaySender, 'child-frame'), 'https://example.com/x'));
await assert.rejects(
invoke(event({ mainFrame: 'other' }, 'other'), 'https://example.com/x'),
/only available from the active overlay/,
);
assert.deepEqual(opened, ['https://example.com/entry']);
});
+335
View File
@@ -0,0 +1,335 @@
import type { BrowserWindow, Extension, IpcMain, Session } from 'electron';
import {
clearYomitanParserCachesForWindow,
destroyYomitanSettingsWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
loadYomitanExtension as loadYomitanExtensionCore,
openYomitanSettingsWindow,
} from '../../core/services';
import { generateSentenceFurigana } from '../../core/services/tokenizer/sentence-furigana';
import type { Logger } from '../../logger';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
} from '../../shared/dictionary-external-link';
import type { ResolvedConfig } from '../../types';
import type { DictionaryBackend } from '../../types/config';
import type { createOverlayNotificationsRuntime } from '../runtime/overlay-notifications-runtime';
import { createYomitanAnkiServerSyncRuntime } from '../runtime/yomitan-anki-server-sync';
import { reloadOverlayWindowsForYomitanContentScripts } from '../runtime/yomitan-extension-overlay-reload';
import { createYomitanExtensionRuntime } from '../runtime/yomitan-extension-runtime';
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import { createYomitanSettingsRuntime } from '../runtime/yomitan-settings-runtime';
import type { AppState } from '../state';
/** The bundled Hachidori extension. Injected because it reaches Electron's session API. */
export interface HachidoriExtensionLike {
ensureLoaded: () => Promise<Extension>;
getSession: () => Session;
}
/**
* What the dictionary-extension domain needs from the rest of the app. Siblings built after
* this assembly in `main.ts` are reached through callbacks.
*/
export interface YomitanAssemblyDeps {
appState: Pick<
AppState,
| 'yomitanExt'
| 'yomitanSession'
| 'yomitanParserWindow'
| 'yomitanParserReadyPromise'
| 'yomitanParserInitPromise'
| 'yomitanSettingsWindow'
>;
getConfig: () => ResolvedConfig;
logger: Pick<Logger, 'info' | 'warn' | 'error' | 'debug'>;
userDataPath: string;
/** Fixed for the process lifetime; backend changes apply on restart. */
dictionaryBackend: DictionaryBackend;
hachidori: HachidoriExtensionLike;
getOverlayWindows: () => BrowserWindow[];
getOverlayNotifications: () => Pick<
ReturnType<typeof createOverlayNotificationsRuntime>,
'showConfiguredStatusNotification'
>;
ipcMain: Pick<IpcMain, 'handle'>;
openExternal: (url: string) => Promise<void>;
}
export type YomitanAssembly = ReturnType<typeof createYomitanAssembly>;
/**
* Owns the Yomitan/Hachidori extension: loading, the parser runtime deps every tokenizer
* caller shares, the external-profile policy, and the dictionary settings windows. The
* inactive backend's settings window stays openable so users can manage both.
*/
export function createYomitanAssembly(deps: YomitanAssemblyDeps) {
const { appState, logger, dictionaryBackend, hachidori } = deps;
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
let hachidoriSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanExtension: Extension | null = null;
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
const profilePolicy = createYomitanProfilePolicy({
externalProfilePath:
dictionaryBackend === 'yomitan' ? deps.getConfig().yomitan.externalProfilePath : '',
logInfo: (message) => logger.info(message),
});
function getParserRuntimeDeps() {
return {
getYomitanExt: () => appState.yomitanExt,
getYomitanSession: () => appState.yomitanSession,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window: BrowserWindow | null) => {
appState.yomitanParserWindow = window;
},
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
appState.yomitanParserReadyPromise = promise;
},
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
appState.yomitanParserInitPromise = promise;
},
};
}
const extensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore: async (loadDeps) => {
if (dictionaryBackend === 'yomitan') return loadYomitanExtensionCore(loadDeps);
try {
const extension = await hachidori.ensureLoaded();
loadDeps.setYomitanExtension(extension);
loadDeps.setYomitanSession(hachidori.getSession());
return extension;
} catch (error) {
logger.error('Failed to load Hachidori extension:', error);
loadDeps.setYomitanExtension(null);
loadDeps.setYomitanSession(null);
return null;
}
},
userDataPath: deps.userDataPath,
externalProfilePath: profilePolicy.externalProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow,
setYomitanParserWindow: (window) => {
appState.yomitanParserWindow = window as BrowserWindow | null;
},
setYomitanParserReadyPromise: (promise) => {
appState.yomitanParserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
setYomitanExtension: (extension) => {
appState.yomitanExt = extension;
},
setYomitanSession: (nextSession) => {
appState.yomitanSession = nextSession;
},
getYomitanExtension: () => appState.yomitanExt,
getLoadInFlight: () => yomitanLoadInFlight,
setLoadInFlight: (promise) => {
yomitanLoadInFlight = promise;
},
onYomitanExtensionLoaded: () => {
const reloaded = reloadOverlayWindowsForYomitanContentScripts(
deps.getOverlayWindows(),
(message, error) => logger.warn(message, error),
);
if (reloaded > 0) {
logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`);
}
},
});
const { syncYomitanDefaultProfileAnkiServer } = createYomitanAnkiServerSyncRuntime({
isExternalReadOnlyMode: () => profilePolicy.isExternalReadOnlyMode(),
getResolvedConfig: () => deps.getConfig(),
getYomitanParserRuntimeDeps: () => getParserRuntimeDeps(),
logError: (message, ...args) => {
logger.error(message, ...args);
},
logInfo: (message, ...args) => {
logger.info(message, ...args);
},
});
async function loadExtension(): Promise<Extension | null> {
const extension = await extensionRuntime.loadYomitanExtension();
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
}
async function ensureExtensionLoaded(): Promise<Extension | null> {
const extension = await extensionRuntime.ensureYomitanExtensionLoaded();
if (extension && !profilePolicy.isExternalReadOnlyMode()) {
await syncYomitanDefaultProfileAnkiServer();
}
return extension;
}
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureExtensionLoaded(),
getYomitanExtension: () => appState.yomitanExt,
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
openYomitanSettingsWindow({
yomitanExt: yomitanExt as Extension,
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
setWindow: (window) => setWindow(window as BrowserWindow | null),
yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession,
onWindowClosed: () => {
if (appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
},
});
},
getExistingWindow: () => appState.yomitanSettingsWindow,
setWindow: (window) => {
appState.yomitanSettingsWindow = window as BrowserWindow | null;
},
logWarn: (message) => logger.warn(message),
logError: (message, error) => logger.error(message, error),
});
function openYomitanSettings(): boolean {
if (dictionaryBackend === 'hachidori') {
if (deps.getConfig().yomitan.externalProfilePath.trim()) {
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
return false;
}
inactiveYomitanLoad ??= inactiveYomitanExtension
? Promise.resolve(inactiveYomitanExtension)
: loadYomitanExtensionCore({
userDataPath: deps.userDataPath,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: (extension) => {
inactiveYomitanExtension = extension;
},
setYomitanSession: () => {},
}).finally(() => {
inactiveYomitanLoad = null;
});
void (
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
)
.then((extension) =>
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => inactiveYomitanSettingsWindow,
setWindow: (window) => {
inactiveYomitanSettingsWindow = window;
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
return true;
}
if (profilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
);
deps
.getOverlayNotifications()
.showConfiguredStatusNotification(message, { variant: 'warning' });
return false;
}
openYomitanSettingsHandler();
return true;
}
function openHachidoriSettings(): void {
void (dictionaryBackend === 'hachidori' ? ensureExtensionLoaded() : hachidori.ensureLoaded())
.then((extension) =>
openYomitanSettingsWindow({
backend: 'hachidori',
yomitanExt: extension,
yomitanSession: hachidori.getSession(),
getExistingWindow: () => hachidoriSettingsWindow,
setWindow: (window) => {
hachidoriSettingsWindow = window;
},
onWindowClosed: () => {
if (dictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
}
function openDictionarySettings(): boolean {
if (dictionaryBackend === 'hachidori') {
openHachidoriSettings();
return true;
}
return openYomitanSettings();
}
async function getCurrentAnkiDeckName(): Promise<string> {
await extensionRuntime.ensureYomitanExtensionLoaded();
return getYomitanCurrentAnkiDeckNameCore(getParserRuntimeDeps(), {
error: (message, ...args) => {
logger.error(message, ...args);
},
info: (message, ...args) => {
logger.info(message, ...args);
},
});
}
return {
profilePolicy,
getParserRuntimeDeps,
loadExtension,
ensureExtensionLoaded,
syncYomitanDefaultProfileAnkiServer,
getCurrentAnkiDeckName,
openYomitanSettings,
openHachidoriSettings,
openDictionarySettings,
generateSentenceFurigana: (text: string, highlightedText?: string): Promise<string | null> =>
generateSentenceFurigana(text, highlightedText, getParserRuntimeDeps(), logger),
/** The session overlay windows load the active backend's content scripts from. */
getOverlaySession: (): Session | null =>
dictionaryBackend === 'hachidori' ? hachidori.getSession() : appState.yomitanSession,
/** Dictionary settings windows the overlay must not cover. */
getSettingsWindows: (): Array<BrowserWindow | null> => [
appState.yomitanSettingsWindow,
hachidoriSettingsWindow,
inactiveYomitanSettingsWindow,
],
destroySettingsWindows: (): void => {
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
},
/** Hachidori dictionary entries link out; only the active overlay's main frame may ask. */
registerDictionaryExternalLinkHandler: (): void => {
deps.ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
if (
dictionaryBackend !== 'hachidori' ||
event.senderFrame !== event.sender.mainFrame ||
!deps.getOverlayWindows().some((window) => window.webContents === event.sender)
) {
throw new Error('Dictionary links are only available from the active overlay');
}
await deps.openExternal(parseDictionaryExternalUrl(value));
});
},
};
}