mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
refactor(main): move IPC, CLI and app-ready wiring into assemblies (#311)
This commit is contained in:
1 parent
655510cf2e
commit
696462a82e
33 files changed
+3719
-2101
No files matched your search
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
>;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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:',
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user