mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor: split main runtime wrappers into focused modules
This commit is contained in:
89
src/main/runtime/anki-actions.test.ts
Normal file
89
src/main/runtime/anki-actions.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
} from './anki-actions';
|
||||
|
||||
test('update last card handler forwards integration/clipboard/osd deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const updateLastCard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
readClipboardText: () => 'clipboard-value',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
updateLastCardFromClipboardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.readClipboardText(), 'clipboard-value');
|
||||
options.showMpvOsd('ok');
|
||||
calls.push('core');
|
||||
},
|
||||
});
|
||||
|
||||
await updateLastCard();
|
||||
assert.deepEqual(calls, ['osd:ok', 'core']);
|
||||
});
|
||||
|
||||
test('refresh known word cache handler throws when Anki integration missing', async () => {
|
||||
const refresh = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () => null,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
|
||||
});
|
||||
|
||||
test('trigger and mark handlers delegate to core services', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
triggerFieldGroupingCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('group');
|
||||
calls.push('group-core');
|
||||
},
|
||||
});
|
||||
const markAudio = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
markLastCardAsAudioCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
options.showMpvOsd('mark');
|
||||
calls.push('mark-core');
|
||||
},
|
||||
});
|
||||
|
||||
await triggerFieldGrouping();
|
||||
await markAudio();
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
let created = false;
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => integration,
|
||||
getMpvClient: () => mpvClient,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
mineSentenceCardCore: async (options) => {
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.mpvClient, mpvClient);
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
});
|
||||
90
src/main/runtime/anki-actions.ts
Normal file
90
src/main/runtime/anki-actions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
type AnkiIntegrationLike = {
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.updateLastCardFromClipboardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
readClipboardText: deps.readClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshKnownWordCacheHandler(deps: {
|
||||
getAnkiIntegration: () => AnkiIntegrationLike | null;
|
||||
missingIntegrationMessage: string;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const anki = deps.getAnkiIntegration();
|
||||
if (!anki) {
|
||||
throw new Error(deps.missingIntegrationMessage);
|
||||
}
|
||||
await anki.refreshKnownWordCache();
|
||||
};
|
||||
}
|
||||
|
||||
export function createTriggerFieldGroupingHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
triggerFieldGroupingCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.triggerFieldGroupingCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
markLastCardAsAudioCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
await deps.markLastCardAsAudioCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
deps.recordCardsMined(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
96
src/main/runtime/cli-command-context.test.ts
Normal file
96
src/main/runtime/cli-command-context.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCliCommandContext } from './cli-command-context';
|
||||
|
||||
function createDeps() {
|
||||
let socketPath = '/tmp/mpv.sock';
|
||||
const logs: string[] = [];
|
||||
const browserErrors: string[] = [];
|
||||
|
||||
return {
|
||||
deps: {
|
||||
getSocketPath: () => socketPath,
|
||||
setSocketPath: (value: string) => {
|
||||
socketPath = value;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
showOsd: () => {},
|
||||
texthookerService: {} as never,
|
||||
getTexthookerPort: () => 6677,
|
||||
setTexthookerPort: () => {},
|
||||
shouldOpenBrowser: () => true,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: (url: string) => browserErrors.push(url),
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
toggleInvisibleOverlay: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
setInvisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({} as never),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
openJellyfinSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({} as never),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {},
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 1000,
|
||||
schedule: (fn: () => void) => setTimeout(fn, 0),
|
||||
logInfo: (message: string) => {
|
||||
logs.push(`i:${message}`);
|
||||
},
|
||||
logWarn: (message: string) => {
|
||||
logs.push(`w:${message}`);
|
||||
},
|
||||
logError: (message: string) => {
|
||||
logs.push(`e:${message}`);
|
||||
},
|
||||
},
|
||||
getLogs: () => logs,
|
||||
getBrowserErrors: () => browserErrors,
|
||||
};
|
||||
}
|
||||
|
||||
test('cli command context proxies socket path getters/setters', () => {
|
||||
const { deps } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
assert.equal(context.getSocketPath(), '/tmp/mpv.sock');
|
||||
context.setSocketPath('/tmp/next.sock');
|
||||
assert.equal(context.getSocketPath(), '/tmp/next.sock');
|
||||
});
|
||||
|
||||
test('cli command context openInBrowser reports failures', async () => {
|
||||
const { deps, getBrowserErrors } = createDeps();
|
||||
deps.openExternal = async () => {
|
||||
throw new Error('no browser');
|
||||
};
|
||||
const context = createCliCommandContext(deps);
|
||||
context.openInBrowser('https://example.com');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(getBrowserErrors(), ['https://example.com']);
|
||||
});
|
||||
|
||||
test('cli command context log methods map to deps loggers', () => {
|
||||
const { deps, getLogs } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
context.log('info');
|
||||
context.warn('warn');
|
||||
context.error('error', new Error('x'));
|
||||
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
|
||||
});
|
||||
106
src/main/runtime/cli-command-context.ts
Normal file
106
src/main/runtime/cli-command-context.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from '../cli-runtime';
|
||||
|
||||
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
|
||||
|
||||
export type CliCommandContextFactoryDeps = {
|
||||
getSocketPath: () => string;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getMpvClient: () => MpvClientLike;
|
||||
showOsd: (text: string) => void;
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
shouldOpenBrowser: () => boolean;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
setInvisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
};
|
||||
|
||||
export function createCliCommandContext(
|
||||
deps: CliCommandContextFactoryDeps,
|
||||
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
|
||||
return {
|
||||
getSocketPath: deps.getSocketPath,
|
||||
setSocketPath: deps.setSocketPath,
|
||||
getClient: deps.getMpvClient,
|
||||
showOsd: deps.showOsd,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: deps.getTexthookerPort,
|
||||
setTexthookerPort: deps.setTexthookerPort,
|
||||
shouldOpenBrowser: deps.shouldOpenBrowser,
|
||||
openInBrowser: (url: string) => {
|
||||
void deps.openExternal(url).catch((error) => {
|
||||
deps.logBrowserOpenError(url, error);
|
||||
});
|
||||
},
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
setInvisibleOverlay: deps.setInvisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: deps.startPendingMultiCopy,
|
||||
mineSentenceCard: deps.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
|
||||
refreshKnownWordCache: deps.refreshKnownWordCache,
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
openJellyfinSetup: deps.openJellyfinSetup,
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
stopApp: deps.stopApp,
|
||||
hasMainWindow: deps.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
log: deps.logInfo,
|
||||
warn: deps.logWarn,
|
||||
error: deps.logError,
|
||||
};
|
||||
}
|
||||
85
src/main/runtime/global-shortcuts.test.ts
Normal file
85
src/main/runtime/global-shortcuts.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGetConfiguredShortcutsHandler,
|
||||
createRefreshGlobalAndOverlayShortcutsHandler,
|
||||
createRegisterGlobalShortcutsHandler,
|
||||
} from './global-shortcuts';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
|
||||
function createShortcuts(): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
|
||||
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
|
||||
copySubtitle: 's',
|
||||
copySubtitleMultiple: 'CommandOrControl+s',
|
||||
updateLastCardFromClipboard: 'c',
|
||||
triggerFieldGrouping: null,
|
||||
triggerSubsync: null,
|
||||
mineSentence: 'q',
|
||||
mineSentenceMultiple: 'w',
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('configured shortcuts handler resolves from current config', () => {
|
||||
const calls: string[] = [];
|
||||
const config = {} as never;
|
||||
const defaultConfig = {} as never;
|
||||
const shortcuts = createShortcuts();
|
||||
const getConfiguredShortcuts = createGetConfiguredShortcutsHandler({
|
||||
getResolvedConfig: () => config,
|
||||
defaultConfig,
|
||||
resolveConfiguredShortcuts: (nextConfig, nextDefaultConfig) => {
|
||||
calls.push('resolve');
|
||||
assert.equal(nextConfig, config);
|
||||
assert.equal(nextDefaultConfig, defaultConfig);
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getConfiguredShortcuts(), shortcuts);
|
||||
assert.deepEqual(calls, ['resolve']);
|
||||
});
|
||||
|
||||
test('register global shortcuts handler passes through callbacks and shortcuts', () => {
|
||||
const calls: string[] = [];
|
||||
const shortcuts = createShortcuts();
|
||||
const mainWindow = {} as never;
|
||||
const registerGlobalShortcuts = createRegisterGlobalShortcutsHandler({
|
||||
getConfiguredShortcuts: () => shortcuts,
|
||||
registerGlobalShortcutsCore: (options) => {
|
||||
calls.push('register');
|
||||
assert.equal(options.shortcuts, shortcuts);
|
||||
assert.equal(options.isDev, true);
|
||||
assert.equal(options.getMainWindow(), mainWindow);
|
||||
options.onToggleVisibleOverlay();
|
||||
options.onToggleInvisibleOverlay();
|
||||
options.onOpenYomitanSettings();
|
||||
},
|
||||
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
onOpenYomitanSettings: () => calls.push('open-yomitan'),
|
||||
isDev: true,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
registerGlobalShortcuts();
|
||||
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
|
||||
});
|
||||
|
||||
test('refresh global and overlay shortcuts unregisters then re-registers', () => {
|
||||
const calls: string[] = [];
|
||||
const refresh = createRefreshGlobalAndOverlayShortcutsHandler({
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
|
||||
registerGlobalShortcuts: () => calls.push('register'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-overlay'),
|
||||
});
|
||||
|
||||
refresh();
|
||||
assert.deepEqual(calls, ['unregister', 'register', 'sync-overlay']);
|
||||
});
|
||||
48
src/main/runtime/global-shortcuts.ts
Normal file
48
src/main/runtime/global-shortcuts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Config } from '../../types';
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
|
||||
|
||||
export function createGetConfiguredShortcutsHandler(deps: {
|
||||
getResolvedConfig: () => Config;
|
||||
defaultConfig: Config;
|
||||
resolveConfiguredShortcuts: (
|
||||
config: Config,
|
||||
defaultConfig: Config,
|
||||
) => ConfiguredShortcuts;
|
||||
}) {
|
||||
return (): ConfiguredShortcuts =>
|
||||
deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig);
|
||||
}
|
||||
|
||||
export function createRegisterGlobalShortcutsHandler(deps: {
|
||||
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
|
||||
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
|
||||
onToggleVisibleOverlay: () => void;
|
||||
onToggleInvisibleOverlay: () => void;
|
||||
onOpenYomitanSettings: () => void;
|
||||
isDev: boolean;
|
||||
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.registerGlobalShortcutsCore({
|
||||
shortcuts: deps.getConfiguredShortcuts(),
|
||||
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
|
||||
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
|
||||
onOpenYomitanSettings: deps.onOpenYomitanSettings,
|
||||
isDev: deps.isDev,
|
||||
getMainWindow: deps.getMainWindow,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshGlobalAndOverlayShortcutsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.syncOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
70
src/main/runtime/mining-actions.test.ts
Normal file
70
src/main/runtime/mining-actions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
createHandleMultiCopyDigitHandler,
|
||||
} from './mining-actions';
|
||||
|
||||
test('multi-copy digit handler forwards tracker/clipboard/osd deps', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
handleMultiCopyDigitCore: (count, options) => {
|
||||
assert.equal(count, 3);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
options.writeClipboardText('copied');
|
||||
options.showMpvOsd('done');
|
||||
},
|
||||
});
|
||||
|
||||
handleMultiCopyDigit(3);
|
||||
assert.deepEqual(calls, ['clipboard:copied', 'osd:done']);
|
||||
});
|
||||
|
||||
test('copy current subtitle handler forwards tracker/clipboard/osd deps', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
copyCurrentSubtitleCore: (options) => {
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
options.writeClipboardText('subtitle');
|
||||
options.showMpvOsd('copied');
|
||||
},
|
||||
});
|
||||
|
||||
copyCurrentSubtitle();
|
||||
assert.deepEqual(calls, ['clipboard:subtitle', 'osd:copied']);
|
||||
});
|
||||
|
||||
test('mine sentence digit handler forwards all dependencies', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {};
|
||||
const integration = {};
|
||||
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => tracker,
|
||||
getAnkiIntegration: () => integration,
|
||||
getCurrentSecondarySubText: () => 'secondary',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: (count, options) => {
|
||||
assert.equal(count, 4);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
assert.equal(options.ankiIntegration, integration);
|
||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||
options.showMpvOsd('mine');
|
||||
options.logError('boom', new Error('x'));
|
||||
options.onCardsMined(2);
|
||||
},
|
||||
});
|
||||
|
||||
handleMineSentenceDigit(4);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
||||
});
|
||||
73
src/main/runtime/mining-actions.ts
Normal file
73
src/main/runtime/mining-actions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function createHandleMultiCopyDigitHandler<TSubtitleTimingTracker>(deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
handleMultiCopyDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
return (count: number): void => {
|
||||
deps.handleMultiCopyDigitCore(count, {
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
writeClipboardText: deps.writeClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createCopyCurrentSubtitleHandler<TSubtitleTimingTracker>(deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
copyCurrentSubtitleCore: (options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.copyCurrentSubtitleCore({
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
writeClipboardText: deps.writeClipboardText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMineSentenceDigitHandler<TSubtitleTimingTracker, TAnkiIntegration>(
|
||||
deps: {
|
||||
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
|
||||
getAnkiIntegration: () => TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: TSubtitleTimingTracker;
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
},
|
||||
) {
|
||||
return (count: number): void => {
|
||||
deps.handleMineSentenceDigitCore(count, {
|
||||
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
logError: deps.logError,
|
||||
onCardsMined: deps.onCardsMined,
|
||||
});
|
||||
};
|
||||
}
|
||||
79
src/main/runtime/mpv-client-event-bindings.test.ts
Normal file
79
src/main/runtime/mpv-client-event-bindings.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createBindMpvClientEventHandlers,
|
||||
createHandleMpvConnectionChangeHandler,
|
||||
createHandleMpvSubtitleTimingHandler,
|
||||
} from './mpv-client-event-bindings';
|
||||
|
||||
test('mpv connection handler reports stop and quits when disconnect guard passes', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
callback();
|
||||
},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
handler({ connected: false });
|
||||
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']);
|
||||
});
|
||||
|
||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: () => calls.push('immersion'),
|
||||
hasSubtitleTimingTracker: () => true,
|
||||
recordSubtitleTiming: () => calls.push('timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
});
|
||||
|
||||
handler({ text: ' ', start: 1, end: 2 });
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('mpv event bindings register all expected events', () => {
|
||||
const seenEvents: string[] = [];
|
||||
const bindHandlers = createBindMpvClientEventHandlers({
|
||||
onConnectionChange: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
onMediaTitleChange: () => {},
|
||||
onTimePosChange: () => {},
|
||||
onPauseChange: () => {},
|
||||
onSubtitleMetricsChange: () => {},
|
||||
onSecondarySubtitleVisibility: () => {},
|
||||
});
|
||||
|
||||
bindHandlers({
|
||||
on: (event) => {
|
||||
seenEvents.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seenEvents, [
|
||||
'connection-change',
|
||||
'subtitle-change',
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
'time-pos-change',
|
||||
'pause-change',
|
||||
'subtitle-metrics-change',
|
||||
'secondary-subtitle-visibility',
|
||||
]);
|
||||
});
|
||||
84
src/main/runtime/mpv-client-event-bindings.ts
Normal file
84
src/main/runtime/mpv-client-event-bindings.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
type MpvBindingEventName =
|
||||
| 'connection-change'
|
||||
| 'subtitle-change'
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
| 'media-title-change'
|
||||
| 'time-pos-change'
|
||||
| 'pause-change'
|
||||
| 'subtitle-metrics-change'
|
||||
| 'secondary-subtitle-visibility';
|
||||
|
||||
type MpvEventClient = {
|
||||
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
|
||||
};
|
||||
|
||||
export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return ({ connected }: { connected: boolean }): void => {
|
||||
if (connected) return;
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
if (!deps.hasInitialJellyfinPlayArg()) return;
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
if (!deps.isQuitOnDisconnectArmed()) return;
|
||||
deps.scheduleQuitCheck(() => {
|
||||
if (deps.isMpvConnected()) return;
|
||||
deps.quitApp();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleTimingHandler(deps: {
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||
hasSubtitleTimingTracker: () => boolean;
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
|
||||
if (!text.trim()) return;
|
||||
deps.recordImmersionSubtitleLine(text, start, end);
|
||||
if (!deps.hasSubtitleTimingTracker()) return;
|
||||
deps.recordSubtitleTiming(text, start, end);
|
||||
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
|
||||
deps.logError('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createBindMpvClientEventHandlers(deps: {
|
||||
onConnectionChange: (payload: { connected: boolean }) => void;
|
||||
onSubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string }) => void;
|
||||
onMediaTitleChange: (payload: { title: string }) => void;
|
||||
onTimePosChange: (payload: { time: number }) => void;
|
||||
onPauseChange: (payload: { paused: boolean }) => void;
|
||||
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
|
||||
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
}) {
|
||||
return (mpvClient: MpvEventClient): void => {
|
||||
mpvClient.on('connection-change', deps.onConnectionChange);
|
||||
mpvClient.on('subtitle-change', deps.onSubtitleChange);
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
mpvClient.on('media-title-change', deps.onMediaTitleChange);
|
||||
mpvClient.on('time-pos-change', deps.onTimePosChange);
|
||||
mpvClient.on('pause-change', deps.onPauseChange);
|
||||
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
|
||||
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
|
||||
};
|
||||
}
|
||||
40
src/main/runtime/mpv-client-runtime-service.test.ts
Normal file
40
src/main/runtime/mpv-client-runtime-service.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMpvClientRuntimeServiceFactory } from './mpv-client-runtime-service';
|
||||
|
||||
test('mpv runtime service factory constructs client, binds handlers, and connects', () => {
|
||||
const calls: string[] = [];
|
||||
let constructedSocketPath = '';
|
||||
|
||||
class FakeClient {
|
||||
connect(): void {
|
||||
calls.push('connect');
|
||||
}
|
||||
constructor(socketPath: string) {
|
||||
constructedSocketPath = socketPath;
|
||||
calls.push('construct');
|
||||
}
|
||||
}
|
||||
|
||||
const createRuntimeService = createMpvClientRuntimeServiceFactory({
|
||||
createClient: FakeClient,
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
options: {
|
||||
getResolvedConfig: () => ({}),
|
||||
autoStartOverlay: true,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
},
|
||||
bindEventHandlers: () => {
|
||||
calls.push('bind');
|
||||
},
|
||||
});
|
||||
|
||||
const client = createRuntimeService();
|
||||
assert.ok(client instanceof FakeClient);
|
||||
assert.equal(constructedSocketPath, '/tmp/mpv.sock');
|
||||
assert.deepEqual(calls, ['construct', 'bind', 'connect']);
|
||||
});
|
||||
35
src/main/runtime/mpv-client-runtime-service.ts
Normal file
35
src/main/runtime/mpv-client-runtime-service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type MpvClientCtorBaseOptions = {
|
||||
getResolvedConfig: () => unknown;
|
||||
autoStartOverlay: boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connect: () => void;
|
||||
};
|
||||
|
||||
type MpvClientCtor<TClient extends MpvClientLike, TOptions extends MpvClientCtorBaseOptions> = new (
|
||||
socketPath: string,
|
||||
options: TOptions,
|
||||
) => TClient;
|
||||
|
||||
export function createMpvClientRuntimeServiceFactory<
|
||||
TClient extends MpvClientLike,
|
||||
TOptions extends MpvClientCtorBaseOptions,
|
||||
>(deps: {
|
||||
createClient: MpvClientCtor<TClient, TOptions>;
|
||||
socketPath: string;
|
||||
options: TOptions;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
}) {
|
||||
return (): TClient => {
|
||||
const mpvClient = new deps.createClient(deps.socketPath, deps.options);
|
||||
deps.bindEventHandlers(mpvClient);
|
||||
mpvClient.connect();
|
||||
return mpvClient;
|
||||
};
|
||||
}
|
||||
65
src/main/runtime/mpv-osd-log.test.ts
Normal file
65
src/main/runtime/mpv-osd-log.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
|
||||
|
||||
test('append mpv log writes timestamped message', () => {
|
||||
const calls: string[] = [];
|
||||
const appendToMpvLog = createAppendToMpvLogHandler({
|
||||
logPath: '/tmp/subminer/mpv.log',
|
||||
dirname: (targetPath) => {
|
||||
calls.push(`dirname:${targetPath}`);
|
||||
return '/tmp/subminer';
|
||||
},
|
||||
mkdirSync: (targetPath) => {
|
||||
calls.push(`mkdir:${targetPath}`);
|
||||
},
|
||||
appendFileSync: (_targetPath, data) => {
|
||||
calls.push(`append:${data.trimEnd()}`);
|
||||
},
|
||||
now: () => new Date('2026-02-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
appendToMpvLog('hello');
|
||||
assert.deepEqual(calls, [
|
||||
'dirname:/tmp/subminer/mpv.log',
|
||||
'mkdir:/tmp/subminer',
|
||||
'append:[2026-02-20T00:00:00.000Z] hello',
|
||||
]);
|
||||
});
|
||||
|
||||
test('append mpv log swallows filesystem errors', () => {
|
||||
const appendToMpvLog = createAppendToMpvLogHandler({
|
||||
logPath: '/tmp/subminer/mpv.log',
|
||||
dirname: () => '/tmp/subminer',
|
||||
mkdirSync: () => {
|
||||
throw new Error('disk error');
|
||||
},
|
||||
appendFileSync: () => {
|
||||
throw new Error('should not reach');
|
||||
},
|
||||
now: () => new Date('2026-02-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => appendToMpvLog('hello'));
|
||||
});
|
||||
|
||||
test('show mpv osd logs marker and forwards fallback logging', () => {
|
||||
const calls: string[] = [];
|
||||
const client = { connected: false, send: () => {} } as never;
|
||||
const showMpvOsd = createShowMpvOsdHandler({
|
||||
appendToMpvLog: (message) => calls.push(`append:${message}`),
|
||||
showMpvOsdRuntime: (_client, text, fallbackLog) => {
|
||||
calls.push(`show:${text}`);
|
||||
fallbackLog('fallback-line');
|
||||
},
|
||||
getMpvClient: () => client,
|
||||
logInfo: (line) => calls.push(`info:${line}`),
|
||||
});
|
||||
|
||||
showMpvOsd('subtitle copied');
|
||||
assert.deepEqual(calls, [
|
||||
'append:[OSD] subtitle copied',
|
||||
'show:subtitle copied',
|
||||
'info:fallback-line',
|
||||
]);
|
||||
});
|
||||
42
src/main/runtime/mpv-osd-log.ts
Normal file
42
src/main/runtime/mpv-osd-log.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
|
||||
export function createAppendToMpvLogHandler(deps: {
|
||||
logPath: string;
|
||||
dirname: (targetPath: string) => string;
|
||||
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
|
||||
appendFileSync: (
|
||||
targetPath: string,
|
||||
data: string,
|
||||
options: { encoding: 'utf8' },
|
||||
) => void;
|
||||
now: () => Date;
|
||||
}) {
|
||||
return (message: string): void => {
|
||||
try {
|
||||
deps.mkdirSync(deps.dirname(deps.logPath), { recursive: true });
|
||||
deps.appendFileSync(deps.logPath, `[${deps.now().toISOString()}] ${message}\n`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
} catch {
|
||||
// best-effort logging
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createShowMpvOsdHandler(deps: {
|
||||
appendToMpvLog: (message: string) => void;
|
||||
showMpvOsdRuntime: (
|
||||
mpvClient: MpvRuntimeClientLike | null,
|
||||
text: string,
|
||||
fallbackLog: (line: string) => void,
|
||||
) => void;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
logInfo: (line: string) => void;
|
||||
}) {
|
||||
return (text: string): void => {
|
||||
deps.appendToMpvLog(`[OSD] ${text}`);
|
||||
deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
|
||||
deps.logInfo(line);
|
||||
});
|
||||
};
|
||||
}
|
||||
63
src/main/runtime/mpv-subtitle-render-metrics.test.ts
Normal file
63
src/main/runtime/mpv-subtitle-render-metrics.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics';
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
|
||||
const BASE_METRICS: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
subFontSize: 36,
|
||||
subScale: 1,
|
||||
subMarginY: 0,
|
||||
subMarginX: 0,
|
||||
subFont: '',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 0,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: true,
|
||||
subUseMargins: true,
|
||||
osdHeight: 0,
|
||||
osdDimensions: null,
|
||||
};
|
||||
|
||||
test('subtitle render metrics handler no-ops when patch does not change state', () => {
|
||||
let metrics = { ...BASE_METRICS };
|
||||
let broadcasts = 0;
|
||||
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
|
||||
getCurrentMetrics: () => metrics,
|
||||
setCurrentMetrics: (next) => {
|
||||
metrics = next;
|
||||
},
|
||||
applyPatch: (current) => ({ next: current, changed: false }),
|
||||
broadcastMetrics: () => {
|
||||
broadcasts += 1;
|
||||
},
|
||||
});
|
||||
|
||||
updateMetrics({});
|
||||
assert.equal(broadcasts, 0);
|
||||
});
|
||||
|
||||
test('subtitle render metrics handler updates and broadcasts when changed', () => {
|
||||
let metrics = { ...BASE_METRICS };
|
||||
let broadcasts = 0;
|
||||
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
|
||||
getCurrentMetrics: () => metrics,
|
||||
setCurrentMetrics: (next) => {
|
||||
metrics = next;
|
||||
},
|
||||
applyPatch: (current, patch) => ({
|
||||
next: { ...current, ...patch },
|
||||
changed: true,
|
||||
}),
|
||||
broadcastMetrics: () => {
|
||||
broadcasts += 1;
|
||||
},
|
||||
});
|
||||
|
||||
updateMetrics({ subPos: 80 });
|
||||
assert.equal(metrics.subPos, 80);
|
||||
assert.equal(broadcasts, 1);
|
||||
});
|
||||
18
src/main/runtime/mpv-subtitle-render-metrics.ts
Normal file
18
src/main/runtime/mpv-subtitle-render-metrics.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
|
||||
export function createUpdateMpvSubtitleRenderMetricsHandler(deps: {
|
||||
getCurrentMetrics: () => MpvSubtitleRenderMetrics;
|
||||
setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
|
||||
applyPatch: (
|
||||
current: MpvSubtitleRenderMetrics,
|
||||
patch: Partial<MpvSubtitleRenderMetrics>,
|
||||
) => { next: MpvSubtitleRenderMetrics; changed: boolean };
|
||||
broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
|
||||
}) {
|
||||
return (patch: Partial<MpvSubtitleRenderMetrics>): void => {
|
||||
const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch);
|
||||
if (!changed) return;
|
||||
deps.setCurrentMetrics(next);
|
||||
deps.broadcastMetrics(next);
|
||||
};
|
||||
}
|
||||
42
src/main/runtime/numeric-shortcut-session-handlers.test.ts
Normal file
42
src/main/runtime/numeric-shortcut-session-handlers.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCancelNumericShortcutSessionHandler,
|
||||
createStartNumericShortcutSessionHandler,
|
||||
} from './numeric-shortcut-session-handlers';
|
||||
|
||||
test('cancel numeric shortcut session handler cancels active session', () => {
|
||||
const calls: string[] = [];
|
||||
const cancel = createCancelNumericShortcutSessionHandler({
|
||||
session: {
|
||||
start: () => {},
|
||||
cancel: () => calls.push('cancel'),
|
||||
},
|
||||
});
|
||||
|
||||
cancel();
|
||||
assert.deepEqual(calls, ['cancel']);
|
||||
});
|
||||
|
||||
test('start numeric shortcut session handler forwards timeout, messages, and onDigit', () => {
|
||||
const calls: string[] = [];
|
||||
const start = createStartNumericShortcutSessionHandler({
|
||||
session: {
|
||||
cancel: () => {},
|
||||
start: ({ timeoutMs, onDigit, messages }) => {
|
||||
calls.push(`timeout:${timeoutMs}`);
|
||||
calls.push(`prompt:${messages.prompt}`);
|
||||
onDigit(3);
|
||||
},
|
||||
},
|
||||
onDigit: (digit) => calls.push(`digit:${digit}`),
|
||||
messages: {
|
||||
prompt: 'Prompt',
|
||||
timeout: 'Timeout',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
});
|
||||
|
||||
start(1200);
|
||||
assert.deepEqual(calls, ['timeout:1200', 'prompt:Prompt', 'digit:3']);
|
||||
});
|
||||
31
src/main/runtime/numeric-shortcut-session-handlers.ts
Normal file
31
src/main/runtime/numeric-shortcut-session-handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
NumericShortcutSessionMessages,
|
||||
NumericShortcutSessionStartParams,
|
||||
} from '../../core/services/numeric-shortcut';
|
||||
|
||||
type NumericShortcutSessionLike = {
|
||||
start: (params: NumericShortcutSessionStartParams) => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export function createCancelNumericShortcutSessionHandler(deps: {
|
||||
session: NumericShortcutSessionLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.session.cancel();
|
||||
};
|
||||
}
|
||||
|
||||
export function createStartNumericShortcutSessionHandler(deps: {
|
||||
session: NumericShortcutSessionLike;
|
||||
onDigit: (digit: number) => void;
|
||||
messages: NumericShortcutSessionMessages;
|
||||
}) {
|
||||
return (timeoutMs: number): void => {
|
||||
deps.session.start({
|
||||
timeoutMs,
|
||||
onDigit: deps.onDigit,
|
||||
messages: deps.messages,
|
||||
});
|
||||
};
|
||||
}
|
||||
51
src/main/runtime/overlay-runtime-bootstrap.test.ts
Normal file
51
src/main/runtime/overlay-runtime-bootstrap.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap';
|
||||
|
||||
test('overlay runtime bootstrap no-ops when already initialized', () => {
|
||||
let coreCalls = 0;
|
||||
const initialize = createInitializeOverlayRuntimeHandler({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
coreCalls += 1;
|
||||
return { invisibleOverlayVisible: false };
|
||||
},
|
||||
buildOptions: () => ({} as never),
|
||||
setInvisibleOverlayVisible: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
});
|
||||
|
||||
initialize();
|
||||
assert.equal(coreCalls, 0);
|
||||
});
|
||||
|
||||
test('overlay runtime bootstrap runs core init and applies post-init state', () => {
|
||||
const calls: string[] = [];
|
||||
let initialized = false;
|
||||
const initialize = createInitializeOverlayRuntimeHandler({
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
calls.push('core');
|
||||
return { invisibleOverlayVisible: true };
|
||||
},
|
||||
buildOptions: () => {
|
||||
calls.push('options');
|
||||
return {} as never;
|
||||
},
|
||||
setInvisibleOverlayVisible: (visible) => {
|
||||
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
|
||||
},
|
||||
setOverlayRuntimeInitialized: (value) => {
|
||||
initialized = value;
|
||||
calls.push(`initialized:${value ? 'yes' : 'no'}`);
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('warmups');
|
||||
},
|
||||
});
|
||||
|
||||
initialize();
|
||||
assert.equal(initialized, true);
|
||||
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
|
||||
});
|
||||
55
src/main/runtime/overlay-runtime-bootstrap.ts
Normal file
55
src/main/runtime/overlay-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
import type {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
WindowGeometry,
|
||||
} from '../../types';
|
||||
|
||||
type InitializeOverlayRuntimeCore = (options: {
|
||||
backendOverride: string | null;
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
isInvisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
getMpvSocketPath: () => string;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
getRuntimeOptionsManager: () => {
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
}) => { invisibleOverlayVisible: boolean };
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
|
||||
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
|
||||
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
};
|
||||
}
|
||||
49
src/main/runtime/overlay-shortcuts-lifecycle.test.ts
Normal file
49
src/main/runtime/overlay-shortcuts-lifecycle.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createRefreshOverlayShortcutsHandler,
|
||||
createRegisterOverlayShortcutsHandler,
|
||||
createSyncOverlayShortcutsHandler,
|
||||
createUnregisterOverlayShortcutsHandler,
|
||||
} from './overlay-shortcuts-lifecycle';
|
||||
|
||||
function createRuntime(calls: string[]) {
|
||||
return {
|
||||
registerOverlayShortcuts: () => calls.push('register'),
|
||||
unregisterOverlayShortcuts: () => calls.push('unregister'),
|
||||
syncOverlayShortcuts: () => calls.push('sync'),
|
||||
refreshOverlayShortcuts: () => calls.push('refresh'),
|
||||
};
|
||||
}
|
||||
|
||||
test('register overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createRegisterOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['register']);
|
||||
});
|
||||
|
||||
test('unregister overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createUnregisterOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['unregister']);
|
||||
});
|
||||
|
||||
test('sync overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createSyncOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['sync']);
|
||||
});
|
||||
|
||||
test('refresh overlay shortcuts handler delegates to runtime', () => {
|
||||
const calls: string[] = [];
|
||||
createRefreshOverlayShortcutsHandler({
|
||||
overlayShortcutsRuntime: createRuntime(calls),
|
||||
})();
|
||||
assert.deepEqual(calls, ['refresh']);
|
||||
});
|
||||
38
src/main/runtime/overlay-shortcuts-lifecycle.ts
Normal file
38
src/main/runtime/overlay-shortcuts-lifecycle.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
type OverlayShortcutsRuntimeLike = {
|
||||
registerOverlayShortcuts: () => void;
|
||||
unregisterOverlayShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
};
|
||||
|
||||
export function createRegisterOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.registerOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnregisterOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.unregisterOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createSyncOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshOverlayShortcutsHandler(deps: {
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.overlayShortcutsRuntime.refreshOverlayShortcuts();
|
||||
};
|
||||
}
|
||||
53
src/main/runtime/overlay-window-layout.test.ts
Normal file
53
src/main/runtime/overlay-window-layout.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateInvisibleOverlayBoundsHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
} from './overlay-window-layout';
|
||||
|
||||
test('visible bounds handler writes visible layer geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: (layer) => calls.push(layer),
|
||||
});
|
||||
handleVisible({ x: 0, y: 0, width: 100, height: 50 });
|
||||
assert.deepEqual(calls, ['visible']);
|
||||
});
|
||||
|
||||
test('invisible bounds handler writes invisible layer geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: (layer) => calls.push(layer),
|
||||
});
|
||||
handleInvisible({ x: 0, y: 0, width: 100, height: 50 });
|
||||
assert.deepEqual(calls, ['invisible']);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
ensureOverlayWindowLevelCore: () => calls.push('core'),
|
||||
});
|
||||
ensureLevel({});
|
||||
assert.deepEqual(calls, ['core']);
|
||||
});
|
||||
|
||||
test('enforce overlay layer order handler forwards resolved state', () => {
|
||||
const calls: string[] = [];
|
||||
const enforce = createEnforceOverlayLayerOrderHandler({
|
||||
enforceOverlayLayerOrderCore: (params) => {
|
||||
calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off');
|
||||
calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off');
|
||||
params.ensureOverlayWindowLevel({});
|
||||
},
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getInvisibleOverlayVisible: () => false,
|
||||
getMainWindow: () => ({}),
|
||||
getInvisibleWindow: () => ({}),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
});
|
||||
enforce();
|
||||
assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']);
|
||||
});
|
||||
50
src/main/runtime/overlay-window-layout.ts
Normal file
50
src/main/runtime/overlay-window-layout.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds('visible', geometry);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateInvisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds('invisible', geometry);
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnsureOverlayWindowLevelHandler(deps: {
|
||||
ensureOverlayWindowLevelCore: (window: unknown) => void;
|
||||
}) {
|
||||
return (window: unknown): void => {
|
||||
deps.ensureOverlayWindowLevelCore(window);
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnforceOverlayLayerOrderHandler(deps: {
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
invisibleOverlayVisible: boolean;
|
||||
mainWindow: unknown;
|
||||
invisibleWindow: unknown;
|
||||
ensureOverlayWindowLevel: (window: unknown) => void;
|
||||
}) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => unknown;
|
||||
getInvisibleWindow: () => unknown;
|
||||
ensureOverlayWindowLevel: (window: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
invisibleWindow: deps.getInvisibleWindow(),
|
||||
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
|
||||
});
|
||||
};
|
||||
}
|
||||
71
src/main/runtime/startup-warmups.test.ts
Normal file
71
src/main/runtime/startup-warmups.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createLaunchBackgroundWarmupTaskHandler,
|
||||
createStartBackgroundWarmupsHandler,
|
||||
} from './startup-warmups';
|
||||
|
||||
test('launchBackgroundWarmupTask logs completion timing', async () => {
|
||||
const debugLogs: string[] = [];
|
||||
const launchTask = createLaunchBackgroundWarmupTaskHandler({
|
||||
now: (() => {
|
||||
let tick = 0;
|
||||
return () => ++tick * 10;
|
||||
})(),
|
||||
logDebug: (message) => debugLogs.push(message),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
launchTask('demo', async () => {});
|
||||
await Promise.resolve();
|
||||
assert.ok(debugLogs.some((line) => line.includes('[startup-warmup] demo completed in')));
|
||||
});
|
||||
|
||||
test('startBackgroundWarmups no-ops when already started', () => {
|
||||
let launches = 0;
|
||||
const startWarmups = createStartBackgroundWarmupsHandler({
|
||||
getStarted: () => true,
|
||||
setStarted: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
launchTask: () => {
|
||||
launches += 1;
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
shouldAutoConnectJellyfinRemote: () => false,
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
});
|
||||
|
||||
startWarmups();
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
test('startBackgroundWarmups schedules base warmups and optional jellyfin warmup', () => {
|
||||
const labels: string[] = [];
|
||||
let started = false;
|
||||
const startWarmups = createStartBackgroundWarmupsHandler({
|
||||
getStarted: () => started,
|
||||
setStarted: (value) => {
|
||||
started = value;
|
||||
},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
launchTask: (label) => {
|
||||
labels.push(label);
|
||||
},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
shouldAutoConnectJellyfinRemote: () => true,
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
});
|
||||
|
||||
startWarmups();
|
||||
assert.equal(started, true);
|
||||
assert.deepEqual(labels, [
|
||||
'mecab',
|
||||
'yomitan-extension',
|
||||
'subtitle-dictionaries',
|
||||
'jellyfin-remote-session',
|
||||
]);
|
||||
});
|
||||
50
src/main/runtime/startup-warmups.ts
Normal file
50
src/main/runtime/startup-warmups.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export function createLaunchBackgroundWarmupTaskHandler(deps: {
|
||||
now: () => number;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return (label: string, task: () => Promise<void>): void => {
|
||||
const startedAtMs = deps.now();
|
||||
void task()
|
||||
.then(() => {
|
||||
deps.logDebug(`[startup-warmup] ${label} completed in ${deps.now() - startedAtMs}ms`);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logWarn(`[startup-warmup] ${label} failed: ${message}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createStartBackgroundWarmupsHandler(deps: {
|
||||
getStarted: () => boolean;
|
||||
setStarted: (started: boolean) => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
launchTask: (label: string, task: () => Promise<void>) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
shouldAutoConnectJellyfinRemote: () => boolean;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.getStarted()) return;
|
||||
if (deps.isTexthookerOnlyMode()) return;
|
||||
|
||||
deps.setStarted(true);
|
||||
deps.launchTask('mecab', async () => {
|
||||
await deps.createMecabTokenizerAndCheck();
|
||||
});
|
||||
deps.launchTask('yomitan-extension', async () => {
|
||||
await deps.ensureYomitanExtensionLoaded();
|
||||
});
|
||||
deps.launchTask('subtitle-dictionaries', async () => {
|
||||
await deps.prewarmSubtitleDictionaries();
|
||||
});
|
||||
if (deps.shouldAutoConnectJellyfinRemote()) {
|
||||
deps.launchTask('jellyfin-remote-session', async () => {
|
||||
await deps.startJellyfinRemoteSession();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
119
src/main/runtime/tray-lifecycle.test.ts
Normal file
119
src/main/runtime/tray-lifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
|
||||
|
||||
test('ensure tray updates menu when tray already exists', () => {
|
||||
const calls: string[] = [];
|
||||
const tray = {
|
||||
setContextMenu: () => calls.push('set-menu'),
|
||||
setToolTip: () => calls.push('set-tooltip'),
|
||||
on: () => calls.push('bind-click'),
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => tray,
|
||||
setTray: () => calls.push('set-tray'),
|
||||
buildTrayMenu: () => ({}),
|
||||
resolveTrayIconPath: () => null,
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('should not resize');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('should not resize');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () => tray as never,
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: () => calls.push('warn'),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
assert.deepEqual(calls, ['set-menu']);
|
||||
});
|
||||
|
||||
test('ensure tray creates new tray and binds click handler', () => {
|
||||
const calls: string[] = [];
|
||||
let trayRef: unknown = null;
|
||||
|
||||
const ensureTray = createEnsureTrayHandler({
|
||||
getTray: () => null,
|
||||
setTray: (tray) => {
|
||||
trayRef = tray;
|
||||
calls.push('set-tray');
|
||||
},
|
||||
buildTrayMenu: () => ({ id: 'menu' }),
|
||||
resolveTrayIconPath: () => '/tmp/icon.png',
|
||||
createImageFromPath: () =>
|
||||
({
|
||||
isEmpty: () => false,
|
||||
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => {
|
||||
calls.push(`resize:${options.width}x${options.height}`);
|
||||
return {
|
||||
isEmpty: () => false,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => calls.push('template'),
|
||||
};
|
||||
},
|
||||
setTemplateImage: () => calls.push('template'),
|
||||
}) as never,
|
||||
createEmptyImage: () =>
|
||||
({
|
||||
isEmpty: () => true,
|
||||
resize: () => {
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
setTemplateImage: () => {},
|
||||
}) as never,
|
||||
createTray: () =>
|
||||
({
|
||||
setContextMenu: () => calls.push('set-menu'),
|
||||
setToolTip: () => calls.push('set-tooltip'),
|
||||
on: (_event: 'click', _handler: () => void) => {
|
||||
calls.push('bind-click');
|
||||
},
|
||||
destroy: () => calls.push('destroy'),
|
||||
}) as never,
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: () => calls.push('warn'),
|
||||
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
|
||||
});
|
||||
|
||||
ensureTray();
|
||||
assert.ok(trayRef);
|
||||
assert.ok(calls.includes('set-tray'));
|
||||
assert.ok(calls.includes('set-menu'));
|
||||
assert.ok(calls.includes('bind-click'));
|
||||
});
|
||||
|
||||
test('destroy tray handler destroys active tray and clears ref', () => {
|
||||
const calls: string[] = [];
|
||||
let tray: { destroy: () => void } | null = {
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
const destroyTray = createDestroyTrayHandler({
|
||||
getTray: () => tray as never,
|
||||
setTray: (next) => {
|
||||
tray = next as never;
|
||||
calls.push('set-null');
|
||||
},
|
||||
});
|
||||
|
||||
destroyTray();
|
||||
assert.deepEqual(calls, ['destroy', 'set-null']);
|
||||
assert.equal(tray, null);
|
||||
});
|
||||
67
src/main/runtime/tray-lifecycle.ts
Normal file
67
src/main/runtime/tray-lifecycle.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
type TrayIconLike = {
|
||||
isEmpty: () => boolean;
|
||||
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => TrayIconLike;
|
||||
setTemplateImage: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type TrayLike = {
|
||||
setContextMenu: (menu: any) => void;
|
||||
setToolTip: (tooltip: string) => void;
|
||||
on: (event: 'click', handler: () => void) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export function createEnsureTrayHandler(deps: {
|
||||
getTray: () => TrayLike | null;
|
||||
setTray: (tray: TrayLike | null) => void;
|
||||
buildTrayMenu: () => any;
|
||||
resolveTrayIconPath: () => string | null;
|
||||
createImageFromPath: (iconPath: string) => TrayIconLike;
|
||||
createEmptyImage: () => TrayIconLike;
|
||||
createTray: (icon: TrayIconLike) => TrayLike;
|
||||
trayTooltip: string;
|
||||
platform: string;
|
||||
logWarn: (message: string) => void;
|
||||
ensureOverlayVisibleFromTrayClick: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const existingTray = deps.getTray();
|
||||
if (existingTray) {
|
||||
existingTray.setContextMenu(deps.buildTrayMenu());
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = deps.resolveTrayIconPath();
|
||||
let trayIcon = iconPath ? deps.createImageFromPath(iconPath) : deps.createEmptyImage();
|
||||
if (trayIcon.isEmpty()) {
|
||||
deps.logWarn('Tray icon asset not found; using empty icon placeholder.');
|
||||
}
|
||||
if (deps.platform === 'darwin' && !trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' });
|
||||
trayIcon.setTemplateImage(true);
|
||||
}
|
||||
if (deps.platform === 'linux' && !trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
const tray = deps.createTray(trayIcon);
|
||||
tray.setToolTip(deps.trayTooltip);
|
||||
tray.setContextMenu(deps.buildTrayMenu());
|
||||
tray.on('click', () => {
|
||||
deps.ensureOverlayVisibleFromTrayClick();
|
||||
});
|
||||
deps.setTray(tray);
|
||||
};
|
||||
}
|
||||
|
||||
export function createDestroyTrayHandler(deps: {
|
||||
getTray: () => TrayLike | null;
|
||||
setTray: (tray: TrayLike | null) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const tray = deps.getTray();
|
||||
if (!tray) return;
|
||||
tray.destroy();
|
||||
deps.setTray(null);
|
||||
};
|
||||
}
|
||||
45
src/main/runtime/tray-runtime.test.ts
Normal file
45
src/main/runtime/tray-runtime.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './tray-runtime';
|
||||
|
||||
test('resolve tray icon picks template icon first on darwin', () => {
|
||||
const path = resolveTrayIconPathRuntime({
|
||||
platform: 'darwin',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate.endsWith('/res/assets/SubMinerTemplate.png'),
|
||||
});
|
||||
assert.equal(path, '/res/assets/SubMinerTemplate.png');
|
||||
});
|
||||
|
||||
test('resolve tray icon returns null when no asset exists', () => {
|
||||
const path = resolveTrayIconPathRuntime({
|
||||
platform: 'linux',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.equal(path, null);
|
||||
});
|
||||
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 7);
|
||||
template[0].click?.();
|
||||
template[5].type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[6].click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
});
|
||||
73
src/main/runtime/tray-runtime.ts
Normal file
73
src/main/runtime/tray-runtime.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function resolveTrayIconPathRuntime(deps: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}): string | null {
|
||||
const iconNames =
|
||||
deps.platform === 'darwin'
|
||||
? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png']
|
||||
: ['SubMiner.png'];
|
||||
|
||||
const baseDirs = [
|
||||
deps.joinPath(deps.resourcesPath, 'assets'),
|
||||
deps.joinPath(deps.appPath, 'assets'),
|
||||
deps.joinPath(deps.dirname, '..', 'assets'),
|
||||
deps.joinPath(deps.dirname, '..', '..', 'assets'),
|
||||
];
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
for (const iconName of iconNames) {
|
||||
const candidate = deps.joinPath(baseDir, iconName);
|
||||
if (deps.fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
};
|
||||
|
||||
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
|
||||
label?: string;
|
||||
type?: 'separator';
|
||||
click?: () => void;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
label: 'Open Overlay',
|
||||
click: handlers.openOverlay,
|
||||
},
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open Runtime Options',
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Configure Jellyfin',
|
||||
click: handlers.openJellyfinSetup,
|
||||
},
|
||||
{
|
||||
label: 'Configure AniList',
|
||||
click: handlers.openAnilistSetup,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: handlers.quitApp,
|
||||
},
|
||||
];
|
||||
}
|
||||
41
src/main/runtime/yomitan-settings-opener.test.ts
Normal file
41
src/main/runtime/yomitan-settings-opener.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener';
|
||||
|
||||
test('yomitan opener warns when extension cannot be loaded', async () => {
|
||||
const logs: string[] = [];
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => null,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open');
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.ok(logs.includes('Unable to open Yomitan settings: extension failed to load.'));
|
||||
});
|
||||
|
||||
test('yomitan opener opens settings window when extension is available', async () => {
|
||||
let opened = false;
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
opened = true;
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(opened, true);
|
||||
});
|
||||
32
src/main/runtime/yomitan-settings-opener.ts
Normal file
32
src/main/runtime/yomitan-settings-opener.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type YomitanExtensionLike = unknown;
|
||||
type BrowserWindowLike = unknown;
|
||||
|
||||
export function createOpenYomitanSettingsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: YomitanExtensionLike;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
setWindow: (window: BrowserWindowLike | null) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
void (async () => {
|
||||
const extension = await deps.ensureYomitanExtensionLoaded();
|
||||
if (!extension) {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
setWindow: deps.setWindow,
|
||||
});
|
||||
})().catch((error) => {
|
||||
deps.logError('Failed to open Yomitan settings window.', error);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user