mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
29
src/main/anilist-url-guard.test.ts
Normal file
29
src/main/anilist-url-guard.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
isAllowedAnilistExternalUrl,
|
||||
isAllowedAnilistSetupNavigationUrl,
|
||||
} from './anilist-url-guard';
|
||||
|
||||
test('allows only AniList https URLs for external opens', () => {
|
||||
assert.equal(isAllowedAnilistExternalUrl('https://anilist.co'), true);
|
||||
assert.equal(isAllowedAnilistExternalUrl('https://www.anilist.co/settings/developer'), true);
|
||||
assert.equal(isAllowedAnilistExternalUrl('http://anilist.co'), false);
|
||||
assert.equal(isAllowedAnilistExternalUrl('https://example.com'), false);
|
||||
assert.equal(isAllowedAnilistExternalUrl('file:///tmp/test'), false);
|
||||
assert.equal(isAllowedAnilistExternalUrl('not a url'), false);
|
||||
});
|
||||
|
||||
test('allows only AniList https or data URLs for setup navigation', () => {
|
||||
assert.equal(
|
||||
isAllowedAnilistSetupNavigationUrl('https://anilist.co/api/v2/oauth/authorize'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAllowedAnilistSetupNavigationUrl('data:text/html;charset=utf-8,%3Chtml%3E%3C%2Fhtml%3E'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isAllowedAnilistSetupNavigationUrl('https://example.com/redirect'), false);
|
||||
assert.equal(isAllowedAnilistSetupNavigationUrl('javascript:alert(1)'), false);
|
||||
});
|
||||
24
src/main/anilist-url-guard.ts
Normal file
24
src/main/anilist-url-guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const ANILIST_ALLOWED_HOSTS = new Set(['anilist.co', 'www.anilist.co']);
|
||||
|
||||
export function isAllowedAnilistExternalUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(rawUrl);
|
||||
return (
|
||||
parsedUrl.protocol === 'https:' && ANILIST_ALLOWED_HOSTS.has(parsedUrl.hostname.toLowerCase())
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAllowedAnilistSetupNavigationUrl(rawUrl: string): boolean {
|
||||
if (isAllowedAnilistExternalUrl(rawUrl)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsedUrl = new URL(rawUrl);
|
||||
return parsedUrl.protocol === 'data:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
115
src/main/app-lifecycle.ts
Normal file
115
src/main/app-lifecycle.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import { runAppReadyRuntime } from '../core/services/startup';
|
||||
import type { AppReadyRuntimeDeps } from '../core/services/startup';
|
||||
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
|
||||
|
||||
export interface AppLifecycleRuntimeDepsFactoryInput {
|
||||
app: AppLifecycleDepsRuntimeOptions['app'];
|
||||
platform: NodeJS.Platform;
|
||||
shouldStartApp: (args: CliArgs) => boolean;
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
shouldQuitOnWindowAllClosed: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppReadyRuntimeDepsFactoryInput {
|
||||
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
||||
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
||||
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
||||
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
|
||||
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
|
||||
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
|
||||
logConfigWarning: AppReadyRuntimeDeps['logConfigWarning'];
|
||||
initRuntimeOptionsManager: AppReadyRuntimeDeps['initRuntimeOptionsManager'];
|
||||
setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode'];
|
||||
defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode'];
|
||||
defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort'];
|
||||
hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin'];
|
||||
startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket'];
|
||||
log: AppReadyRuntimeDeps['log'];
|
||||
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
|
||||
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
|
||||
createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker'];
|
||||
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
||||
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
||||
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
||||
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||
now?: AppReadyRuntimeDeps['now'];
|
||||
}
|
||||
|
||||
export function createAppLifecycleRuntimeDeps(
|
||||
params: AppLifecycleRuntimeDepsFactoryInput,
|
||||
): AppLifecycleDepsRuntimeOptions {
|
||||
return {
|
||||
app: params.app,
|
||||
platform: params.platform,
|
||||
shouldStartApp: params.shouldStartApp,
|
||||
parseArgs: params.parseArgs,
|
||||
handleCliCommand: params.handleCliCommand,
|
||||
printHelp: params.printHelp,
|
||||
logNoRunningInstance: params.logNoRunningInstance,
|
||||
onReady: params.onReady,
|
||||
onWillQuitCleanup: params.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
||||
restoreWindowsOnActivate: params.restoreWindowsOnActivate,
|
||||
shouldQuitOnWindowAllClosed: params.shouldQuitOnWindowAllClosed,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppReadyRuntimeDeps(
|
||||
params: AppReadyRuntimeDepsFactoryInput,
|
||||
): AppReadyRuntimeDeps {
|
||||
return {
|
||||
loadSubtitlePosition: params.loadSubtitlePosition,
|
||||
resolveKeybindings: params.resolveKeybindings,
|
||||
createMpvClient: params.createMpvClient,
|
||||
reloadConfig: params.reloadConfig,
|
||||
getResolvedConfig: params.getResolvedConfig,
|
||||
getConfigWarnings: params.getConfigWarnings,
|
||||
logConfigWarning: params.logConfigWarning,
|
||||
initRuntimeOptionsManager: params.initRuntimeOptionsManager,
|
||||
setSecondarySubMode: params.setSecondarySubMode,
|
||||
defaultSecondarySubMode: params.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: params.defaultWebsocketPort,
|
||||
hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin,
|
||||
startSubtitleWebsocket: params.startSubtitleWebsocket,
|
||||
log: params.log,
|
||||
setLogLevel: params.setLogLevel,
|
||||
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
|
||||
createSubtitleTimingTracker: params.createSubtitleTimingTracker,
|
||||
createImmersionTracker: params.createImmersionTracker,
|
||||
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: params.loadYomitanExtension,
|
||||
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||
texthookerOnlyMode: params.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig:
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||
handleInitialArgs: params.handleInitialArgs,
|
||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||
logDebug: params.logDebug,
|
||||
now: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppReadyRuntimeRunner(
|
||||
params: AppReadyRuntimeDepsFactoryInput,
|
||||
): () => Promise<void> {
|
||||
return async () => {
|
||||
await runAppReadyRuntime(createAppReadyRuntimeDeps(params));
|
||||
};
|
||||
}
|
||||
136
src/main/cli-runtime.ts
Normal file
136
src/main/cli-runtime.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import {
|
||||
createCliCommandRuntimeServiceDeps,
|
||||
CliCommandRuntimeServiceDepsParams,
|
||||
} from './dependencies';
|
||||
|
||||
export interface CliCommandRuntimeServiceContext {
|
||||
getSocketPath: () => string;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient'];
|
||||
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
shouldOpenBrowser: () => boolean;
|
||||
openInBrowser: (url: string) => 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: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
log: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, err: unknown) => void;
|
||||
}
|
||||
|
||||
export interface CliCommandRuntimeServiceContextHandlers {
|
||||
texthookerService: CliCommandRuntimeServiceDepsParams['texthooker']['service'];
|
||||
}
|
||||
|
||||
function createCliCommandDepsFromContext(
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
): CliCommandRuntimeServiceDepsParams {
|
||||
return {
|
||||
mpv: {
|
||||
getSocketPath: context.getSocketPath,
|
||||
setSocketPath: context.setSocketPath,
|
||||
getClient: context.getClient,
|
||||
showOsd: context.showOsd,
|
||||
},
|
||||
texthooker: {
|
||||
service: context.texthookerService,
|
||||
getPort: context.getTexthookerPort,
|
||||
setPort: context.setTexthookerPort,
|
||||
shouldOpenBrowser: context.shouldOpenBrowser,
|
||||
openInBrowser: context.openInBrowser,
|
||||
},
|
||||
overlay: {
|
||||
isInitialized: context.isOverlayInitialized,
|
||||
initialize: context.initializeOverlay,
|
||||
toggleVisible: context.toggleVisibleOverlay,
|
||||
toggleInvisible: context.toggleInvisibleOverlay,
|
||||
setVisible: context.setVisibleOverlay,
|
||||
setInvisible: context.setInvisibleOverlay,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: context.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: context.startPendingMultiCopy,
|
||||
mineSentenceCard: context.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
|
||||
refreshKnownWords: context.refreshKnownWordCache,
|
||||
triggerFieldGrouping: context.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
|
||||
},
|
||||
anilist: {
|
||||
getStatus: context.getAnilistStatus,
|
||||
clearToken: context.clearAnilistToken,
|
||||
openSetup: context.openAnilistSetup,
|
||||
getQueueStatus: context.getAnilistQueueStatus,
|
||||
retryQueueNow: context.retryAnilistQueueNow,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
runCommand: context.runJellyfinCommand,
|
||||
},
|
||||
ui: {
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
},
|
||||
app: {
|
||||
stop: context.stopApp,
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
},
|
||||
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
||||
schedule: context.schedule,
|
||||
log: context.log,
|
||||
warn: context.warn,
|
||||
error: context.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleCliCommandRuntimeService(
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
params: CliCommandRuntimeServiceDepsParams,
|
||||
): void {
|
||||
const deps = createCliCommandDepsRuntime(createCliCommandRuntimeServiceDeps(params));
|
||||
handleCliCommand(args, source, deps);
|
||||
}
|
||||
|
||||
export function handleCliCommandRuntimeServiceWithContext(
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
): void {
|
||||
handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context));
|
||||
}
|
||||
90
src/main/config-validation.test.ts
Normal file
90
src/main/config-validation.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
buildConfigWarningSummary,
|
||||
failStartupFromConfig,
|
||||
formatConfigValue,
|
||||
} from './config-validation';
|
||||
|
||||
test('formatConfigValue handles undefined and JSON values', () => {
|
||||
assert.equal(formatConfigValue(undefined), 'undefined');
|
||||
assert.equal(formatConfigValue({ x: 1 }), '{"x":1}');
|
||||
assert.equal(formatConfigValue(['a', 2]), '["a",2]');
|
||||
});
|
||||
|
||||
test('buildConfigWarningSummary includes warnings with formatted values', () => {
|
||||
const summary = buildConfigWarningSummary('/tmp/config.jsonc', [
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
message: 'must be >= 50',
|
||||
value: 20,
|
||||
fallback: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(summary, /Validation found 1 issue\(s\)\. File: \/tmp\/config\.jsonc/);
|
||||
assert.match(summary, /ankiConnect\.pollingRate: must be >= 50 actual=20 fallback=250/);
|
||||
});
|
||||
|
||||
test('buildConfigWarningNotificationBody includes concise warning details', () => {
|
||||
const body = buildConfigWarningNotificationBody('/tmp/config.jsonc', [
|
||||
{
|
||||
path: 'ankiConnect.openRouter',
|
||||
message: 'Deprecated key; use ankiConnect.ai instead.',
|
||||
value: { enabled: true },
|
||||
fallback: {},
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||
message: 'Deprecated key; sentence-card sentence field is fixed to Sentence.',
|
||||
value: 'Sentence',
|
||||
fallback: 'Sentence',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(body, /2 config validation issue\(s\) detected\./);
|
||||
assert.match(body, /File: \/tmp\/config\.jsonc/);
|
||||
assert.match(body, /1\. ankiConnect\.openRouter: Deprecated key; use ankiConnect\.ai instead\./);
|
||||
assert.match(
|
||||
body,
|
||||
/2\. ankiConnect\.isLapis\.sentenceCardSentenceField: Deprecated key; sentence-card sentence field is fixed to Sentence\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConfigParseErrorDetails includes path error and restart guidance', () => {
|
||||
const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1');
|
||||
|
||||
assert.match(details, /Failed to parse config file at:/);
|
||||
assert.match(details, /\/tmp\/config\.jsonc/);
|
||||
assert.match(details, /Error: unexpected token at line 1/);
|
||||
assert.match(details, /Fix the config file and restart SubMiner\./);
|
||||
});
|
||||
|
||||
test('failStartupFromConfig invokes handlers and throws', () => {
|
||||
const calls: string[] = [];
|
||||
const previousExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
failStartupFromConfig('Config Error', 'bad value', {
|
||||
logError: (details) => {
|
||||
calls.push(`log:${details}`);
|
||||
},
|
||||
showErrorBox: (title, details) => {
|
||||
calls.push(`dialog:${title}:${details}`);
|
||||
},
|
||||
quit: () => {
|
||||
calls.push('quit');
|
||||
},
|
||||
}),
|
||||
/bad value/,
|
||||
);
|
||||
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.deepEqual(calls, ['log:bad value', 'dialog:Config Error:bad value', 'quit']);
|
||||
|
||||
process.exitCode = previousExitCode;
|
||||
});
|
||||
85
src/main/config-validation.ts
Normal file
85
src/main/config-validation.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { ConfigValidationWarning } from '../types';
|
||||
|
||||
export type StartupFailureHandlers = {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
quit: () => void;
|
||||
};
|
||||
|
||||
export function formatConfigValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigWarningSummary(
|
||||
configPath: string,
|
||||
warnings: ConfigValidationWarning[],
|
||||
): string {
|
||||
const lines = [
|
||||
`[config] Validation found ${warnings.length} issue(s). File: ${configPath}`,
|
||||
...warnings.map(
|
||||
(warning, index) =>
|
||||
`[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`,
|
||||
),
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildConfigWarningNotificationBody(
|
||||
configPath: string,
|
||||
warnings: ConfigValidationWarning[],
|
||||
): string {
|
||||
const maxLines = 3;
|
||||
const maxPathLength = 48;
|
||||
|
||||
const trimPath = (value: string): string =>
|
||||
value.length > maxPathLength ? `...${value.slice(-(maxPathLength - 3))}` : value;
|
||||
const clippedPath = trimPath(configPath);
|
||||
|
||||
const lines = warnings.slice(0, maxLines).map((warning, index) => {
|
||||
const message = `${warning.path}: ${warning.message}`;
|
||||
return `${index + 1}. ${message}`;
|
||||
});
|
||||
|
||||
const overflow = warnings.length - lines.length;
|
||||
if (overflow > 0) {
|
||||
lines.push(`+${overflow} more issue(s)`);
|
||||
}
|
||||
|
||||
return [
|
||||
`${warnings.length} config validation issue(s) detected.`,
|
||||
'Defaults were applied where possible.',
|
||||
`File: ${clippedPath}`,
|
||||
...lines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildConfigParseErrorDetails(configPath: string, parseError: string): string {
|
||||
return [
|
||||
'Failed to parse config file at:',
|
||||
configPath,
|
||||
'',
|
||||
`Error: ${parseError}`,
|
||||
'',
|
||||
'Fix the config file and restart SubMiner.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function failStartupFromConfig(
|
||||
title: string,
|
||||
details: string,
|
||||
handlers: StartupFailureHandlers,
|
||||
): never {
|
||||
handlers.logError(details);
|
||||
handlers.showErrorBox(title, details);
|
||||
process.exitCode = 1;
|
||||
handlers.quit();
|
||||
throw new Error(details);
|
||||
}
|
||||
341
src/main/dependencies.ts
Normal file
341
src/main/dependencies.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
|
||||
import { SubsyncResolvedConfig } from '../subsync/utils';
|
||||
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
|
||||
import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
|
||||
import type { AnkiJimakuIpcRuntimeOptions } from '../core/services/anki-jimaku';
|
||||
import type { CliCommandDepsRuntimeOptions } from '../core/services/cli-command';
|
||||
import type { HandleMpvCommandFromIpcOptions } from '../core/services/ipc-command';
|
||||
import {
|
||||
cycleRuntimeOptionFromIpcRuntime,
|
||||
setRuntimeOptionFromIpcRuntime,
|
||||
} from '../core/services/runtime-options-ipc';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
|
||||
export interface RuntimeOptionsIpcDepsParams {
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface SubsyncRuntimeDepsParams {
|
||||
getMpvClient: () => ReturnType<SubsyncRuntimeDeps['getMpvClient']>;
|
||||
getResolvedSubsyncConfig: () => SubsyncResolvedConfig;
|
||||
isSubsyncInProgress: () => boolean;
|
||||
setSubsyncInProgress: (inProgress: boolean) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openManualPicker: (payload: SubsyncManualPayload) => void;
|
||||
}
|
||||
|
||||
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): {
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
} {
|
||||
return {
|
||||
setRuntimeOption: (id, value) =>
|
||||
setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) =>
|
||||
params.showMpvOsd(text),
|
||||
),
|
||||
cycleRuntimeOption: (id, direction) =>
|
||||
cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) =>
|
||||
params.showMpvOsd(text),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): SubsyncRuntimeDeps {
|
||||
return {
|
||||
getMpvClient: params.getMpvClient,
|
||||
getResolvedSubsyncConfig: params.getResolvedSubsyncConfig,
|
||||
isSubsyncInProgress: params.isSubsyncInProgress,
|
||||
setSubsyncInProgress: params.setSubsyncInProgress,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
openManualPicker: params.openManualPicker,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainIpcRuntimeServiceDepsParams {
|
||||
getInvisibleWindow: IpcDepsRuntimeOptions['getInvisibleWindow'];
|
||||
getMainWindow: IpcDepsRuntimeOptions['getMainWindow'];
|
||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||
getInvisibleOverlayVisibility: IpcDepsRuntimeOptions['getInvisibleOverlayVisibility'];
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
|
||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
|
||||
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
|
||||
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||
getAnkiConnectStatus: IpcDepsRuntimeOptions['getAnkiConnectStatus'];
|
||||
getRuntimeOptions: IpcDepsRuntimeOptions['getRuntimeOptions'];
|
||||
setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption'];
|
||||
cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption'];
|
||||
reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds'];
|
||||
reportHoveredSubtitleToken: IpcDepsRuntimeOptions['reportHoveredSubtitleToken'];
|
||||
getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus'];
|
||||
clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken'];
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
}
|
||||
|
||||
export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
patchAnkiConnectEnabled: AnkiJimakuIpcRuntimeOptions['patchAnkiConnectEnabled'];
|
||||
getResolvedConfig: AnkiJimakuIpcRuntimeOptions['getResolvedConfig'];
|
||||
getRuntimeOptionsManager: AnkiJimakuIpcRuntimeOptions['getRuntimeOptionsManager'];
|
||||
getSubtitleTimingTracker: AnkiJimakuIpcRuntimeOptions['getSubtitleTimingTracker'];
|
||||
getMpvClient: AnkiJimakuIpcRuntimeOptions['getMpvClient'];
|
||||
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
|
||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
|
||||
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
|
||||
setFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['setFieldGroupingResolver'];
|
||||
parseMediaInfo: AnkiJimakuIpcRuntimeOptions['parseMediaInfo'];
|
||||
getCurrentMediaPath: AnkiJimakuIpcRuntimeOptions['getCurrentMediaPath'];
|
||||
jimakuFetchJson: AnkiJimakuIpcRuntimeOptions['jimakuFetchJson'];
|
||||
getJimakuMaxEntryResults: AnkiJimakuIpcRuntimeOptions['getJimakuMaxEntryResults'];
|
||||
getJimakuLanguagePreference: AnkiJimakuIpcRuntimeOptions['getJimakuLanguagePreference'];
|
||||
resolveJimakuApiKey: AnkiJimakuIpcRuntimeOptions['resolveJimakuApiKey'];
|
||||
isRemoteMediaPath: AnkiJimakuIpcRuntimeOptions['isRemoteMediaPath'];
|
||||
downloadToFile: AnkiJimakuIpcRuntimeOptions['downloadToFile'];
|
||||
}
|
||||
|
||||
export interface CliCommandRuntimeServiceDepsParams {
|
||||
mpv: {
|
||||
getSocketPath: CliCommandDepsRuntimeOptions['mpv']['getSocketPath'];
|
||||
setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath'];
|
||||
getClient: CliCommandDepsRuntimeOptions['mpv']['getClient'];
|
||||
showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd'];
|
||||
};
|
||||
texthooker: {
|
||||
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
|
||||
getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort'];
|
||||
setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort'];
|
||||
shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser'];
|
||||
openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser'];
|
||||
};
|
||||
overlay: {
|
||||
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
|
||||
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
|
||||
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
|
||||
toggleInvisible: CliCommandDepsRuntimeOptions['overlay']['toggleInvisible'];
|
||||
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
|
||||
setInvisible: CliCommandDepsRuntimeOptions['overlay']['setInvisible'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: CliCommandDepsRuntimeOptions['mining']['copyCurrentSubtitle'];
|
||||
startPendingMultiCopy: CliCommandDepsRuntimeOptions['mining']['startPendingMultiCopy'];
|
||||
mineSentenceCard: CliCommandDepsRuntimeOptions['mining']['mineSentenceCard'];
|
||||
startPendingMineSentenceMultiple: CliCommandDepsRuntimeOptions['mining']['startPendingMineSentenceMultiple'];
|
||||
updateLastCardFromClipboard: CliCommandDepsRuntimeOptions['mining']['updateLastCardFromClipboard'];
|
||||
refreshKnownWords: CliCommandDepsRuntimeOptions['mining']['refreshKnownWords'];
|
||||
triggerFieldGrouping: CliCommandDepsRuntimeOptions['mining']['triggerFieldGrouping'];
|
||||
triggerSubsyncFromConfig: CliCommandDepsRuntimeOptions['mining']['triggerSubsyncFromConfig'];
|
||||
markLastCardAsAudioCard: CliCommandDepsRuntimeOptions['mining']['markLastCardAsAudioCard'];
|
||||
};
|
||||
anilist: {
|
||||
getStatus: CliCommandDepsRuntimeOptions['anilist']['getStatus'];
|
||||
clearToken: CliCommandDepsRuntimeOptions['anilist']['clearToken'];
|
||||
openSetup: CliCommandDepsRuntimeOptions['anilist']['openSetup'];
|
||||
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
|
||||
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||
};
|
||||
ui: {
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
};
|
||||
app: {
|
||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
};
|
||||
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
||||
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
||||
log: CliCommandDepsRuntimeOptions['log'];
|
||||
warn: CliCommandDepsRuntimeOptions['warn'];
|
||||
error: CliCommandDepsRuntimeOptions['error'];
|
||||
}
|
||||
|
||||
export interface MpvCommandRuntimeServiceDepsParams {
|
||||
specialCommands: HandleMpvCommandFromIpcOptions['specialCommands'];
|
||||
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
|
||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
|
||||
}
|
||||
|
||||
export function createMainIpcRuntimeServiceDeps(
|
||||
params: MainIpcRuntimeServiceDepsParams,
|
||||
): IpcDepsRuntimeOptions {
|
||||
return {
|
||||
getInvisibleWindow: params.getInvisibleWindow,
|
||||
getMainWindow: params.getMainWindow,
|
||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||
getInvisibleOverlayVisibility: params.getInvisibleOverlayVisibility,
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
|
||||
getSubtitlePosition: params.getSubtitlePosition,
|
||||
getSubtitleStyle: params.getSubtitleStyle,
|
||||
saveSubtitlePosition: params.saveSubtitlePosition,
|
||||
getMecabTokenizer: params.getMecabTokenizer,
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||
getSecondarySubMode: params.getSecondarySubMode,
|
||||
getMpvClient: params.getMpvClient,
|
||||
runSubsyncManual: params.runSubsyncManual,
|
||||
getAnkiConnectStatus: params.getAnkiConnectStatus,
|
||||
getRuntimeOptions: params.getRuntimeOptions,
|
||||
setRuntimeOption: params.setRuntimeOption,
|
||||
cycleRuntimeOption: params.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: params.reportOverlayContentBounds,
|
||||
reportHoveredSubtitleToken: params.reportHoveredSubtitleToken,
|
||||
getAnilistStatus: params.getAnilistStatus,
|
||||
clearAnilistToken: params.clearAnilistToken,
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): AnkiJimakuIpcRuntimeOptions {
|
||||
return {
|
||||
patchAnkiConnectEnabled: params.patchAnkiConnectEnabled,
|
||||
getResolvedConfig: params.getResolvedConfig,
|
||||
getRuntimeOptionsManager: params.getRuntimeOptionsManager,
|
||||
getSubtitleTimingTracker: params.getSubtitleTimingTracker,
|
||||
getMpvClient: params.getMpvClient,
|
||||
getAnkiIntegration: params.getAnkiIntegration,
|
||||
setAnkiIntegration: params.setAnkiIntegration,
|
||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||
getFieldGroupingResolver: params.getFieldGroupingResolver,
|
||||
setFieldGroupingResolver: params.setFieldGroupingResolver,
|
||||
parseMediaInfo: params.parseMediaInfo,
|
||||
getCurrentMediaPath: params.getCurrentMediaPath,
|
||||
jimakuFetchJson: params.jimakuFetchJson,
|
||||
getJimakuMaxEntryResults: params.getJimakuMaxEntryResults,
|
||||
getJimakuLanguagePreference: params.getJimakuLanguagePreference,
|
||||
resolveJimakuApiKey: params.resolveJimakuApiKey,
|
||||
isRemoteMediaPath: params.isRemoteMediaPath,
|
||||
downloadToFile: params.downloadToFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCliCommandRuntimeServiceDeps(
|
||||
params: CliCommandRuntimeServiceDepsParams,
|
||||
): CliCommandDepsRuntimeOptions {
|
||||
return {
|
||||
mpv: {
|
||||
getSocketPath: params.mpv.getSocketPath,
|
||||
setSocketPath: params.mpv.setSocketPath,
|
||||
getClient: params.mpv.getClient,
|
||||
showOsd: params.mpv.showOsd,
|
||||
},
|
||||
texthooker: {
|
||||
service: params.texthooker.service,
|
||||
getPort: params.texthooker.getPort,
|
||||
setPort: params.texthooker.setPort,
|
||||
shouldOpenBrowser: params.texthooker.shouldOpenBrowser,
|
||||
openInBrowser: params.texthooker.openInBrowser,
|
||||
},
|
||||
overlay: {
|
||||
isInitialized: params.overlay.isInitialized,
|
||||
initialize: params.overlay.initialize,
|
||||
toggleVisible: params.overlay.toggleVisible,
|
||||
toggleInvisible: params.overlay.toggleInvisible,
|
||||
setVisible: params.overlay.setVisible,
|
||||
setInvisible: params.overlay.setInvisible,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: params.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: params.mining.startPendingMultiCopy,
|
||||
mineSentenceCard: params.mining.mineSentenceCard,
|
||||
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
|
||||
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
|
||||
refreshKnownWords: params.mining.refreshKnownWords,
|
||||
triggerFieldGrouping: params.mining.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard,
|
||||
},
|
||||
anilist: {
|
||||
getStatus: params.anilist.getStatus,
|
||||
clearToken: params.anilist.clearToken,
|
||||
openSetup: params.anilist.openSetup,
|
||||
getQueueStatus: params.anilist.getQueueStatus,
|
||||
retryQueueNow: params.anilist.retryQueueNow,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
runCommand: params.jellyfin.runCommand,
|
||||
},
|
||||
ui: {
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
},
|
||||
app: {
|
||||
stop: params.app.stop,
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
},
|
||||
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
||||
schedule: params.schedule,
|
||||
log: params.log,
|
||||
warn: params.warn,
|
||||
error: params.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMpvCommandRuntimeServiceDeps(
|
||||
params: MpvCommandRuntimeServiceDepsParams,
|
||||
): HandleMpvCommandFromIpcOptions {
|
||||
return {
|
||||
specialCommands: params.specialCommands,
|
||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
mpvSendCommand: params.mpvSendCommand,
|
||||
isMpvConnected: params.isMpvConnected,
|
||||
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
|
||||
};
|
||||
}
|
||||
86
src/main/frequency-dictionary-runtime.ts
Normal file
86
src/main/frequency-dictionary-runtime.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as path from 'path';
|
||||
import type { FrequencyDictionaryLookup } from '../types';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services';
|
||||
|
||||
export interface FrequencyDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
getSourcePath?: () => string | undefined;
|
||||
}
|
||||
|
||||
export interface FrequencyDictionaryRuntimeDeps {
|
||||
isFrequencyDictionaryEnabled: () => boolean;
|
||||
getSearchPaths: () => string[];
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
let frequencyDictionaryLookupInitialized = false;
|
||||
let frequencyDictionaryLookupInitialization: Promise<void> | null = null;
|
||||
|
||||
// Frequency dictionary services are initialized lazily as a process-wide singleton.
|
||||
// Initialization is idempotent and intentionally shared across callers.
|
||||
|
||||
export function getFrequencyDictionarySearchPaths(
|
||||
deps: FrequencyDictionarySearchPathDeps,
|
||||
): string[] {
|
||||
const dictionaryRoots = deps.getDictionaryRoots();
|
||||
const sourcePath = deps.getSourcePath?.();
|
||||
|
||||
const rawSearchPaths: string[] = [];
|
||||
// User-provided path takes precedence over bundled/default roots.
|
||||
// Root list should include `vendor/jiten_freq_global` in callers.
|
||||
if (sourcePath && sourcePath.trim()) {
|
||||
rawSearchPaths.push(sourcePath.trim());
|
||||
rawSearchPaths.push(path.join(sourcePath.trim(), 'frequency-dictionary'));
|
||||
rawSearchPaths.push(path.join(sourcePath.trim(), 'vendor', 'frequency-dictionary'));
|
||||
}
|
||||
|
||||
for (const dictionaryRoot of dictionaryRoots) {
|
||||
rawSearchPaths.push(dictionaryRoot);
|
||||
rawSearchPaths.push(path.join(dictionaryRoot, 'frequency-dictionary'));
|
||||
rawSearchPaths.push(path.join(dictionaryRoot, 'vendor', 'frequency-dictionary'));
|
||||
}
|
||||
|
||||
return [...new Set(rawSearchPaths)];
|
||||
}
|
||||
|
||||
export async function initializeFrequencyDictionaryLookup(
|
||||
deps: FrequencyDictionaryRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const lookup = await createFrequencyDictionaryLookup({
|
||||
searchPaths: deps.getSearchPaths(),
|
||||
log: deps.log,
|
||||
});
|
||||
deps.setFrequencyRankLookup(lookup);
|
||||
}
|
||||
|
||||
export async function ensureFrequencyDictionaryLookup(
|
||||
deps: FrequencyDictionaryRuntimeDeps,
|
||||
): Promise<void> {
|
||||
if (!deps.isFrequencyDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (frequencyDictionaryLookupInitialized) {
|
||||
return;
|
||||
}
|
||||
if (!frequencyDictionaryLookupInitialization) {
|
||||
frequencyDictionaryLookupInitialization = initializeFrequencyDictionaryLookup(deps)
|
||||
.then(() => {
|
||||
frequencyDictionaryLookupInitialized = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
frequencyDictionaryLookupInitialized = true;
|
||||
deps.log(`Failed to initialize frequency dictionary: ${String(error)}`);
|
||||
deps.setFrequencyRankLookup(() => null);
|
||||
});
|
||||
}
|
||||
await frequencyDictionaryLookupInitialization;
|
||||
}
|
||||
|
||||
export function createFrequencyDictionaryRuntimeService(deps: FrequencyDictionaryRuntimeDeps): {
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps),
|
||||
};
|
||||
}
|
||||
37
src/main/ipc-mpv-command.ts
Normal file
37
src/main/ipc-mpv-command.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../types';
|
||||
import { handleMpvCommandFromIpc } from '../core/services';
|
||||
import { createMpvCommandRuntimeServiceDeps } from './dependencies';
|
||||
import { SPECIAL_COMMANDS } from '../config';
|
||||
|
||||
export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
hasRuntimeOptionsManager: () => boolean;
|
||||
}
|
||||
|
||||
export function handleMpvCommandFromIpcRuntime(
|
||||
command: (string | number)[],
|
||||
deps: MpvCommandFromIpcRuntimeDeps,
|
||||
): void {
|
||||
handleMpvCommandFromIpc(
|
||||
command,
|
||||
createMpvCommandRuntimeServiceDeps({
|
||||
specialCommands: SPECIAL_COMMANDS,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
mpvSendCommand: deps.sendMpvCommand,
|
||||
isMpvConnected: deps.isMpvConnected,
|
||||
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
|
||||
}),
|
||||
);
|
||||
}
|
||||
46
src/main/ipc-runtime.ts
Normal file
46
src/main/ipc-runtime.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
RuntimeOptionsIpcDepsParams,
|
||||
} from './dependencies';
|
||||
|
||||
export interface RegisterIpcRuntimeServicesParams {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
mainDeps: Omit<MainIpcRuntimeServiceDepsParams, 'setRuntimeOption' | 'cycleRuntimeOption'>;
|
||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeServices(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||
});
|
||||
registerMainIpcRuntimeServices({
|
||||
...params.mainDeps,
|
||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||
});
|
||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||
}
|
||||
73
src/main/jlpt-runtime.ts
Normal file
73
src/main/jlpt-runtime.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as path from 'path';
|
||||
import type { JlptLevel } from '../types';
|
||||
|
||||
import { createJlptVocabularyLookup } from '../core/services';
|
||||
|
||||
export interface JlptDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
}
|
||||
|
||||
export type JlptLookup = (term: string) => JlptLevel | null;
|
||||
|
||||
export interface JlptDictionaryRuntimeDeps {
|
||||
isJlptEnabled: () => boolean;
|
||||
getSearchPaths: () => string[];
|
||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
let jlptDictionaryLookupInitialized = false;
|
||||
let jlptDictionaryLookupInitialization: Promise<void> | null = null;
|
||||
|
||||
export function getJlptDictionarySearchPaths(deps: JlptDictionarySearchPathDeps): string[] {
|
||||
const dictionaryRoots = deps.getDictionaryRoots();
|
||||
|
||||
const searchPaths: string[] = [];
|
||||
for (const dictionaryRoot of dictionaryRoots) {
|
||||
searchPaths.push(dictionaryRoot);
|
||||
searchPaths.push(path.join(dictionaryRoot, 'vendor', 'yomitan-jlpt-vocab'));
|
||||
searchPaths.push(path.join(dictionaryRoot, 'yomitan-jlpt-vocab'));
|
||||
}
|
||||
|
||||
const uniquePaths = new Set<string>(searchPaths);
|
||||
return [...uniquePaths];
|
||||
}
|
||||
|
||||
export async function initializeJlptDictionaryLookup(
|
||||
deps: JlptDictionaryRuntimeDeps,
|
||||
): Promise<void> {
|
||||
deps.setJlptLevelLookup(
|
||||
await createJlptVocabularyLookup({
|
||||
searchPaths: deps.getSearchPaths(),
|
||||
log: deps.log,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureJlptDictionaryLookup(deps: JlptDictionaryRuntimeDeps): Promise<void> {
|
||||
if (!deps.isJlptEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (jlptDictionaryLookupInitialized) {
|
||||
return;
|
||||
}
|
||||
if (!jlptDictionaryLookupInitialization) {
|
||||
jlptDictionaryLookupInitialization = initializeJlptDictionaryLookup(deps)
|
||||
.then(() => {
|
||||
jlptDictionaryLookupInitialized = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
jlptDictionaryLookupInitialization = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
await jlptDictionaryLookupInitialization;
|
||||
}
|
||||
|
||||
export function createJlptDictionaryRuntimeService(deps: JlptDictionaryRuntimeDeps): {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
ensureJlptDictionaryLookup: () => ensureJlptDictionaryLookup(deps),
|
||||
};
|
||||
}
|
||||
68
src/main/media-runtime.ts
Normal file
68
src/main/media-runtime.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { updateCurrentMediaPath } from '../core/services';
|
||||
|
||||
import type { SubtitlePosition } from '../types';
|
||||
|
||||
export interface MediaRuntimeDeps {
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
loadSubtitlePosition: () => SubtitlePosition | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getPendingSubtitlePosition: () => SubtitlePosition | null;
|
||||
getSubtitlePositionsDir: () => string;
|
||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
||||
clearPendingSubtitlePosition: () => void;
|
||||
setSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
setCurrentMediaTitle: (title: string | null) => void;
|
||||
}
|
||||
|
||||
export interface MediaRuntimeService {
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
}
|
||||
|
||||
export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeService {
|
||||
return {
|
||||
updateCurrentMediaPath(mediaPath: unknown): void {
|
||||
if (typeof mediaPath !== 'string' || !deps.isRemoteMediaPath(mediaPath)) {
|
||||
deps.setCurrentMediaTitle(null);
|
||||
}
|
||||
|
||||
updateCurrentMediaPath({
|
||||
mediaPath,
|
||||
currentMediaPath: deps.getCurrentMediaPath(),
|
||||
pendingSubtitlePosition: deps.getPendingSubtitlePosition(),
|
||||
subtitlePositionsDir: deps.getSubtitlePositionsDir(),
|
||||
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
|
||||
setCurrentMediaPath: (nextPath: string | null) => {
|
||||
deps.setCurrentMediaPath(nextPath);
|
||||
},
|
||||
clearPendingSubtitlePosition: () => {
|
||||
deps.clearPendingSubtitlePosition();
|
||||
},
|
||||
setSubtitlePosition: (position: SubtitlePosition | null) => {
|
||||
deps.setSubtitlePosition(position);
|
||||
},
|
||||
broadcastSubtitlePosition: (position: SubtitlePosition | null) => {
|
||||
deps.broadcastSubtitlePosition(position);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateCurrentMediaTitle(mediaTitle: unknown): void {
|
||||
if (typeof mediaTitle === 'string') {
|
||||
const sanitized = mediaTitle.trim();
|
||||
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
|
||||
return;
|
||||
}
|
||||
deps.setCurrentMediaTitle(null);
|
||||
},
|
||||
|
||||
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
|
||||
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
|
||||
? deps.getCurrentMediaTitle()
|
||||
: mediaPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
134
src/main/overlay-runtime.ts
Normal file
134
src/main/overlay-runtime.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku';
|
||||
type OverlayHostLayer = 'visible' | 'invisible';
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
export interface OverlayModalRuntime {
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): OverlayModalRuntime {
|
||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>();
|
||||
|
||||
const getTargetOverlayWindow = (): {
|
||||
window: BrowserWindow;
|
||||
layer: OverlayHostLayer;
|
||||
} | null => {
|
||||
const visibleMainWindow = deps.getMainWindow();
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
|
||||
if (visibleMainWindow && !visibleMainWindow.isDestroyed()) {
|
||||
return { window: visibleMainWindow, layer: 'visible' };
|
||||
}
|
||||
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
return { window: invisibleWindow, layer: 'invisible' };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
|
||||
if (layer === 'invisible' && typeof window.showInactive === 'function') {
|
||||
window.showInactive();
|
||||
} else {
|
||||
window.show();
|
||||
}
|
||||
if (!window.isFocused()) {
|
||||
window.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const sendToActiveOverlayWindow = (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
||||
): boolean => {
|
||||
const target = getTargetOverlayWindow();
|
||||
if (!target) return false;
|
||||
|
||||
const { window: targetWindow, layer } = target;
|
||||
const wasVisible = targetWindow.isVisible();
|
||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||
|
||||
const sendNow = (): void => {
|
||||
if (payload === undefined) {
|
||||
targetWindow.webContents.send(channel);
|
||||
} else {
|
||||
targetWindow.webContents.send(channel, payload);
|
||||
}
|
||||
};
|
||||
|
||||
if (!wasVisible) {
|
||||
showOverlayWindowForModal(targetWindow, layer);
|
||||
}
|
||||
if (!wasVisible && restoreOnModalClose) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
overlayModalAutoShownLayer.set(restoreOnModalClose, layer);
|
||||
}
|
||||
|
||||
if (targetWindow.webContents.isLoading()) {
|
||||
targetWindow.webContents.once('did-finish-load', () => {
|
||||
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
|
||||
sendNow();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
sendNow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const openRuntimeOptionsPalette = (): void => {
|
||||
sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
|
||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||
restoreVisibleOverlayOnModalClose.delete(modal);
|
||||
const layer = overlayModalAutoShownLayer.get(modal);
|
||||
overlayModalAutoShownLayer.delete(modal);
|
||||
if (!layer) return;
|
||||
const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some(
|
||||
(pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer,
|
||||
);
|
||||
if (shouldKeepLayerVisible) return;
|
||||
|
||||
if (layer === 'visible') {
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
||||
invisibleWindow.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
handleOverlayModalClosed,
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||
};
|
||||
}
|
||||
|
||||
export type { OverlayHostedModal };
|
||||
134
src/main/overlay-shortcuts-runtime.ts
Normal file
134
src/main/overlay-shortcuts-runtime.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { ConfiguredShortcuts } from '../core/utils/shortcut-config';
|
||||
import {
|
||||
createOverlayShortcutRuntimeHandlers,
|
||||
shortcutMatchesInputForLocalFallback,
|
||||
} from '../core/services';
|
||||
import {
|
||||
refreshOverlayShortcutsRuntime,
|
||||
registerOverlayShortcuts,
|
||||
syncOverlayShortcutsRuntime,
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from '../core/services';
|
||||
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
|
||||
|
||||
export interface OverlayShortcutRuntimeServiceInput {
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getShortcutsRegistered: () => boolean;
|
||||
setShortcutsRegistered: (registered: boolean) => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
toggleSecondarySubMode: () => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
mineSentenceMultiple: (timeoutMs: number) => void;
|
||||
cancelPendingMultiCopy: () => void;
|
||||
cancelPendingMineSentenceMultiple: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayShortcutsRuntimeService {
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
registerOverlayShortcuts: () => void;
|
||||
unregisterOverlayShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayShortcutsRuntimeService(
|
||||
input: OverlayShortcutRuntimeServiceInput,
|
||||
): OverlayShortcutsRuntimeService {
|
||||
const handlers = createOverlayShortcutRuntimeHandlers({
|
||||
showMpvOsd: (text: string) => input.showMpvOsd(text),
|
||||
openRuntimeOptions: () => {
|
||||
input.openRuntimeOptionsPalette();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.openJimaku();
|
||||
},
|
||||
markAudioCard: () => {
|
||||
return input.markAudioCard();
|
||||
},
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
input.copySubtitleMultiple(timeoutMs);
|
||||
},
|
||||
copySubtitle: () => {
|
||||
input.copySubtitle();
|
||||
},
|
||||
toggleSecondarySub: () => {
|
||||
input.toggleSecondarySubMode();
|
||||
},
|
||||
updateLastCardFromClipboard: () => {
|
||||
return input.updateLastCardFromClipboard();
|
||||
},
|
||||
triggerFieldGrouping: () => {
|
||||
return input.triggerFieldGrouping();
|
||||
},
|
||||
triggerSubsync: () => {
|
||||
return input.triggerSubsyncFromConfig();
|
||||
},
|
||||
mineSentence: () => {
|
||||
return input.mineSentenceCard();
|
||||
},
|
||||
mineSentenceMultiple: (timeoutMs: number) => {
|
||||
input.mineSentenceMultiple(timeoutMs);
|
||||
},
|
||||
});
|
||||
|
||||
const getShortcutLifecycleDeps = () => {
|
||||
return {
|
||||
getConfiguredShortcuts: () => input.getConfiguredShortcuts(),
|
||||
getOverlayHandlers: () => handlers.overlayHandlers,
|
||||
cancelPendingMultiCopy: () => input.cancelPendingMultiCopy(),
|
||||
cancelPendingMineSentenceMultiple: () => input.cancelPendingMineSentenceMultiple(),
|
||||
};
|
||||
};
|
||||
|
||||
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized();
|
||||
|
||||
return {
|
||||
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
|
||||
runOverlayShortcutLocalFallback(
|
||||
inputEvent,
|
||||
input.getConfiguredShortcuts(),
|
||||
shortcutMatchesInputForLocalFallback,
|
||||
handlers.fallbackHandlers,
|
||||
),
|
||||
registerOverlayShortcuts: () => {
|
||||
input.setShortcutsRegistered(
|
||||
registerOverlayShortcuts(input.getConfiguredShortcuts(), handlers.overlayHandlers),
|
||||
);
|
||||
},
|
||||
unregisterOverlayShortcuts: () => {
|
||||
input.setShortcutsRegistered(
|
||||
unregisterOverlayShortcutsRuntime(
|
||||
input.getShortcutsRegistered(),
|
||||
getShortcutLifecycleDeps(),
|
||||
),
|
||||
);
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
input.setShortcutsRegistered(
|
||||
syncOverlayShortcutsRuntime(
|
||||
shouldOverlayShortcutsBeActive(),
|
||||
input.getShortcutsRegistered(),
|
||||
getShortcutLifecycleDeps(),
|
||||
),
|
||||
);
|
||||
},
|
||||
refreshOverlayShortcuts: () => {
|
||||
input.setShortcutsRegistered(
|
||||
refreshOverlayShortcutsRuntime(
|
||||
shouldOverlayShortcutsBeActive(),
|
||||
input.getShortcutsRegistered(),
|
||||
getShortcutLifecycleDeps(),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
90
src/main/overlay-visibility-runtime.ts
Normal file
90
src/main/overlay-visibility-runtime.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import {
|
||||
syncInvisibleOverlayMousePassthrough,
|
||||
updateInvisibleOverlayVisibility,
|
||||
updateVisibleOverlayVisibility,
|
||||
} from '../core/services';
|
||||
|
||||
export interface OverlayVisibilityRuntimeDeps {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getInvisibleWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getInvisibleOverlayVisible: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayVisibilityRuntimeService {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
syncInvisibleOverlayMousePassthrough: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayVisibilityRuntimeService(
|
||||
deps: OverlayVisibilityRuntimeDeps,
|
||||
): OverlayVisibilityRuntimeService {
|
||||
const hasInvisibleWindow = (): boolean => {
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
return Boolean(invisibleWindow && !invisibleWindow.isDestroyed());
|
||||
};
|
||||
|
||||
const setIgnoreMouseEvents = (
|
||||
ignore: boolean,
|
||||
options?: Parameters<BrowserWindow['setIgnoreMouseEvents']>[1],
|
||||
): void => {
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
if (!invisibleWindow || invisibleWindow.isDestroyed()) return;
|
||||
invisibleWindow.setIgnoreMouseEvents(ignore, options);
|
||||
};
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
deps.setTrackerNotReadyWarningShown(shown);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
});
|
||||
},
|
||||
|
||||
updateInvisibleOverlayVisibility(): void {
|
||||
updateInvisibleOverlayVisibility({
|
||||
invisibleWindow: deps.getInvisibleWindow(),
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateInvisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
});
|
||||
},
|
||||
|
||||
syncInvisibleOverlayMousePassthrough(): void {
|
||||
syncInvisibleOverlayMousePassthrough({
|
||||
hasInvisibleWindow,
|
||||
setIgnoreMouseEvents,
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/main/runtime/anilist-media-guess-main-deps.test.ts
Normal file
78
src/main/runtime/anilist-media-guess-main-deps.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildEnsureAnilistMediaGuessMainDepsHandler,
|
||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||
} from './anilist-media-guess-main-deps';
|
||||
|
||||
test('maybe probe anilist duration main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({
|
||||
getState: () => ({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
}),
|
||||
setState: () => calls.push('set-state'),
|
||||
durationRetryIntervalMs: 1000,
|
||||
now: () => 42,
|
||||
requestMpvDuration: async () => 3600,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.durationRetryIntervalMs, 1000);
|
||||
assert.equal(deps.now(), 42);
|
||||
assert.equal(await deps.requestMpvDuration(), 3600);
|
||||
deps.setState({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: 100,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
deps.logWarn('oops', null);
|
||||
assert.deepEqual(calls, ['set-state', 'warn:oops']);
|
||||
});
|
||||
|
||||
test('ensure anilist media guess main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({
|
||||
getState: () => ({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
}),
|
||||
setState: () => calls.push('set-state'),
|
||||
resolveMediaPathForJimaku: (path) => {
|
||||
calls.push('resolve');
|
||||
return path;
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
getCurrentMediaTitle: () => 'title',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls.push('guess');
|
||||
return { title: 'title', episode: 1, source: 'fallback' };
|
||||
},
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
|
||||
assert.equal(deps.getCurrentMediaTitle(), 'title');
|
||||
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
|
||||
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
|
||||
title: 'title',
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
});
|
||||
deps.setState({
|
||||
mediaKey: 'm',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
assert.deepEqual(calls, ['resolve', 'guess', 'set-state']);
|
||||
});
|
||||
31
src/main/runtime/anilist-media-guess-main-deps.ts
Normal file
31
src/main/runtime/anilist-media-guess-main-deps.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
createEnsureAnilistMediaGuessHandler,
|
||||
createMaybeProbeAnilistDurationHandler,
|
||||
} from './anilist-media-guess';
|
||||
|
||||
type MaybeProbeAnilistDurationMainDeps = Parameters<typeof createMaybeProbeAnilistDurationHandler>[0];
|
||||
type EnsureAnilistMediaGuessMainDeps = Parameters<typeof createEnsureAnilistMediaGuessHandler>[0];
|
||||
|
||||
export function createBuildMaybeProbeAnilistDurationMainDepsHandler(
|
||||
deps: MaybeProbeAnilistDurationMainDeps,
|
||||
) {
|
||||
return (): MaybeProbeAnilistDurationMainDeps => ({
|
||||
getState: () => deps.getState(),
|
||||
setState: (state) => deps.setState(state),
|
||||
durationRetryIntervalMs: deps.durationRetryIntervalMs,
|
||||
now: () => deps.now(),
|
||||
requestMpvDuration: () => deps.requestMpvDuration(),
|
||||
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildEnsureAnilistMediaGuessMainDepsHandler(deps: EnsureAnilistMediaGuessMainDeps) {
|
||||
return (): EnsureAnilistMediaGuessMainDeps => ({
|
||||
getState: () => deps.getState(),
|
||||
setState: (state) => deps.setState(state),
|
||||
resolveMediaPathForJimaku: (currentMediaPath) => deps.resolveMediaPathForJimaku(currentMediaPath),
|
||||
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => deps.getCurrentMediaTitle(),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => deps.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
});
|
||||
}
|
||||
65
src/main/runtime/anilist-media-guess.test.ts
Normal file
65
src/main/runtime/anilist-media-guess.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createEnsureAnilistMediaGuessHandler,
|
||||
createMaybeProbeAnilistDurationHandler,
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
} from './anilist-media-guess';
|
||||
|
||||
test('maybeProbeAnilistDuration updates state with probed duration', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
const probe = createMaybeProbeAnilistDurationHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
durationRetryIntervalMs: 1000,
|
||||
now: () => 2000,
|
||||
requestMpvDuration: async () => 321,
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const duration = await probe('/tmp/video.mkv');
|
||||
assert.equal(duration, 321);
|
||||
assert.equal(state.mediaDurationSec, 321);
|
||||
});
|
||||
|
||||
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
let calls = 0;
|
||||
const ensureGuess = createEnsureAnilistMediaGuessHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
getCurrentMediaTitle: () => 'Episode 1',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
calls += 1;
|
||||
return { title: 'Show', episode: 1, source: 'guessit' };
|
||||
},
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
ensureGuess('/tmp/video.mkv'),
|
||||
ensureGuess('/tmp/video.mkv'),
|
||||
]);
|
||||
assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' });
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
});
|
||||
112
src/main/runtime/anilist-media-guess.ts
Normal file
112
src/main/runtime/anilist-media-guess.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
|
||||
export type AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: string | null;
|
||||
mediaDurationSec: number | null;
|
||||
mediaGuess: AnilistMediaGuess | null;
|
||||
mediaGuessPromise: Promise<AnilistMediaGuess | null> | null;
|
||||
lastDurationProbeAtMs: number;
|
||||
};
|
||||
|
||||
type GuessAnilistMediaInfo = (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
|
||||
export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
durationRetryIntervalMs: number;
|
||||
now: () => number;
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (mediaKey: string): Promise<number | null> => {
|
||||
const state = deps.getState();
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
|
||||
return state.mediaDurationSec;
|
||||
}
|
||||
const now = deps.now();
|
||||
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
deps.setState({
|
||||
...state,
|
||||
lastDurationProbeAtMs: now,
|
||||
});
|
||||
|
||||
try {
|
||||
const durationCandidate = await deps.requestMpvDuration();
|
||||
const duration =
|
||||
typeof durationCandidate === 'number' && Number.isFinite(durationCandidate)
|
||||
? durationCandidate
|
||||
: null;
|
||||
const latestState = deps.getState();
|
||||
if (duration && duration > 0 && latestState.mediaKey === mediaKey) {
|
||||
deps.setState({
|
||||
...latestState,
|
||||
mediaDurationSec: duration,
|
||||
});
|
||||
return duration;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('AniList duration probe failed:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnsureAnilistMediaGuessHandler(deps: {
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
guessAnilistMediaInfo: GuessAnilistMediaInfo;
|
||||
}) {
|
||||
return async (mediaKey: string): Promise<AnilistMediaGuess | null> => {
|
||||
const state = deps.getState();
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
}
|
||||
if (state.mediaGuess) {
|
||||
return state.mediaGuess;
|
||||
}
|
||||
if (state.mediaGuessPromise) {
|
||||
return state.mediaGuessPromise;
|
||||
}
|
||||
|
||||
const mediaPathForGuess = deps.resolveMediaPathForJimaku(deps.getCurrentMediaPath());
|
||||
const promise = deps
|
||||
.guessAnilistMediaInfo(mediaPathForGuess, deps.getCurrentMediaTitle())
|
||||
.then((guess) => {
|
||||
const latestState = deps.getState();
|
||||
if (latestState.mediaKey === mediaKey) {
|
||||
deps.setState({
|
||||
...latestState,
|
||||
mediaGuess: guess,
|
||||
});
|
||||
}
|
||||
return guess;
|
||||
})
|
||||
.finally(() => {
|
||||
const latestState = deps.getState();
|
||||
if (latestState.mediaKey === mediaKey) {
|
||||
deps.setState({
|
||||
...latestState,
|
||||
mediaGuessPromise: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
deps.setState({
|
||||
...state,
|
||||
mediaGuessPromise: promise,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
72
src/main/runtime/anilist-media-state-main-deps.test.ts
Normal file
72
src/main/runtime/anilist-media-state-main-deps.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
} from './anilist-media-state-main-deps';
|
||||
|
||||
test('get current anilist media key main deps builder maps callbacks', () => {
|
||||
const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({
|
||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||
})();
|
||||
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
|
||||
});
|
||||
|
||||
test('reset anilist media tracking main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({
|
||||
setMediaKey: () => calls.push('key'),
|
||||
setMediaDurationSec: () => calls.push('duration'),
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
setLastDurationProbeAtMs: () => calls.push('probe'),
|
||||
})();
|
||||
deps.setMediaKey(null);
|
||||
deps.setMediaDurationSec(null);
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
deps.setLastDurationProbeAtMs(0);
|
||||
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state main deps builders map callbacks', () => {
|
||||
const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({
|
||||
getMediaKey: () => '/tmp/video.mkv',
|
||||
getMediaDurationSec: () => 24,
|
||||
getMediaGuess: () => ({ title: 'X' }) as never,
|
||||
getMediaGuessPromise: () => Promise.resolve(null) as never,
|
||||
getLastDurationProbeAtMs: () => 123,
|
||||
})();
|
||||
assert.equal(getter.getMediaKey(), '/tmp/video.mkv');
|
||||
assert.equal(getter.getMediaDurationSec(), 24);
|
||||
assert.equal(getter.getLastDurationProbeAtMs(), 123);
|
||||
|
||||
const calls: string[] = [];
|
||||
const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({
|
||||
setMediaKey: () => calls.push('key'),
|
||||
setMediaDurationSec: () => calls.push('duration'),
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
setLastDurationProbeAtMs: () => calls.push('probe'),
|
||||
})();
|
||||
setter.setMediaKey(null);
|
||||
setter.setMediaDurationSec(null);
|
||||
setter.setMediaGuess(null);
|
||||
setter.setMediaGuessPromise(null);
|
||||
setter.setLastDurationProbeAtMs(0);
|
||||
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
|
||||
});
|
||||
|
||||
test('reset anilist media guess state main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({
|
||||
setMediaGuess: () => calls.push('guess'),
|
||||
setMediaGuessPromise: () => calls.push('promise'),
|
||||
})();
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
assert.deepEqual(calls, ['guess', 'promise']);
|
||||
});
|
||||
72
src/main/runtime/anilist-media-state-main-deps.ts
Normal file
72
src/main/runtime/anilist-media-state-main-deps.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
} from './anilist-media-state';
|
||||
|
||||
type GetCurrentAnilistMediaKeyMainDeps = Parameters<typeof createGetCurrentAnilistMediaKeyHandler>[0];
|
||||
type ResetAnilistMediaTrackingMainDeps = Parameters<typeof createResetAnilistMediaTrackingHandler>[0];
|
||||
type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
typeof createGetAnilistMediaGuessRuntimeStateHandler
|
||||
>[0];
|
||||
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||
>[0];
|
||||
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
||||
typeof createResetAnilistMediaGuessStateHandler
|
||||
>[0];
|
||||
|
||||
export function createBuildGetCurrentAnilistMediaKeyMainDepsHandler(
|
||||
deps: GetCurrentAnilistMediaKeyMainDeps,
|
||||
) {
|
||||
return (): GetCurrentAnilistMediaKeyMainDeps => ({
|
||||
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildResetAnilistMediaTrackingMainDepsHandler(
|
||||
deps: ResetAnilistMediaTrackingMainDeps,
|
||||
) {
|
||||
return (): ResetAnilistMediaTrackingMainDeps => ({
|
||||
setMediaKey: (value) => deps.setMediaKey(value),
|
||||
setMediaDurationSec: (value) => deps.setMediaDurationSec(value),
|
||||
setMediaGuess: (value) => deps.setMediaGuess(value),
|
||||
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
|
||||
setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
||||
deps: GetAnilistMediaGuessRuntimeStateMainDeps,
|
||||
) {
|
||||
return (): GetAnilistMediaGuessRuntimeStateMainDeps => ({
|
||||
getMediaKey: () => deps.getMediaKey(),
|
||||
getMediaDurationSec: () => deps.getMediaDurationSec(),
|
||||
getMediaGuess: () => deps.getMediaGuess(),
|
||||
getMediaGuessPromise: () => deps.getMediaGuessPromise(),
|
||||
getLastDurationProbeAtMs: () => deps.getLastDurationProbeAtMs(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
||||
deps: SetAnilistMediaGuessRuntimeStateMainDeps,
|
||||
) {
|
||||
return (): SetAnilistMediaGuessRuntimeStateMainDeps => ({
|
||||
setMediaKey: (value) => deps.setMediaKey(value),
|
||||
setMediaDurationSec: (value) => deps.setMediaDurationSec(value),
|
||||
setMediaGuess: (value) => deps.setMediaGuess(value),
|
||||
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
|
||||
setLastDurationProbeAtMs: (value: number) => deps.setLastDurationProbeAtMs(value),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
||||
deps: ResetAnilistMediaGuessStateMainDeps,
|
||||
) {
|
||||
return (): ResetAnilistMediaGuessStateMainDeps => ({
|
||||
setMediaGuess: (value) => deps.setMediaGuess(value),
|
||||
setMediaGuessPromise: (value) => deps.setMediaGuessPromise(value),
|
||||
});
|
||||
}
|
||||
166
src/main/runtime/anilist-media-state.test.ts
Normal file
166
src/main/runtime/anilist-media-state.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
} from './anilist-media-state';
|
||||
|
||||
test('get current anilist media key trims and normalizes empty path', () => {
|
||||
const getKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' /tmp/video.mkv ',
|
||||
});
|
||||
const getEmptyKey = createGetCurrentAnilistMediaKeyHandler({
|
||||
getCurrentMediaPath: () => ' ',
|
||||
});
|
||||
|
||||
assert.equal(getKey(), '/tmp/video.mkv');
|
||||
assert.equal(getEmptyKey(), null);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
let mediaKey: string | null = 'old';
|
||||
let mediaDurationSec: number | null = 123;
|
||||
let mediaGuess: { title: string } | null = { title: 'guess' };
|
||||
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
|
||||
let lastDurationProbeAtMs = 999;
|
||||
|
||||
const reset = createResetAnilistMediaTrackingHandler({
|
||||
setMediaKey: (value) => {
|
||||
mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
reset('/new/media');
|
||||
assert.equal(mediaKey, '/new/media');
|
||||
assert.equal(mediaDurationSec, null);
|
||||
assert.equal(mediaGuess, null);
|
||||
assert.equal(mediaGuessPromise, null);
|
||||
assert.equal(lastDurationProbeAtMs, 0);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking is idempotent', () => {
|
||||
const state = {
|
||||
mediaKey: 'old' as string | null,
|
||||
mediaDurationSec: 123 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 999,
|
||||
};
|
||||
|
||||
const reset = createResetAnilistMediaTrackingHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
reset('/new/media');
|
||||
const afterFirstReset = { ...state };
|
||||
reset('/new/media');
|
||||
|
||||
assert.deepEqual(state, afterFirstReset);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state round-trips fields', () => {
|
||||
let state = {
|
||||
mediaKey: null as string | null,
|
||||
mediaDurationSec: null as number | null,
|
||||
mediaGuess: null as { title: string } | null,
|
||||
mediaGuessPromise: null as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
|
||||
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
|
||||
getMediaKey: () => state.mediaKey,
|
||||
getMediaDurationSec: () => state.mediaDurationSec,
|
||||
getMediaGuess: () => state.mediaGuess as never,
|
||||
getMediaGuessPromise: () => state.mediaGuessPromise as never,
|
||||
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
|
||||
});
|
||||
|
||||
const nextPromise = Promise.resolve(null);
|
||||
setState({
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: 24,
|
||||
mediaGuess: { title: 'Title' } as never,
|
||||
mediaGuessPromise: nextPromise as never,
|
||||
lastDurationProbeAtMs: 321,
|
||||
});
|
||||
|
||||
const roundTrip = getState();
|
||||
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(roundTrip.mediaDurationSec, 24);
|
||||
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
|
||||
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
|
||||
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('reset anilist media guess state clears guess and in-flight promise', () => {
|
||||
const state = {
|
||||
mediaKey: '/tmp/video.mkv' as string | null,
|
||||
mediaDurationSec: 240 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 321,
|
||||
};
|
||||
|
||||
const resetGuessState = createResetAnilistMediaGuessStateHandler({
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
});
|
||||
|
||||
resetGuessState();
|
||||
assert.equal(state.mediaGuess, null);
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
assert.equal(state.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(state.mediaDurationSec, 240);
|
||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
68
src/main/runtime/anilist-media-state.ts
Normal file
68
src/main/runtime/anilist-media-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
||||
|
||||
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
}) {
|
||||
return (): string | null => {
|
||||
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
||||
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetAnilistMediaTrackingHandler(deps: {
|
||||
setMediaKey: (value: string | null) => void;
|
||||
setMediaDurationSec: (value: number | null) => void;
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
setLastDurationProbeAtMs: (value: number) => void;
|
||||
}) {
|
||||
return (mediaKey: string | null): void => {
|
||||
deps.setMediaKey(mediaKey);
|
||||
deps.setMediaDurationSec(null);
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
deps.setLastDurationProbeAtMs(0);
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetAnilistMediaGuessRuntimeStateHandler(deps: {
|
||||
getMediaKey: () => string | null;
|
||||
getMediaDurationSec: () => number | null;
|
||||
getMediaGuess: () => AnilistMediaGuessRuntimeState['mediaGuess'];
|
||||
getMediaGuessPromise: () => AnilistMediaGuessRuntimeState['mediaGuessPromise'];
|
||||
getLastDurationProbeAtMs: () => number;
|
||||
}) {
|
||||
return (): AnilistMediaGuessRuntimeState => ({
|
||||
mediaKey: deps.getMediaKey(),
|
||||
mediaDurationSec: deps.getMediaDurationSec(),
|
||||
mediaGuess: deps.getMediaGuess(),
|
||||
mediaGuessPromise: deps.getMediaGuessPromise(),
|
||||
lastDurationProbeAtMs: deps.getLastDurationProbeAtMs(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
|
||||
setMediaKey: (value: string | null) => void;
|
||||
setMediaDurationSec: (value: number | null) => void;
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
setLastDurationProbeAtMs: (value: number) => void;
|
||||
}) {
|
||||
return (state: AnilistMediaGuessRuntimeState): void => {
|
||||
deps.setMediaKey(state.mediaKey);
|
||||
deps.setMediaDurationSec(state.mediaDurationSec);
|
||||
deps.setMediaGuess(state.mediaGuess);
|
||||
deps.setMediaGuessPromise(state.mediaGuessPromise);
|
||||
deps.setLastDurationProbeAtMs(state.lastDurationProbeAtMs);
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetAnilistMediaGuessStateHandler(deps: {
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setMediaGuess(null);
|
||||
deps.setMediaGuessPromise(null);
|
||||
};
|
||||
}
|
||||
118
src/main/runtime/anilist-post-watch-main-deps.test.ts
Normal file
118
src/main/runtime/anilist-post-watch-main-deps.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||
} from './anilist-post-watch-main-deps';
|
||||
|
||||
test('process next anilist retry update main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
|
||||
nextReady: () => ({ key: 'k', title: 't', episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: () => calls.push('error'),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
now: () => 7,
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 });
|
||||
deps.refreshRetryQueueState();
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
status: 'updated',
|
||||
message: 'ok',
|
||||
});
|
||||
deps.markSuccess('k');
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.markFailure('k', 'bad');
|
||||
deps.logInfo('hello');
|
||||
assert.equal(deps.now(), 7);
|
||||
assert.deepEqual(calls, [
|
||||
'refresh',
|
||||
'attempt',
|
||||
'error',
|
||||
'success',
|
||||
'remember',
|
||||
'failure',
|
||||
'info:hello',
|
||||
]);
|
||||
});
|
||||
|
||||
test('maybe run anilist post watch update main deps builder maps callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => calls.push('in-flight'),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => 'media',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'media',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 100,
|
||||
maybeProbeAnilistDuration: async () => 120,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('retry-fail'),
|
||||
markRetrySuccess: () => calls.push('retry-ok'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: () => calls.push('osd'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 5,
|
||||
minWatchRatio: 0.5,
|
||||
})();
|
||||
|
||||
assert.equal(deps.getInFlight(), false);
|
||||
deps.setInFlight(true);
|
||||
assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true);
|
||||
assert.equal(deps.getCurrentMediaKey(), 'media');
|
||||
assert.equal(deps.hasMpvClient(), true);
|
||||
assert.equal(deps.getTrackedMediaKey(), 'media');
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', episode: 1 });
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
deps.enqueueRetry('k', 't', 1);
|
||||
deps.markRetryFailure('k', 'bad');
|
||||
deps.markRetrySuccess('k');
|
||||
deps.refreshRetryQueueState();
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
status: 'updated',
|
||||
message: 'done',
|
||||
});
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.showMpvOsd('ok');
|
||||
deps.logInfo('x');
|
||||
deps.logWarn('y');
|
||||
assert.equal(deps.minWatchSeconds, 5);
|
||||
assert.equal(deps.minWatchRatio, 0.5);
|
||||
assert.deepEqual(calls, [
|
||||
'in-flight',
|
||||
'reset',
|
||||
'enqueue',
|
||||
'retry-fail',
|
||||
'retry-ok',
|
||||
'refresh',
|
||||
'remember',
|
||||
'osd',
|
||||
'info:x',
|
||||
'warn:y',
|
||||
]);
|
||||
});
|
||||
63
src/main/runtime/anilist-post-watch-main-deps.ts
Normal file
63
src/main/runtime/anilist-post-watch-main-deps.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||
createProcessNextAnilistRetryUpdateHandler,
|
||||
} from './anilist-post-watch';
|
||||
|
||||
type ProcessNextAnilistRetryUpdateMainDeps = Parameters<
|
||||
typeof createProcessNextAnilistRetryUpdateHandler
|
||||
>[0];
|
||||
type MaybeRunAnilistPostWatchUpdateMainDeps = Parameters<
|
||||
typeof createMaybeRunAnilistPostWatchUpdateHandler
|
||||
>[0];
|
||||
|
||||
export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
||||
deps: ProcessNextAnilistRetryUpdateMainDeps,
|
||||
) {
|
||||
return (): ProcessNextAnilistRetryUpdateMainDeps => ({
|
||||
nextReady: () => deps.nextReady(),
|
||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
|
||||
setLastError: (value: string | null) => deps.setLastError(value),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
markSuccess: (key: string) => deps.markSuccess(key),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
now: () => deps.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
deps: MaybeRunAnilistPostWatchUpdateMainDeps,
|
||||
) {
|
||||
return (): MaybeRunAnilistPostWatchUpdateMainDeps => ({
|
||||
getInFlight: () => deps.getInFlight(),
|
||||
setInFlight: (value: boolean) => deps.setInFlight(value),
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config),
|
||||
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
|
||||
hasMpvClient: () => deps.hasMpvClient(),
|
||||
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
|
||||
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
|
||||
getWatchedSeconds: () => deps.getWatchedSeconds(),
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key: string, title: string, episode: number) => deps.enqueueRetry(key, title, episode),
|
||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
minWatchSeconds: deps.minWatchSeconds,
|
||||
minWatchRatio: deps.minWatchRatio,
|
||||
});
|
||||
}
|
||||
78
src/main/runtime/anilist-post-watch.test.ts
Normal file
78
src/main/runtime/anilist-post-watch.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAnilistAttemptKey,
|
||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||
createProcessNextAnilistRetryUpdateHandler,
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './anilist-post-watch';
|
||||
|
||||
test('buildAnilistAttemptKey formats media and episode', () => {
|
||||
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
|
||||
});
|
||||
|
||||
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
|
||||
const set = new Set<string>(['a', 'b']);
|
||||
rememberAnilistAttemptedUpdateKey(set, 'c', 2);
|
||||
assert.deepEqual(Array.from(set), ['b', 'c']);
|
||||
});
|
||||
|
||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
logInfo: () => calls.push('info'),
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
const result = await handler();
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.ok(calls.includes('success'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => null,
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
assert.ok(calls.includes('enqueue'));
|
||||
assert.ok(calls.includes('mark-failure'));
|
||||
assert.ok(calls.includes('osd:AniList: access token not configured'));
|
||||
assert.ok(calls.includes('inflight:true'));
|
||||
assert.ok(calls.includes('inflight:false'));
|
||||
});
|
||||
195
src/main/runtime/anilist-post-watch.ts
Normal file
195
src/main/runtime/anilist-post-watch.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
type AnilistGuess = {
|
||||
title: string;
|
||||
episode: number | null;
|
||||
};
|
||||
|
||||
type AnilistUpdateResult = {
|
||||
status: 'updated' | 'skipped' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type RetryQueueItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||
return `${mediaKey}::${episode}`;
|
||||
}
|
||||
|
||||
export function rememberAnilistAttemptedUpdateKey(
|
||||
attemptedKeys: Set<string>,
|
||||
key: string,
|
||||
maxSize: number,
|
||||
): void {
|
||||
attemptedKeys.add(key);
|
||||
if (attemptedKeys.size <= maxSize) {
|
||||
return;
|
||||
}
|
||||
const oldestKey = attemptedKeys.values().next().value;
|
||||
if (typeof oldestKey === 'string') {
|
||||
attemptedKeys.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
nextReady: () => RetryQueueItem | null;
|
||||
refreshRetryQueueState: () => void;
|
||||
setLastAttemptAt: (value: number) => void;
|
||||
setLastError: (value: string | null) => void;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
markSuccess: (key: string) => void;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
markFailure: (key: string, message: string) => void;
|
||||
logInfo: (message: string) => void;
|
||||
now: () => number;
|
||||
}) {
|
||||
return async (): Promise<{ ok: boolean; message: string }> => {
|
||||
const queued = deps.nextReady();
|
||||
deps.refreshRetryQueueState();
|
||||
if (!queued) {
|
||||
return { ok: true, message: 'AniList queue has no ready items.' };
|
||||
}
|
||||
|
||||
deps.setLastAttemptAt(deps.now());
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
deps.setLastError('AniList token unavailable for queued retry.');
|
||||
return { ok: false, message: 'AniList token unavailable for queued retry.' };
|
||||
}
|
||||
|
||||
const result = await deps.updateAnilistPostWatchProgress(accessToken, queued.title, queued.episode);
|
||||
if (result.status === 'updated' || result.status === 'skipped') {
|
||||
deps.markSuccess(queued.key);
|
||||
deps.rememberAttemptedUpdateKey(queued.key);
|
||||
deps.setLastError(null);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.logInfo(`[AniList queue] ${result.message}`);
|
||||
return { ok: true, message: result.message };
|
||||
}
|
||||
|
||||
deps.markFailure(queued.key, result.message);
|
||||
deps.setLastError(result.message);
|
||||
deps.refreshRetryQueueState();
|
||||
return { ok: false, message: result.message };
|
||||
};
|
||||
}
|
||||
|
||||
export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
getInFlight: () => boolean;
|
||||
setInFlight: (value: boolean) => void;
|
||||
getResolvedConfig: () => unknown;
|
||||
isAnilistTrackingEnabled: (config: unknown) => boolean;
|
||||
getCurrentMediaKey: () => string | null;
|
||||
hasMpvClient: () => boolean;
|
||||
getTrackedMediaKey: () => string | null;
|
||||
resetTrackedMedia: (mediaKey: string | null) => void;
|
||||
getWatchedSeconds: () => number;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
enqueueRetry: (key: string, title: string, episode: number) => void;
|
||||
markRetryFailure: (key: string, message: string) => void;
|
||||
markRetrySuccess: (key: string) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
minWatchSeconds: number;
|
||||
minWatchRatio: number;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
if (deps.getInFlight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = deps.getResolvedConfig();
|
||||
if (!deps.isAnilistTrackingEnabled(resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaKey = deps.getCurrentMediaKey();
|
||||
if (!mediaKey || !deps.hasMpvClient()) {
|
||||
return;
|
||||
}
|
||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||
deps.resetTrackedMedia(mediaKey);
|
||||
}
|
||||
|
||||
const watchedSeconds = deps.getWatchedSeconds();
|
||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||
if (!duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
if (watchedSeconds / duration < deps.minWatchRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
|
||||
if (!guess?.title || !guess.episode || guess.episode <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
await deps.processNextAnilistRetryUpdate();
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd('AniList: access token not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deps.updateAnilistPostWatchProgress(accessToken, guess.title, guess.episode);
|
||||
if (result.status === 'updated') {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
deps.markRetrySuccess(attemptKey);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(result.message);
|
||||
deps.logInfo(result.message);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'skipped') {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
deps.markRetrySuccess(attemptKey);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.logInfo(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
deps.markRetryFailure(attemptKey, result.message);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||
deps.logWarn(result.message);
|
||||
} finally {
|
||||
deps.setInFlight(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
87
src/main/runtime/anilist-setup-protocol-main-deps.test.ts
Normal file
87
src/main/runtime/anilist-setup-protocol-main-deps.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
|
||||
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
|
||||
createBuildNotifyAnilistSetupMainDepsHandler,
|
||||
createBuildRegisterSubminerProtocolClientMainDepsHandler,
|
||||
} from './anilist-setup-protocol-main-deps';
|
||||
|
||||
test('notify anilist setup main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
|
||||
hasMpvClient: () => true,
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title) => calls.push(`notify:${title}`),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasMpvClient(), true);
|
||||
deps.showMpvOsd('ok');
|
||||
deps.showDesktopNotification('SubMiner', { body: 'x' });
|
||||
deps.logInfo('done');
|
||||
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
|
||||
});
|
||||
|
||||
test('consume anilist setup token main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
|
||||
consumeAnilistSetupCallbackUrl: () => true,
|
||||
saveToken: () => calls.push('save'),
|
||||
setCachedToken: () => calls.push('cache'),
|
||||
setResolvedState: () => calls.push('resolved'),
|
||||
setSetupPageOpened: () => calls.push('opened'),
|
||||
onSuccess: () => calls.push('success'),
|
||||
closeWindow: () => calls.push('close'),
|
||||
})();
|
||||
|
||||
assert.equal(
|
||||
deps.consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup',
|
||||
saveToken: () => {},
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
onSuccess: () => {},
|
||||
closeWindow: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
deps.saveToken('token');
|
||||
deps.setCachedToken('token');
|
||||
deps.setResolvedState(Date.now());
|
||||
deps.setSetupPageOpened(true);
|
||||
deps.onSuccess();
|
||||
deps.closeWindow();
|
||||
assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']);
|
||||
});
|
||||
|
||||
test('handle anilist setup protocol url main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({
|
||||
consumeAnilistSetupTokenFromUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true);
|
||||
deps.logWarn('missing', null);
|
||||
assert.deepEqual(calls, ['warn:missing']);
|
||||
});
|
||||
|
||||
test('register subminer protocol client main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({
|
||||
isDefaultApp: () => true,
|
||||
getArgv: () => ['electron', 'entry.js'],
|
||||
execPath: '/tmp/electron',
|
||||
resolvePath: (value) => `/abs/${value}`,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isDefaultApp(), true);
|
||||
assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']);
|
||||
assert.equal(deps.execPath, '/tmp/electron');
|
||||
assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js');
|
||||
assert.equal(deps.setAsDefaultProtocolClient('subminer'), true);
|
||||
});
|
||||
64
src/main/runtime/anilist-setup-protocol-main-deps.ts
Normal file
64
src/main/runtime/anilist-setup-protocol-main-deps.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
createConsumeAnilistSetupTokenFromUrlHandler,
|
||||
createHandleAnilistSetupProtocolUrlHandler,
|
||||
createNotifyAnilistSetupHandler,
|
||||
createRegisterSubminerProtocolClientHandler,
|
||||
} from './anilist-setup-protocol';
|
||||
|
||||
type NotifyAnilistSetupMainDeps = Parameters<typeof createNotifyAnilistSetupHandler>[0];
|
||||
type ConsumeAnilistSetupTokenMainDeps = Parameters<
|
||||
typeof createConsumeAnilistSetupTokenFromUrlHandler
|
||||
>[0];
|
||||
type HandleAnilistSetupProtocolUrlMainDeps = Parameters<
|
||||
typeof createHandleAnilistSetupProtocolUrlHandler
|
||||
>[0];
|
||||
type RegisterSubminerProtocolClientMainDeps = Parameters<
|
||||
typeof createRegisterSubminerProtocolClientHandler
|
||||
>[0];
|
||||
|
||||
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
|
||||
return (): NotifyAnilistSetupMainDeps => ({
|
||||
hasMpvClient: () => deps.hasMpvClient(),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler(
|
||||
deps: ConsumeAnilistSetupTokenMainDeps,
|
||||
) {
|
||||
return (): ConsumeAnilistSetupTokenMainDeps => ({
|
||||
consumeAnilistSetupCallbackUrl: (input) => deps.consumeAnilistSetupCallbackUrl(input),
|
||||
saveToken: (token: string) => deps.saveToken(token),
|
||||
setCachedToken: (token: string) => deps.setCachedToken(token),
|
||||
setResolvedState: (resolvedAt: number) => deps.setResolvedState(resolvedAt),
|
||||
setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened),
|
||||
onSuccess: () => deps.onSuccess(),
|
||||
closeWindow: () => deps.closeWindow(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(
|
||||
deps: HandleAnilistSetupProtocolUrlMainDeps,
|
||||
) {
|
||||
return (): HandleAnilistSetupProtocolUrlMainDeps => ({
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => deps.consumeAnilistSetupTokenFromUrl(rawUrl),
|
||||
logWarn: (message: string, details: unknown) => deps.logWarn(message, details),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRegisterSubminerProtocolClientMainDepsHandler(
|
||||
deps: RegisterSubminerProtocolClientMainDeps,
|
||||
) {
|
||||
return (): RegisterSubminerProtocolClientMainDeps => ({
|
||||
isDefaultApp: () => deps.isDefaultApp(),
|
||||
getArgv: () => deps.getArgv(),
|
||||
execPath: deps.execPath,
|
||||
resolvePath: (value: string) => deps.resolvePath(value),
|
||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) =>
|
||||
deps.setAsDefaultProtocolClient(scheme, path, args),
|
||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||
});
|
||||
}
|
||||
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal file
64
src/main/runtime/anilist-setup-protocol.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createConsumeAnilistSetupTokenFromUrlHandler,
|
||||
createHandleAnilistSetupProtocolUrlHandler,
|
||||
createNotifyAnilistSetupHandler,
|
||||
createRegisterSubminerProtocolClientHandler,
|
||||
} from './anilist-setup-protocol';
|
||||
|
||||
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
|
||||
const calls: string[] = [];
|
||||
const notify = createNotifyAnilistSetupHandler({
|
||||
hasMpvClient: () => true,
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: () => calls.push('desktop'),
|
||||
logInfo: () => calls.push('log'),
|
||||
});
|
||||
notify('AniList login success');
|
||||
assert.deepEqual(calls, ['osd:AniList login success']);
|
||||
});
|
||||
|
||||
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
|
||||
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
|
||||
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
|
||||
saveToken: () => {},
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
onSuccess: () => {},
|
||||
closeWindow: () => {},
|
||||
});
|
||||
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
|
||||
assert.equal(consume('subminer://anilist-setup'), false);
|
||||
});
|
||||
|
||||
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
|
||||
const warnings: string[] = [];
|
||||
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
|
||||
consumeAnilistSetupTokenFromUrl: () => false,
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
assert.equal(handleProtocolUrl('https://example.com'), false);
|
||||
assert.equal(handleProtocolUrl('subminer://anilist-setup'), true);
|
||||
assert.deepEqual(warnings, ['AniList setup protocol URL missing access token']);
|
||||
});
|
||||
|
||||
test('createRegisterSubminerProtocolClientHandler registers default app entry', () => {
|
||||
const calls: string[] = [];
|
||||
const register = createRegisterSubminerProtocolClientHandler({
|
||||
isDefaultApp: () => true,
|
||||
getArgv: () => ['electron', './entry.js'],
|
||||
execPath: '/usr/local/bin/electron',
|
||||
resolvePath: (value) => `/resolved/${value}`,
|
||||
setAsDefaultProtocolClient: (_scheme, _path, args) => {
|
||||
calls.push(`register:${String(args?.[0])}`);
|
||||
return true;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
register();
|
||||
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
|
||||
});
|
||||
91
src/main/runtime/anilist-setup-protocol.ts
Normal file
91
src/main/runtime/anilist-setup-protocol.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type ConsumeAnilistSetupTokenDeps = {
|
||||
consumeAnilistSetupCallbackUrl: (input: {
|
||||
rawUrl: string;
|
||||
saveToken: (token: string) => void;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
closeWindow: () => void;
|
||||
}) => boolean;
|
||||
saveToken: (token: string) => void;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
closeWindow: () => void;
|
||||
};
|
||||
|
||||
export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilistSetupTokenDeps) {
|
||||
return (rawUrl: string): boolean =>
|
||||
deps.consumeAnilistSetupCallbackUrl({
|
||||
rawUrl,
|
||||
saveToken: deps.saveToken,
|
||||
setCachedToken: deps.setCachedToken,
|
||||
setResolvedState: deps.setResolvedState,
|
||||
setSetupPageOpened: deps.setSetupPageOpened,
|
||||
onSuccess: deps.onSuccess,
|
||||
closeWindow: deps.closeWindow,
|
||||
});
|
||||
}
|
||||
|
||||
export function createNotifyAnilistSetupHandler(deps: {
|
||||
hasMpvClient: () => boolean;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return (message: string): void => {
|
||||
if (deps.hasMpvClient()) {
|
||||
deps.showMpvOsd(message);
|
||||
return;
|
||||
}
|
||||
deps.showDesktopNotification('SubMiner AniList', { body: message });
|
||||
deps.logInfo(`[AniList setup] ${message}`);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleAnilistSetupProtocolUrlHandler(deps: {
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
|
||||
logWarn: (message: string, details: unknown) => void;
|
||||
}) {
|
||||
return (rawUrl: string): boolean => {
|
||||
if (!rawUrl.startsWith('subminer://anilist-setup')) {
|
||||
return false;
|
||||
}
|
||||
if (deps.consumeAnilistSetupTokenFromUrl(rawUrl)) {
|
||||
return true;
|
||||
}
|
||||
deps.logWarn('AniList setup protocol URL missing access token', { rawUrl });
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createRegisterSubminerProtocolClientHandler(deps: {
|
||||
isDefaultApp: () => boolean;
|
||||
getArgv: () => string[];
|
||||
execPath: string;
|
||||
resolvePath: (value: string) => string;
|
||||
setAsDefaultProtocolClient: (
|
||||
scheme: string,
|
||||
path?: string,
|
||||
args?: string[],
|
||||
) => boolean;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
try {
|
||||
const defaultAppEntry = deps.isDefaultApp() ? deps.getArgv()[1] : undefined;
|
||||
const success = defaultAppEntry
|
||||
? deps.setAsDefaultProtocolClient('subminer', deps.execPath, [
|
||||
deps.resolvePath(defaultAppEntry),
|
||||
])
|
||||
: deps.setAsDefaultProtocolClient('subminer');
|
||||
if (!success) {
|
||||
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Failed to register subminer:// protocol handler', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
52
src/main/runtime/anilist-setup-window-main-deps.test.ts
Normal file
52
src/main/runtime/anilist-setup-window-main-deps.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps';
|
||||
|
||||
test('open anilist setup window main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => ({}) as never,
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/auth',
|
||||
consumeCallbackUrl: () => true,
|
||||
openSetupInBrowser: (url) => calls.push(`browser:${url}`),
|
||||
loadManualTokenEntry: () => calls.push('manual'),
|
||||
redirectUri: 'subminer://anilist-auth',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`),
|
||||
setSetupWindow: () => calls.push('window'),
|
||||
openExternal: (url) => calls.push(`external:${url}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
|
||||
assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth');
|
||||
assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true);
|
||||
assert.equal(deps.redirectUri, 'subminer://anilist-auth');
|
||||
assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer');
|
||||
assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true);
|
||||
assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true);
|
||||
deps.openSetupInBrowser('https://anilist.co/auth');
|
||||
deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth');
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', null);
|
||||
deps.clearSetupWindow();
|
||||
deps.setSetupPageOpened(true);
|
||||
deps.setSetupWindow({} as never);
|
||||
deps.openExternal('https://anilist.co');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'browser:https://anilist.co/auth',
|
||||
'manual',
|
||||
'warn:warn',
|
||||
'error:error',
|
||||
'clear',
|
||||
'opened:true',
|
||||
'window',
|
||||
'external:https://anilist.co',
|
||||
]);
|
||||
});
|
||||
27
src/main/runtime/anilist-setup-window-main-deps.ts
Normal file
27
src/main/runtime/anilist-setup-window-main-deps.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { createOpenAnilistSetupWindowHandler } from './anilist-setup-window';
|
||||
|
||||
type OpenAnilistSetupWindowMainDeps = Parameters<typeof createOpenAnilistSetupWindowHandler>[0];
|
||||
|
||||
export function createBuildOpenAnilistSetupWindowMainDepsHandler(
|
||||
deps: OpenAnilistSetupWindowMainDeps,
|
||||
) {
|
||||
return (): OpenAnilistSetupWindowMainDeps => ({
|
||||
maybeFocusExistingSetupWindow: () => deps.maybeFocusExistingSetupWindow(),
|
||||
createSetupWindow: () => deps.createSetupWindow(),
|
||||
buildAuthorizeUrl: () => deps.buildAuthorizeUrl(),
|
||||
consumeCallbackUrl: (rawUrl: string) => deps.consumeCallbackUrl(rawUrl),
|
||||
openSetupInBrowser: (authorizeUrl: string) => deps.openSetupInBrowser(authorizeUrl),
|
||||
loadManualTokenEntry: (setupWindow, authorizeUrl: string) =>
|
||||
deps.loadManualTokenEntry(setupWindow, authorizeUrl),
|
||||
redirectUri: deps.redirectUri,
|
||||
developerSettingsUrl: deps.developerSettingsUrl,
|
||||
isAllowedExternalUrl: (url: string) => deps.isAllowedExternalUrl(url),
|
||||
isAllowedNavigationUrl: (url: string) => deps.isAllowedNavigationUrl(url),
|
||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||
logError: (message: string, details: unknown) => deps.logError(message, details),
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
setSetupPageOpened: (opened: boolean) => deps.setSetupPageOpened(opened),
|
||||
setSetupWindow: (setupWindow) => deps.setSetupWindow(setupWindow),
|
||||
openExternal: (url: string) => deps.openExternal(url),
|
||||
});
|
||||
}
|
||||
367
src/main/runtime/anilist-setup-window.test.ts
Normal file
367
src/main/runtime/anilist-setup-window.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createHandleAnilistSetupWindowClosedHandler,
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
||||
createHandleAnilistSetupWindowOpenedHandler,
|
||||
createAnilistSetupDidFailLoadHandler,
|
||||
createAnilistSetupDidFinishLoadHandler,
|
||||
createAnilistSetupDidNavigateHandler,
|
||||
createAnilistSetupFallbackHandler,
|
||||
createAnilistSetupWillNavigateHandler,
|
||||
createAnilistSetupWillRedirectHandler,
|
||||
createAnilistSetupWindowOpenHandler,
|
||||
createHandleManualAnilistSetupSubmissionHandler,
|
||||
createOpenAnilistSetupWindowHandler,
|
||||
} from './anilist-setup-window';
|
||||
|
||||
test('manual anilist setup submission forwards access token to callback consumer', () => {
|
||||
const consumed: string[] = [];
|
||||
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
|
||||
consumeCallbackUrl: (rawUrl) => {
|
||||
consumed.push(rawUrl);
|
||||
return true;
|
||||
},
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const handled = handleSubmission('subminer://anilist-setup?access_token=abc123');
|
||||
assert.equal(handled, true);
|
||||
assert.equal(consumed.length, 1);
|
||||
assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123'));
|
||||
});
|
||||
|
||||
test('maybe focus anilist setup window focuses existing window', () => {
|
||||
let focused = false;
|
||||
const handler = createMaybeFocusExistingAnilistSetupWindowHandler({
|
||||
getSetupWindow: () => ({
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const handled = handler();
|
||||
assert.equal(handled, true);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test('manual anilist setup submission warns on missing token', () => {
|
||||
const warnings: string[] = [];
|
||||
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
|
||||
consumeCallbackUrl: () => false,
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
const handled = handleSubmission('subminer://anilist-setup');
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(warnings, ['AniList setup submission missing access token']);
|
||||
});
|
||||
|
||||
test('anilist setup fallback handler triggers browser + manual entry on load fail', () => {
|
||||
const calls: string[] = [];
|
||||
const fallback = createAnilistSetupFallbackHandler({
|
||||
authorizeUrl: 'https://anilist.co',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
setupWindow: {
|
||||
isDestroyed: () => false,
|
||||
},
|
||||
openSetupInBrowser: () => calls.push('open-browser'),
|
||||
loadManualTokenEntry: () => calls.push('load-manual'),
|
||||
logError: () => calls.push('error'),
|
||||
logWarn: () => calls.push('warn'),
|
||||
});
|
||||
|
||||
fallback.onLoadFailure({
|
||||
errorCode: -1,
|
||||
errorDescription: 'failed',
|
||||
validatedURL: 'about:blank',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['error', 'open-browser', 'load-manual']);
|
||||
});
|
||||
|
||||
test('anilist setup window open handler denies unsafe url', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createAnilistSetupWindowOpenHandler({
|
||||
isAllowedExternalUrl: () => false,
|
||||
openExternal: () => calls.push('open'),
|
||||
logWarn: () => calls.push('warn'),
|
||||
});
|
||||
|
||||
const result = handler({ url: 'https://malicious.example' });
|
||||
assert.deepEqual(result, { action: 'deny' });
|
||||
assert.deepEqual(calls, ['warn']);
|
||||
});
|
||||
|
||||
test('anilist setup will-navigate handler blocks callback redirect uri', () => {
|
||||
let prevented = false;
|
||||
const handler = createAnilistSetupWillNavigateHandler({
|
||||
handleManualSubmission: () => false,
|
||||
consumeCallbackUrl: () => false,
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
handler({
|
||||
url: 'https://anilist.subminer.moe/#access_token=abc',
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('anilist setup will-navigate handler blocks unsafe urls', () => {
|
||||
const calls: string[] = [];
|
||||
let prevented = false;
|
||||
const handler = createAnilistSetupWillNavigateHandler({
|
||||
handleManualSubmission: () => false,
|
||||
consumeCallbackUrl: () => false,
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
isAllowedNavigationUrl: () => false,
|
||||
logWarn: () => calls.push('warn'),
|
||||
});
|
||||
|
||||
handler({
|
||||
url: 'https://unsafe.example',
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(calls, ['warn']);
|
||||
});
|
||||
|
||||
test('anilist setup will-redirect handler prevents callback redirects', () => {
|
||||
let prevented = false;
|
||||
const handler = createAnilistSetupWillRedirectHandler({
|
||||
consumeCallbackUrl: () => true,
|
||||
});
|
||||
|
||||
handler({
|
||||
url: 'https://anilist.subminer.moe/#access_token=abc',
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
});
|
||||
|
||||
test('anilist setup did-navigate handler consumes callback url', () => {
|
||||
const seen: string[] = [];
|
||||
const handler = createAnilistSetupDidNavigateHandler({
|
||||
consumeCallbackUrl: (url) => {
|
||||
seen.push(url);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
handler('https://anilist.subminer.moe/#access_token=abc');
|
||||
assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']);
|
||||
});
|
||||
|
||||
test('anilist setup did-fail-load handler forwards details', () => {
|
||||
const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = [];
|
||||
const handler = createAnilistSetupDidFailLoadHandler({
|
||||
onLoadFailure: (details) => seen.push(details),
|
||||
});
|
||||
|
||||
handler({
|
||||
errorCode: -3,
|
||||
errorDescription: 'timeout',
|
||||
validatedURL: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
});
|
||||
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0]!.errorCode, -3);
|
||||
});
|
||||
|
||||
test('anilist setup did-finish-load handler triggers fallback on blank page', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createAnilistSetupDidFinishLoadHandler({
|
||||
getLoadedUrl: () => 'about:blank',
|
||||
onBlankPageLoaded: () => calls.push('fallback'),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['fallback']);
|
||||
});
|
||||
|
||||
test('anilist setup did-finish-load handler no-ops on non-blank page', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createAnilistSetupDidFinishLoadHandler({
|
||||
getLoadedUrl: () => 'https://anilist.co/api/v2/oauth/authorize',
|
||||
onBlankPageLoaded: () => calls.push('fallback'),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('anilist setup window closed handler clears references', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleAnilistSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['clear-window', 'opened:no']);
|
||||
});
|
||||
|
||||
test('anilist setup window opened handler sets references', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleAnilistSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['set-window', 'opened:yes']);
|
||||
});
|
||||
|
||||
test('open anilist setup handler no-ops when existing setup window focused', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => {
|
||||
calls.push('focus-existing');
|
||||
return true;
|
||||
},
|
||||
createSetupWindow: () => {
|
||||
calls.push('create-window');
|
||||
throw new Error('should not create');
|
||||
},
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
|
||||
consumeCallbackUrl: () => false,
|
||||
openSetupInBrowser: () => {},
|
||||
loadManualTokenEntry: () => {},
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
setSetupWindow: () => {},
|
||||
openExternal: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.deepEqual(calls, ['focus-existing']);
|
||||
});
|
||||
|
||||
test('open anilist setup handler wires navigation, fallback, and lifecycle', () => {
|
||||
let openHandler: ((params: { url: string }) => { action: 'deny' }) | null = null;
|
||||
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
||||
let didNavigateHandler: ((event: unknown, url: string) => void) | null = null;
|
||||
let didFinishLoadHandler: (() => void) | null = null;
|
||||
let didFailLoadHandler:
|
||||
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
|
||||
| null = null;
|
||||
let closedHandler: (() => void) | null = null;
|
||||
let prevented = false;
|
||||
const calls: string[] = [];
|
||||
|
||||
const fakeWindow = {
|
||||
focus: () => {},
|
||||
webContents: {
|
||||
setWindowOpenHandler: (handler: (params: { url: string }) => { action: 'deny' }) => {
|
||||
openHandler = handler;
|
||||
},
|
||||
on: (
|
||||
event: 'will-navigate' | 'will-redirect' | 'did-navigate' | 'did-fail-load' | 'did-finish-load',
|
||||
handler: (...args: any[]) => void,
|
||||
) => {
|
||||
if (event === 'will-navigate') willNavigateHandler = handler as never;
|
||||
if (event === 'did-navigate') didNavigateHandler = handler as never;
|
||||
if (event === 'did-finish-load') didFinishLoadHandler = handler as never;
|
||||
if (event === 'did-fail-load') didFailLoadHandler = handler as never;
|
||||
},
|
||||
getURL: () => 'about:blank',
|
||||
},
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
if (event === 'closed') closedHandler = handler;
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
|
||||
const handler = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => fakeWindow,
|
||||
buildAuthorizeUrl: () => 'https://anilist.co/api/v2/oauth/authorize?client_id=36084',
|
||||
consumeCallbackUrl: (rawUrl) => {
|
||||
calls.push(`consume:${rawUrl}`);
|
||||
return rawUrl.includes('access_token=');
|
||||
},
|
||||
openSetupInBrowser: () => calls.push('open-browser'),
|
||||
loadManualTokenEntry: () => calls.push('load-manual'),
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
clearSetupWindow: () => calls.push('clear-window'),
|
||||
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
|
||||
setSetupWindow: () => calls.push('set-window'),
|
||||
openExternal: (url) => calls.push(`open:${url}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
assert.ok(openHandler);
|
||||
assert.ok(willNavigateHandler);
|
||||
assert.ok(didNavigateHandler);
|
||||
assert.ok(didFinishLoadHandler);
|
||||
assert.ok(didFailLoadHandler);
|
||||
assert.ok(closedHandler);
|
||||
assert.deepEqual(calls.slice(0, 3), ['load-manual', 'set-window', 'opened:yes']);
|
||||
|
||||
const onOpen = openHandler as ((params: { url: string }) => { action: 'deny' }) | null;
|
||||
if (!onOpen) throw new Error('missing window open handler');
|
||||
assert.deepEqual(onOpen({ url: 'https://anilist.co/settings/developer' }), { action: 'deny' });
|
||||
assert.ok(calls.includes('open:https://anilist.co/settings/developer'));
|
||||
|
||||
const onWillNavigate = willNavigateHandler as
|
||||
| ((event: { preventDefault: () => void }, url: string) => void)
|
||||
| null;
|
||||
if (!onWillNavigate) throw new Error('missing will navigate handler');
|
||||
onWillNavigate(
|
||||
{
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
'https://anilist.subminer.moe/#access_token=abc',
|
||||
);
|
||||
assert.equal(prevented, true);
|
||||
|
||||
const onDidNavigate = didNavigateHandler as ((event: unknown, url: string) => void) | null;
|
||||
if (!onDidNavigate) throw new Error('missing did navigate handler');
|
||||
onDidNavigate({}, 'https://anilist.subminer.moe/#access_token=abc');
|
||||
|
||||
const onDidFinishLoad = didFinishLoadHandler as (() => void) | null;
|
||||
if (!onDidFinishLoad) throw new Error('missing did finish load handler');
|
||||
onDidFinishLoad();
|
||||
assert.ok(calls.includes('warn:AniList setup loaded a blank page; using fallback'));
|
||||
assert.ok(calls.includes('open-browser'));
|
||||
|
||||
const onDidFailLoad = didFailLoadHandler as
|
||||
| ((event: unknown, errorCode: number, errorDescription: string, validatedURL: string) => void)
|
||||
| null;
|
||||
if (!onDidFailLoad) throw new Error('missing did fail load handler');
|
||||
onDidFailLoad({}, -1, 'load failed', 'about:blank');
|
||||
assert.ok(calls.includes('error:AniList setup window failed to load'));
|
||||
|
||||
const onClosed = closedHandler as (() => void) | null;
|
||||
if (!onClosed) throw new Error('missing closed handler');
|
||||
onClosed();
|
||||
assert.ok(calls.includes('clear-window'));
|
||||
assert.ok(calls.includes('opened:no'));
|
||||
});
|
||||
323
src/main/runtime/anilist-setup-window.ts
Normal file
323
src/main/runtime/anilist-setup-window.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
type SetupWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type OpenHandlerDecision = { action: 'deny' };
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type AnilistSetupWebContentsLike = {
|
||||
setWindowOpenHandler: (...args: any[]) => unknown;
|
||||
on: (...args: any[]) => unknown;
|
||||
getURL: () => string;
|
||||
};
|
||||
|
||||
type AnilistSetupWindowLike = FocusableWindowLike & {
|
||||
webContents: AnilistSetupWebContentsLike;
|
||||
on: (...args: any[]) => unknown;
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export function createHandleManualAnilistSetupSubmissionHandler(deps: {
|
||||
consumeCallbackUrl: (rawUrl: string) => boolean;
|
||||
redirectUri: string;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return (rawUrl: string): boolean => {
|
||||
if (!rawUrl.startsWith('subminer://anilist-setup')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const accessToken = parsed.searchParams.get('access_token')?.trim() ?? '';
|
||||
if (accessToken.length > 0) {
|
||||
return deps.consumeCallbackUrl(
|
||||
`${deps.redirectUri}#access_token=${encodeURIComponent(accessToken)}`,
|
||||
);
|
||||
}
|
||||
deps.logWarn('AniList setup submission missing access token');
|
||||
return true;
|
||||
} catch {
|
||||
deps.logWarn('AniList setup submission had invalid callback input');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createMaybeFocusExistingAnilistSetupWindowHandler(deps: {
|
||||
getSetupWindow: () => FocusableWindowLike | null;
|
||||
}) {
|
||||
return (): boolean => {
|
||||
const window = deps.getSetupWindow();
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
window.focus();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupWindowOpenHandler(deps: {
|
||||
isAllowedExternalUrl: (url: string) => boolean;
|
||||
openExternal: (url: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return ({ url }: { url: string }): OpenHandlerDecision => {
|
||||
if (!deps.isAllowedExternalUrl(url)) {
|
||||
deps.logWarn('Blocked unsafe AniList setup external URL', { url });
|
||||
return { action: 'deny' };
|
||||
}
|
||||
deps.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupWillNavigateHandler(deps: {
|
||||
handleManualSubmission: (url: string) => boolean;
|
||||
consumeCallbackUrl: (url: string) => boolean;
|
||||
redirectUri: string;
|
||||
isAllowedNavigationUrl: (url: string) => boolean;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): void => {
|
||||
const { url, preventDefault } = params;
|
||||
if (deps.handleManualSubmission(url)) {
|
||||
preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deps.consumeCallbackUrl(url)) {
|
||||
preventDefault();
|
||||
return;
|
||||
}
|
||||
if (url.startsWith(deps.redirectUri)) {
|
||||
preventDefault();
|
||||
return;
|
||||
}
|
||||
if (url.startsWith(`${deps.redirectUri}#`)) {
|
||||
preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deps.isAllowedNavigationUrl(url)) {
|
||||
return;
|
||||
}
|
||||
preventDefault();
|
||||
deps.logWarn('Blocked unsafe AniList setup navigation URL', { url });
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupWillRedirectHandler(deps: {
|
||||
consumeCallbackUrl: (url: string) => boolean;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): void => {
|
||||
if (deps.consumeCallbackUrl(params.url)) {
|
||||
params.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupDidNavigateHandler(deps: {
|
||||
consumeCallbackUrl: (url: string) => boolean;
|
||||
}) {
|
||||
return (url: string): void => {
|
||||
deps.consumeCallbackUrl(url);
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupDidFailLoadHandler(deps: {
|
||||
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => void;
|
||||
}) {
|
||||
return (details: { errorCode: number; errorDescription: string; validatedURL: string }): void => {
|
||||
deps.onLoadFailure(details);
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupDidFinishLoadHandler(deps: {
|
||||
getLoadedUrl: () => string;
|
||||
onBlankPageLoaded: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const loadedUrl = deps.getLoadedUrl();
|
||||
if (!loadedUrl || loadedUrl === 'about:blank') {
|
||||
deps.onBlankPageLoaded();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleAnilistSetupWindowClosedHandler(deps: {
|
||||
clearSetupWindow: () => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.clearSetupWindow();
|
||||
deps.setSetupPageOpened(false);
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandleAnilistSetupWindowOpenedHandler(deps: {
|
||||
setSetupWindow: () => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.setSetupWindow();
|
||||
deps.setSetupPageOpened(true);
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistSetupFallbackHandler(deps: {
|
||||
authorizeUrl: string;
|
||||
developerSettingsUrl: string;
|
||||
setupWindow: SetupWindowLike;
|
||||
openSetupInBrowser: () => void;
|
||||
loadManualTokenEntry: () => void;
|
||||
logError: (message: string, details: unknown) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}) {
|
||||
return {
|
||||
onLoadFailure: (details: { errorCode: number; errorDescription: string; validatedURL: string }) => {
|
||||
deps.logError('AniList setup window failed to load', details);
|
||||
deps.openSetupInBrowser();
|
||||
if (!deps.setupWindow.isDestroyed()) {
|
||||
deps.loadManualTokenEntry();
|
||||
}
|
||||
},
|
||||
onBlankPageLoaded: () => {
|
||||
deps.logWarn('AniList setup loaded a blank page; using fallback');
|
||||
deps.openSetupInBrowser();
|
||||
if (!deps.setupWindow.isDestroyed()) {
|
||||
deps.loadManualTokenEntry();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenAnilistSetupWindowHandler<TWindow extends AnilistSetupWindowLike>(deps: {
|
||||
maybeFocusExistingSetupWindow: () => boolean;
|
||||
createSetupWindow: () => TWindow;
|
||||
buildAuthorizeUrl: () => string;
|
||||
consumeCallbackUrl: (rawUrl: string) => boolean;
|
||||
openSetupInBrowser: (authorizeUrl: string) => void;
|
||||
loadManualTokenEntry: (setupWindow: TWindow, authorizeUrl: string) => void;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
isAllowedExternalUrl: (url: string) => boolean;
|
||||
isAllowedNavigationUrl: (url: string) => boolean;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, details: unknown) => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
openExternal: (url: string) => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
if (deps.maybeFocusExistingSetupWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
const authorizeUrl = deps.buildAuthorizeUrl();
|
||||
const consumeCallbackUrl = (rawUrl: string): boolean => deps.consumeCallbackUrl(rawUrl);
|
||||
const openSetupInBrowser = () => deps.openSetupInBrowser(authorizeUrl);
|
||||
const loadManualTokenEntry = () => deps.loadManualTokenEntry(setupWindow, authorizeUrl);
|
||||
const handleManualSubmission = createHandleManualAnilistSetupSubmissionHandler({
|
||||
consumeCallbackUrl: (rawUrl) => consumeCallbackUrl(rawUrl),
|
||||
redirectUri: deps.redirectUri,
|
||||
logWarn: (message) => deps.logWarn(message),
|
||||
});
|
||||
const fallback = createAnilistSetupFallbackHandler({
|
||||
authorizeUrl,
|
||||
developerSettingsUrl: deps.developerSettingsUrl,
|
||||
setupWindow,
|
||||
openSetupInBrowser,
|
||||
loadManualTokenEntry,
|
||||
logError: (message, details) => deps.logError(message, details),
|
||||
logWarn: (message) => deps.logWarn(message),
|
||||
});
|
||||
const handleWindowOpen = createAnilistSetupWindowOpenHandler({
|
||||
isAllowedExternalUrl: (url) => deps.isAllowedExternalUrl(url),
|
||||
openExternal: (url) => deps.openExternal(url),
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
});
|
||||
const handleWillNavigate = createAnilistSetupWillNavigateHandler({
|
||||
handleManualSubmission: (url) => handleManualSubmission(url),
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
redirectUri: deps.redirectUri,
|
||||
isAllowedNavigationUrl: (url) => deps.isAllowedNavigationUrl(url),
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
});
|
||||
const handleWillRedirect = createAnilistSetupWillRedirectHandler({
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
});
|
||||
const handleDidNavigate = createAnilistSetupDidNavigateHandler({
|
||||
consumeCallbackUrl: (url) => consumeCallbackUrl(url),
|
||||
});
|
||||
const handleDidFailLoad = createAnilistSetupDidFailLoadHandler({
|
||||
onLoadFailure: (details) => fallback.onLoadFailure(details),
|
||||
});
|
||||
const handleDidFinishLoad = createAnilistSetupDidFinishLoadHandler({
|
||||
getLoadedUrl: () => setupWindow.webContents.getURL(),
|
||||
onBlankPageLoaded: () => fallback.onBlankPageLoaded(),
|
||||
});
|
||||
const handleWindowClosed = createHandleAnilistSetupWindowClosedHandler({
|
||||
clearSetupWindow: () => deps.clearSetupWindow(),
|
||||
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
|
||||
});
|
||||
const handleWindowOpened = createHandleAnilistSetupWindowOpenedHandler({
|
||||
setSetupWindow: () => deps.setSetupWindow(setupWindow),
|
||||
setSetupPageOpened: (opened) => deps.setSetupPageOpened(opened),
|
||||
});
|
||||
|
||||
setupWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) =>
|
||||
handleWindowOpen({ url }),
|
||||
);
|
||||
setupWindow.webContents.on('will-navigate', (event: unknown, url: string) => {
|
||||
handleWillNavigate({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
setupWindow.webContents.on('will-redirect', (event: unknown, url: string) => {
|
||||
handleWillRedirect({
|
||||
url,
|
||||
preventDefault: () => {
|
||||
if (event && typeof event === 'object' && 'preventDefault' in event) {
|
||||
const typedEvent = event as { preventDefault?: () => void };
|
||||
typedEvent.preventDefault?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
setupWindow.webContents.on('did-navigate', (_event: unknown, url: string) => {
|
||||
handleDidNavigate(url);
|
||||
});
|
||||
setupWindow.webContents.on(
|
||||
'did-fail-load',
|
||||
(
|
||||
_event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
) => {
|
||||
handleDidFailLoad({
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
});
|
||||
},
|
||||
);
|
||||
setupWindow.webContents.on('did-finish-load', () => {
|
||||
handleDidFinishLoad();
|
||||
});
|
||||
loadManualTokenEntry();
|
||||
setupWindow.on('closed', () => {
|
||||
handleWindowClosed();
|
||||
});
|
||||
handleWindowOpened();
|
||||
};
|
||||
}
|
||||
148
src/main/runtime/anilist-setup.test.ts
Normal file
148
src/main/runtime/anilist-setup.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAnilistSetupFallbackHtml,
|
||||
buildAnilistManualTokenEntryHtml,
|
||||
buildAnilistSetupUrl,
|
||||
consumeAnilistSetupCallbackUrl,
|
||||
extractAnilistAccessTokenFromUrl,
|
||||
findAnilistSetupDeepLinkArgvUrl,
|
||||
} from './anilist-setup';
|
||||
|
||||
test('buildAnilistSetupUrl includes required query params', () => {
|
||||
const url = buildAnilistSetupUrl({
|
||||
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
clientId: '36084',
|
||||
responseType: 'token',
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
});
|
||||
assert.match(url, /client_id=36084/);
|
||||
assert.match(url, /response_type=token/);
|
||||
assert.match(url, /redirect_uri=https%3A%2F%2Fanilist\.subminer\.moe%2F/);
|
||||
});
|
||||
|
||||
test('buildAnilistSetupUrl omits redirect_uri when unset', () => {
|
||||
const url = buildAnilistSetupUrl({
|
||||
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
clientId: '36084',
|
||||
responseType: 'token',
|
||||
});
|
||||
assert.match(url, /client_id=36084/);
|
||||
assert.match(url, /response_type=token/);
|
||||
assert.equal(url.includes('redirect_uri='), false);
|
||||
});
|
||||
|
||||
test('buildAnilistSetupFallbackHtml escapes reason content', () => {
|
||||
const html = buildAnilistSetupFallbackHtml({
|
||||
reason: '<script>alert(1)</script>',
|
||||
authorizeUrl: 'https://anilist.example/auth',
|
||||
developerSettingsUrl: 'https://anilist.example/dev',
|
||||
});
|
||||
assert.equal(html.includes('<script>alert(1)</script>'), false);
|
||||
assert.match(html, /<script>alert\(1\)<\/script>/);
|
||||
});
|
||||
|
||||
test('buildAnilistManualTokenEntryHtml includes access-token submit route only', () => {
|
||||
const html = buildAnilistManualTokenEntryHtml({
|
||||
authorizeUrl: 'https://anilist.example/auth',
|
||||
developerSettingsUrl: 'https://anilist.example/dev',
|
||||
});
|
||||
assert.match(html, /subminer:\/\/anilist-setup\?access_token=/);
|
||||
assert.equal(html.includes('callback_url='), false);
|
||||
assert.equal(html.includes('subminer://anilist-setup?code='), false);
|
||||
});
|
||||
|
||||
test('extractAnilistAccessTokenFromUrl returns access token from hash fragment', () => {
|
||||
const token = extractAnilistAccessTokenFromUrl(
|
||||
'https://anilist.subminer.moe/#access_token=token-from-hash&token_type=Bearer',
|
||||
);
|
||||
assert.equal(token, 'token-from-hash');
|
||||
});
|
||||
|
||||
test('extractAnilistAccessTokenFromUrl returns access token from query', () => {
|
||||
const token = extractAnilistAccessTokenFromUrl(
|
||||
'https://anilist.subminer.moe/?access_token=token-from-query&token_type=Bearer',
|
||||
);
|
||||
assert.equal(token, 'token-from-query');
|
||||
});
|
||||
|
||||
test('findAnilistSetupDeepLinkArgvUrl finds subminer deep link from argv', () => {
|
||||
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
|
||||
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
'--start',
|
||||
'subminer://anilist-setup?access_token=argv-token',
|
||||
]);
|
||||
assert.equal(rawUrl, 'subminer://anilist-setup?access_token=argv-token');
|
||||
});
|
||||
|
||||
test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
|
||||
const rawUrl = findAnilistSetupDeepLinkArgvUrl([
|
||||
'/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
'--start',
|
||||
]);
|
||||
assert.equal(rawUrl, null);
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.co/settings/developer',
|
||||
saveToken: () => events.push('save'),
|
||||
setCachedToken: () => events.push('cache'),
|
||||
setResolvedState: () => events.push('state'),
|
||||
setSetupPageOpened: () => events.push('opened'),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
177
src/main/runtime/anilist-setup.ts
Normal file
177
src/main/runtime/anilist-setup.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export type BuildAnilistSetupUrlDeps = {
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
responseType: string;
|
||||
redirectUri?: string;
|
||||
};
|
||||
|
||||
export type ConsumeAnilistSetupCallbackUrlDeps = {
|
||||
rawUrl: string;
|
||||
saveToken: (token: string) => void;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
closeWindow: () => void;
|
||||
};
|
||||
|
||||
export function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean {
|
||||
return resolved.anilist.enabled;
|
||||
}
|
||||
|
||||
export function buildAnilistSetupUrl(params: BuildAnilistSetupUrlDeps): string {
|
||||
const authorizeUrl = new URL(params.authorizeUrl);
|
||||
authorizeUrl.searchParams.set('client_id', params.clientId);
|
||||
authorizeUrl.searchParams.set('response_type', params.responseType);
|
||||
if (params.redirectUri && params.redirectUri.trim().length > 0) {
|
||||
authorizeUrl.searchParams.set('redirect_uri', params.redirectUri);
|
||||
}
|
||||
return authorizeUrl.toString();
|
||||
}
|
||||
|
||||
export function extractAnilistAccessTokenFromUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
|
||||
const fromQuery = parsed.searchParams.get('access_token')?.trim();
|
||||
if (fromQuery && fromQuery.length > 0) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
const hash = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
|
||||
if (hash.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const fromHash = hashParams.get('access_token')?.trim();
|
||||
if (fromHash && fromHash.length > 0) {
|
||||
return fromHash;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findAnilistSetupDeepLinkArgvUrl(argv: readonly string[]): string | null {
|
||||
for (const value of argv) {
|
||||
if (value.startsWith('subminer://anilist-setup')) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function consumeAnilistSetupCallbackUrl(
|
||||
deps: ConsumeAnilistSetupCallbackUrlDeps,
|
||||
): boolean {
|
||||
const token = extractAnilistAccessTokenFromUrl(deps.rawUrl);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedAt = Date.now();
|
||||
deps.saveToken(token);
|
||||
deps.setCachedToken(token);
|
||||
deps.setResolvedState(resolvedAt);
|
||||
deps.setSetupPageOpened(false);
|
||||
deps.onSuccess();
|
||||
deps.closeWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openAnilistSetupInBrowser(params: {
|
||||
authorizeUrl: string;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}): void {
|
||||
void params.openExternal(params.authorizeUrl).catch((error) => {
|
||||
params.logError('Failed to open AniList authorize URL in browser', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAnilistSetupFallbackHtml(params: {
|
||||
reason: string;
|
||||
authorizeUrl: string;
|
||||
developerSettingsUrl: string;
|
||||
}): string {
|
||||
const safeReason = params.reason.replace(/</g, '<').replace(/>/g, '>');
|
||||
const safeAuth = params.authorizeUrl.replace(/"/g, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
|
||||
<h1>AniList setup</h1>
|
||||
<p>Automatic page load failed (${safeReason}).</p>
|
||||
<p><a href="${safeAuth}">Open AniList authorize page</a></p>
|
||||
<p><a href="${safeDev}">Open AniList developer settings</a></p>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export function buildAnilistManualTokenEntryHtml(params: {
|
||||
authorizeUrl: string;
|
||||
developerSettingsUrl: string;
|
||||
}): string {
|
||||
const safeAuth = params.authorizeUrl.replace(/"/g, '"');
|
||||
const safeDev = params.developerSettingsUrl.replace(/"/g, '"');
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>AniList Setup</title></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.5;">
|
||||
<h1>AniList setup</h1>
|
||||
<p>Authorize in browser, then paste the access token below.</p>
|
||||
<p><a href="${safeAuth}" target="_blank" rel="noreferrer">Open AniList authorize page</a></p>
|
||||
<p><a href="${safeDev}" target="_blank" rel="noreferrer">Open AniList developer settings</a></p>
|
||||
<form id="token-form">
|
||||
<label for="token">Access token</label><br />
|
||||
<input id="token" style="width: 100%; max-width: 760px; margin: 8px 0; padding: 8px;" autocomplete="off" />
|
||||
<br />
|
||||
<button type="submit" style="padding: 8px 12px;">Continue</button>
|
||||
</form>
|
||||
<script>
|
||||
const form = document.getElementById('token-form');
|
||||
const token = document.getElementById('token');
|
||||
form?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const rawToken = String(token?.value || '').trim();
|
||||
if (rawToken) {
|
||||
window.location.href = 'subminer://anilist-setup?access_token=' + encodeURIComponent(rawToken);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export function loadAnilistSetupFallback(params: {
|
||||
setupWindow: BrowserWindow;
|
||||
reason: string;
|
||||
authorizeUrl: string;
|
||||
developerSettingsUrl: string;
|
||||
logWarn: (message: string, data: unknown) => void;
|
||||
}): void {
|
||||
const html = buildAnilistSetupFallbackHtml({
|
||||
reason: params.reason,
|
||||
authorizeUrl: params.authorizeUrl,
|
||||
developerSettingsUrl: params.developerSettingsUrl,
|
||||
});
|
||||
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
params.logWarn('Loaded AniList setup fallback page', { reason: params.reason });
|
||||
}
|
||||
|
||||
export function loadAnilistManualTokenEntry(params: {
|
||||
setupWindow: BrowserWindow;
|
||||
authorizeUrl: string;
|
||||
developerSettingsUrl: string;
|
||||
logWarn: (message: string, data: unknown) => void;
|
||||
}): void {
|
||||
const html = buildAnilistManualTokenEntryHtml({
|
||||
authorizeUrl: params.authorizeUrl,
|
||||
developerSettingsUrl: params.developerSettingsUrl,
|
||||
});
|
||||
void params.setupWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
params.logWarn('Loaded AniList manual token entry page', {
|
||||
authorizeUrl: params.authorizeUrl,
|
||||
});
|
||||
}
|
||||
101
src/main/runtime/anilist-state.test.ts
Normal file
101
src/main/runtime/anilist-state.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnilistStateRuntime } from './anilist-state';
|
||||
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
|
||||
|
||||
function createRuntime() {
|
||||
let clientState: AnilistSecretResolutionState = {
|
||||
status: 'resolved',
|
||||
source: 'stored',
|
||||
message: 'ok' as string | null,
|
||||
resolvedAt: 1000 as number | null,
|
||||
errorAt: null as number | null,
|
||||
};
|
||||
let queueState: AnilistRetryQueueState = {
|
||||
pending: 1,
|
||||
ready: 2,
|
||||
deadLetter: 3,
|
||||
lastAttemptAt: 2000 as number | null,
|
||||
lastError: 'none' as string | null,
|
||||
};
|
||||
let clearedStoredToken = false;
|
||||
let clearedCachedToken = false;
|
||||
|
||||
const runtime = createAnilistStateRuntime({
|
||||
getClientSecretState: () => clientState,
|
||||
setClientSecretState: (next) => {
|
||||
clientState = next;
|
||||
},
|
||||
getRetryQueueState: () => queueState,
|
||||
setRetryQueueState: (next) => {
|
||||
queueState = next;
|
||||
},
|
||||
getUpdateQueueSnapshot: () => ({
|
||||
pending: 7,
|
||||
ready: 8,
|
||||
deadLetter: 9,
|
||||
}),
|
||||
clearStoredToken: () => {
|
||||
clearedStoredToken = true;
|
||||
},
|
||||
clearCachedAccessToken: () => {
|
||||
clearedCachedToken = true;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
runtime,
|
||||
getClientState: () => clientState,
|
||||
getQueueState: () => queueState,
|
||||
getClearedStoredToken: () => clearedStoredToken,
|
||||
getClearedCachedToken: () => clearedCachedToken,
|
||||
};
|
||||
}
|
||||
|
||||
test('setClientSecretState merges partial updates', () => {
|
||||
const harness = createRuntime();
|
||||
harness.runtime.setClientSecretState({
|
||||
status: 'error',
|
||||
source: 'none',
|
||||
errorAt: 4000,
|
||||
});
|
||||
|
||||
assert.deepEqual(harness.getClientState(), {
|
||||
status: 'error',
|
||||
source: 'none',
|
||||
message: 'ok',
|
||||
resolvedAt: 1000,
|
||||
errorAt: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
test('queue refresh preserves metadata while syncing counts', () => {
|
||||
const harness = createRuntime();
|
||||
const snapshot = harness.runtime.getQueueStatusSnapshot();
|
||||
|
||||
assert.deepEqual(snapshot, {
|
||||
pending: 7,
|
||||
ready: 8,
|
||||
deadLetter: 9,
|
||||
lastAttemptAt: 2000,
|
||||
lastError: 'none',
|
||||
});
|
||||
assert.deepEqual(harness.getQueueState(), snapshot);
|
||||
});
|
||||
|
||||
test('clearTokenState resets token state and clears caches', () => {
|
||||
const harness = createRuntime();
|
||||
const queueBeforeClear = { ...harness.getQueueState() };
|
||||
harness.runtime.clearTokenState();
|
||||
|
||||
assert.equal(harness.getClearedStoredToken(), true);
|
||||
assert.equal(harness.getClearedCachedToken(), true);
|
||||
assert.deepEqual(harness.getClientState(), {
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: 'stored token cleared',
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
});
|
||||
assert.deepEqual(harness.getQueueState(), queueBeforeClear);
|
||||
});
|
||||
97
src/main/runtime/anilist-state.ts
Normal file
97
src/main/runtime/anilist-state.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { AnilistRetryQueueState, AnilistSecretResolutionState } from '../state';
|
||||
|
||||
type AnilistQueueSnapshot = Pick<AnilistRetryQueueState, 'pending' | 'ready' | 'deadLetter'>;
|
||||
|
||||
type AnilistStatusSnapshot = {
|
||||
tokenStatus: AnilistSecretResolutionState['status'];
|
||||
tokenSource: AnilistSecretResolutionState['source'];
|
||||
tokenMessage: string | null;
|
||||
tokenResolvedAt: number | null;
|
||||
tokenErrorAt: number | null;
|
||||
queuePending: number;
|
||||
queueReady: number;
|
||||
queueDeadLetter: number;
|
||||
queueLastAttemptAt: number | null;
|
||||
queueLastError: string | null;
|
||||
};
|
||||
|
||||
export type AnilistStateRuntimeDeps = {
|
||||
getClientSecretState: () => AnilistSecretResolutionState;
|
||||
setClientSecretState: (next: AnilistSecretResolutionState) => void;
|
||||
getRetryQueueState: () => AnilistRetryQueueState;
|
||||
setRetryQueueState: (next: AnilistRetryQueueState) => void;
|
||||
getUpdateQueueSnapshot: () => AnilistQueueSnapshot;
|
||||
clearStoredToken: () => void;
|
||||
clearCachedAccessToken: () => void;
|
||||
};
|
||||
|
||||
export function createAnilistStateRuntime(deps: AnilistStateRuntimeDeps): {
|
||||
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
getStatusSnapshot: () => AnilistStatusSnapshot;
|
||||
getQueueStatusSnapshot: () => AnilistRetryQueueState;
|
||||
clearTokenState: () => void;
|
||||
} {
|
||||
const setClientSecretState = (partial: Partial<AnilistSecretResolutionState>): void => {
|
||||
deps.setClientSecretState({
|
||||
...deps.getClientSecretState(),
|
||||
...partial,
|
||||
});
|
||||
};
|
||||
|
||||
const refreshRetryQueueState = (): void => {
|
||||
deps.setRetryQueueState({
|
||||
...deps.getRetryQueueState(),
|
||||
...deps.getUpdateQueueSnapshot(),
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusSnapshot = (): AnilistStatusSnapshot => {
|
||||
const client = deps.getClientSecretState();
|
||||
const queue = deps.getRetryQueueState();
|
||||
return {
|
||||
tokenStatus: client.status,
|
||||
tokenSource: client.source,
|
||||
tokenMessage: client.message,
|
||||
tokenResolvedAt: client.resolvedAt,
|
||||
tokenErrorAt: client.errorAt,
|
||||
queuePending: queue.pending,
|
||||
queueReady: queue.ready,
|
||||
queueDeadLetter: queue.deadLetter,
|
||||
queueLastAttemptAt: queue.lastAttemptAt,
|
||||
queueLastError: queue.lastError,
|
||||
};
|
||||
};
|
||||
|
||||
const getQueueStatusSnapshot = (): AnilistRetryQueueState => {
|
||||
refreshRetryQueueState();
|
||||
const queue = deps.getRetryQueueState();
|
||||
return {
|
||||
pending: queue.pending,
|
||||
ready: queue.ready,
|
||||
deadLetter: queue.deadLetter,
|
||||
lastAttemptAt: queue.lastAttemptAt,
|
||||
lastError: queue.lastError,
|
||||
};
|
||||
};
|
||||
|
||||
const clearTokenState = (): void => {
|
||||
deps.clearStoredToken();
|
||||
deps.clearCachedAccessToken();
|
||||
setClientSecretState({
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: 'stored token cleared',
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setClientSecretState,
|
||||
refreshRetryQueueState,
|
||||
getStatusSnapshot,
|
||||
getQueueStatusSnapshot,
|
||||
clearTokenState,
|
||||
};
|
||||
}
|
||||
34
src/main/runtime/anilist-token-refresh-main-deps.test.ts
Normal file
34
src/main/runtime/anilist-token-refresh-main-deps.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps';
|
||||
|
||||
test('refresh anilist client secret state main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const config = { anilist: { accessToken: 'token' } };
|
||||
const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({
|
||||
getResolvedConfig: () => config as never,
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => 'cached',
|
||||
setCachedAccessToken: () => calls.push('set-cache'),
|
||||
saveStoredToken: () => calls.push('save'),
|
||||
loadStoredToken: () => 'stored',
|
||||
setClientSecretState: () => calls.push('set-state'),
|
||||
getAnilistSetupPageOpened: () => false,
|
||||
setAnilistSetupPageOpened: () => calls.push('set-opened'),
|
||||
openAnilistSetupWindow: () => calls.push('open-window'),
|
||||
now: () => 123,
|
||||
})();
|
||||
|
||||
assert.equal(deps.getResolvedConfig(), config);
|
||||
assert.equal(deps.isAnilistTrackingEnabled(config as never), true);
|
||||
assert.equal(deps.getCachedAccessToken(), 'cached');
|
||||
deps.setCachedAccessToken(null);
|
||||
deps.saveStoredToken('x');
|
||||
assert.equal(deps.loadStoredToken(), 'stored');
|
||||
deps.setClientSecretState({} as never);
|
||||
assert.equal(deps.getAnilistSetupPageOpened(), false);
|
||||
deps.setAnilistSetupPageOpened(true);
|
||||
deps.openAnilistSetupWindow();
|
||||
assert.equal(deps.now(), 123);
|
||||
assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']);
|
||||
});
|
||||
23
src/main/runtime/anilist-token-refresh-main-deps.ts
Normal file
23
src/main/runtime/anilist-token-refresh-main-deps.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh';
|
||||
|
||||
type RefreshAnilistClientSecretStateMainDeps = Parameters<
|
||||
typeof createRefreshAnilistClientSecretStateHandler
|
||||
>[0];
|
||||
|
||||
export function createBuildRefreshAnilistClientSecretStateMainDepsHandler(
|
||||
deps: RefreshAnilistClientSecretStateMainDeps,
|
||||
) {
|
||||
return (): RefreshAnilistClientSecretStateMainDeps => ({
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => deps.isAnilistTrackingEnabled(config),
|
||||
getCachedAccessToken: () => deps.getCachedAccessToken(),
|
||||
setCachedAccessToken: (token) => deps.setCachedAccessToken(token),
|
||||
saveStoredToken: (token: string) => deps.saveStoredToken(token),
|
||||
loadStoredToken: () => deps.loadStoredToken(),
|
||||
setClientSecretState: (state) => deps.setClientSecretState(state),
|
||||
getAnilistSetupPageOpened: () => deps.getAnilistSetupPageOpened(),
|
||||
setAnilistSetupPageOpened: (opened: boolean) => deps.setAnilistSetupPageOpened(opened),
|
||||
openAnilistSetupWindow: () => deps.openAnilistSetupWindow(),
|
||||
now: () => deps.now(),
|
||||
});
|
||||
}
|
||||
113
src/main/runtime/anilist-token-refresh.test.ts
Normal file
113
src/main/runtime/anilist-token-refresh.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRefreshAnilistClientSecretStateHandler } from './anilist-token-refresh';
|
||||
|
||||
test('refresh handler marks state not_checked when tracking disabled', async () => {
|
||||
let cached: string | null = 'abc';
|
||||
let opened = true;
|
||||
const states: Array<{ status: string; source: string }> = [];
|
||||
const refresh = createRefreshAnilistClientSecretStateHandler({
|
||||
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
|
||||
isAnilistTrackingEnabled: () => false,
|
||||
getCachedAccessToken: () => cached,
|
||||
setCachedAccessToken: (token) => {
|
||||
cached = token;
|
||||
},
|
||||
saveStoredToken: () => {},
|
||||
loadStoredToken: () => '',
|
||||
setClientSecretState: (state) => {
|
||||
states.push({ status: state.status, source: state.source });
|
||||
},
|
||||
getAnilistSetupPageOpened: () => opened,
|
||||
setAnilistSetupPageOpened: (next) => {
|
||||
opened = next;
|
||||
},
|
||||
openAnilistSetupWindow: () => {},
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const token = await refresh();
|
||||
assert.equal(token, null);
|
||||
assert.equal(cached, null);
|
||||
assert.equal(opened, false);
|
||||
assert.deepEqual(states, [{ status: 'not_checked', source: 'none' }]);
|
||||
});
|
||||
|
||||
test('refresh handler uses literal config token and stores it', async () => {
|
||||
let cached: string | null = null;
|
||||
const saves: string[] = [];
|
||||
const refresh = createRefreshAnilistClientSecretStateHandler({
|
||||
getResolvedConfig: () => ({ anilist: { accessToken: ' token-1 ' } }),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => cached,
|
||||
setCachedAccessToken: (token) => {
|
||||
cached = token;
|
||||
},
|
||||
saveStoredToken: (token) => saves.push(token),
|
||||
loadStoredToken: () => '',
|
||||
setClientSecretState: () => {},
|
||||
getAnilistSetupPageOpened: () => false,
|
||||
setAnilistSetupPageOpened: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
now: () => 200,
|
||||
});
|
||||
|
||||
const token = await refresh({ force: true });
|
||||
assert.equal(token, 'token-1');
|
||||
assert.equal(cached, 'token-1');
|
||||
assert.deepEqual(saves, ['token-1']);
|
||||
});
|
||||
|
||||
test('refresh handler prefers cached token when not forced', async () => {
|
||||
let loadCalls = 0;
|
||||
const refresh = createRefreshAnilistClientSecretStateHandler({
|
||||
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => 'cached-token',
|
||||
setCachedAccessToken: () => {},
|
||||
saveStoredToken: () => {},
|
||||
loadStoredToken: () => {
|
||||
loadCalls += 1;
|
||||
return 'stored-token';
|
||||
},
|
||||
setClientSecretState: () => {},
|
||||
getAnilistSetupPageOpened: () => false,
|
||||
setAnilistSetupPageOpened: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
now: () => 300,
|
||||
});
|
||||
|
||||
const token = await refresh();
|
||||
assert.equal(token, 'cached-token');
|
||||
assert.equal(loadCalls, 0);
|
||||
});
|
||||
|
||||
test('refresh handler falls back to stored token then opens setup when missing', async () => {
|
||||
let cached: string | null = null;
|
||||
let opened = false;
|
||||
let openCalls = 0;
|
||||
const refresh = createRefreshAnilistClientSecretStateHandler({
|
||||
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => cached,
|
||||
setCachedAccessToken: (token) => {
|
||||
cached = token;
|
||||
},
|
||||
saveStoredToken: () => {},
|
||||
loadStoredToken: () => '',
|
||||
setClientSecretState: () => {},
|
||||
getAnilistSetupPageOpened: () => opened,
|
||||
setAnilistSetupPageOpened: (next) => {
|
||||
opened = next;
|
||||
},
|
||||
openAnilistSetupWindow: () => {
|
||||
openCalls += 1;
|
||||
},
|
||||
now: () => 400,
|
||||
});
|
||||
|
||||
const token = await refresh({ force: true });
|
||||
assert.equal(token, null);
|
||||
assert.equal(cached, null);
|
||||
assert.equal(openCalls, 1);
|
||||
});
|
||||
93
src/main/runtime/anilist-token-refresh.ts
Normal file
93
src/main/runtime/anilist-token-refresh.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
type AnilistSecretResolutionState = {
|
||||
status: 'not_checked' | 'resolved' | 'error';
|
||||
source: 'none' | 'literal' | 'stored';
|
||||
message: string | null;
|
||||
resolvedAt: number | null;
|
||||
errorAt: number | null;
|
||||
};
|
||||
|
||||
type ConfigWithAnilistToken = {
|
||||
anilist: {
|
||||
accessToken: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createRefreshAnilistClientSecretStateHandler<TConfig extends ConfigWithAnilistToken>(deps: {
|
||||
getResolvedConfig: () => TConfig;
|
||||
isAnilistTrackingEnabled: (config: TConfig) => boolean;
|
||||
getCachedAccessToken: () => string | null;
|
||||
setCachedAccessToken: (token: string | null) => void;
|
||||
saveStoredToken: (token: string) => void;
|
||||
loadStoredToken: () => string | null | undefined;
|
||||
setClientSecretState: (state: AnilistSecretResolutionState) => void;
|
||||
getAnilistSetupPageOpened: () => boolean;
|
||||
setAnilistSetupPageOpened: (opened: boolean) => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
now: () => number;
|
||||
}) {
|
||||
return async (options?: { force?: boolean }): Promise<string | null> => {
|
||||
const resolved = deps.getResolvedConfig();
|
||||
const now = deps.now();
|
||||
if (!deps.isAnilistTrackingEnabled(resolved)) {
|
||||
deps.setCachedAccessToken(null);
|
||||
deps.setClientSecretState({
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: 'anilist tracking disabled',
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
});
|
||||
deps.setAnilistSetupPageOpened(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawAccessToken = resolved.anilist.accessToken.trim();
|
||||
if (rawAccessToken.length > 0) {
|
||||
if (options?.force || rawAccessToken !== deps.getCachedAccessToken()) {
|
||||
deps.saveStoredToken(rawAccessToken);
|
||||
}
|
||||
deps.setCachedAccessToken(rawAccessToken);
|
||||
deps.setClientSecretState({
|
||||
status: 'resolved',
|
||||
source: 'literal',
|
||||
message: 'using configured anilist.accessToken',
|
||||
resolvedAt: now,
|
||||
errorAt: null,
|
||||
});
|
||||
deps.setAnilistSetupPageOpened(false);
|
||||
return rawAccessToken;
|
||||
}
|
||||
|
||||
const cachedAccessToken = deps.getCachedAccessToken();
|
||||
if (!options?.force && cachedAccessToken && cachedAccessToken.length > 0) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
|
||||
const storedToken = deps.loadStoredToken()?.trim() ?? '';
|
||||
if (storedToken.length > 0) {
|
||||
deps.setCachedAccessToken(storedToken);
|
||||
deps.setClientSecretState({
|
||||
status: 'resolved',
|
||||
source: 'stored',
|
||||
message: 'using stored anilist access token',
|
||||
resolvedAt: now,
|
||||
errorAt: null,
|
||||
});
|
||||
deps.setAnilistSetupPageOpened(false);
|
||||
return storedToken;
|
||||
}
|
||||
|
||||
deps.setCachedAccessToken(null);
|
||||
deps.setClientSecretState({
|
||||
status: 'error',
|
||||
source: 'none',
|
||||
message: 'cannot authenticate without anilist.accessToken',
|
||||
resolvedAt: null,
|
||||
errorAt: now,
|
||||
});
|
||||
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) {
|
||||
deps.openAnilistSetupWindow();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
90
src/main/runtime/anki-actions-main-deps.test.ts
Normal file
90
src/main/runtime/anki-actions-main-deps.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildMarkLastCardAsAudioCardMainDepsHandler,
|
||||
createBuildMineSentenceCardMainDepsHandler,
|
||||
createBuildRefreshKnownWordCacheMainDepsHandler,
|
||||
createBuildTriggerFieldGroupingMainDepsHandler,
|
||||
createBuildUpdateLastCardFromClipboardMainDepsHandler,
|
||||
} from './anki-actions-main-deps';
|
||||
|
||||
test('anki action main deps builders map callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
readClipboardText: () => 'clip',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
updateLastCardFromClipboardCore: async () => {
|
||||
calls.push('update');
|
||||
},
|
||||
})();
|
||||
assert.deepEqual(update.getAnkiIntegration(), { enabled: true });
|
||||
assert.equal(update.readClipboardText(), 'clip');
|
||||
update.showMpvOsd('x');
|
||||
await update.updateLastCardFromClipboardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
readClipboardText: () => '',
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({
|
||||
getAnkiIntegration: () => null,
|
||||
missingIntegrationMessage: 'missing',
|
||||
})();
|
||||
assert.equal(refresh.getAnkiIntegration(), null);
|
||||
assert.equal(refresh.missingIntegrationMessage, 'missing');
|
||||
|
||||
const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
showMpvOsd: (text) => calls.push(`fg:${text}`),
|
||||
triggerFieldGroupingCore: async () => {
|
||||
calls.push('trigger');
|
||||
},
|
||||
})();
|
||||
fieldGrouping.showMpvOsd('fg');
|
||||
await fieldGrouping.triggerFieldGroupingCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
showMpvOsd: (text) => calls.push(`audio:${text}`),
|
||||
markLastCardAsAudioCardCore: async () => {
|
||||
calls.push('mark');
|
||||
},
|
||||
})();
|
||||
markAudio.showMpvOsd('a');
|
||||
await markAudio.markLastCardAsAudioCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const mine = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
mine.showMpvOsd('m');
|
||||
await mine.mineSentenceCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
mpvClient: { connected: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
mine.recordCardsMined(1);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:x',
|
||||
'update',
|
||||
'fg:fg',
|
||||
'trigger',
|
||||
'audio:a',
|
||||
'mark',
|
||||
'mine:m',
|
||||
'cards:1',
|
||||
]);
|
||||
});
|
||||
88
src/main/runtime/anki-actions-main-deps.ts
Normal file
88
src/main/runtime/anki-actions-main-deps.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
|
||||
|
||||
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
|
||||
|
||||
export function createBuildUpdateLastCardFromClipboardMainDepsHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
readClipboardText: () => deps.readClipboardText(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.updateLastCardFromClipboardCore(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRefreshKnownWordCacheMainDepsHandler(deps: RefreshKnownWordCacheMainDeps) {
|
||||
return (): RefreshKnownWordCacheMainDeps => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
missingIntegrationMessage: deps.missingIntegrationMessage,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildTriggerFieldGroupingMainDepsHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
triggerFieldGroupingCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
triggerFieldGroupingCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
|
||||
deps.triggerFieldGroupingCore(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
markLastCardAsAudioCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
markLastCardAsAudioCardCore: (options: { ankiIntegration: TAnki; showMpvOsd: (text: string) => void }) =>
|
||||
deps.markLastCardAsAudioCardCore(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildMineSentenceCardMainDepsHandler<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 () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number) => deps.recordCardsMined(count),
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
68
src/main/runtime/app-lifecycle-actions.test.ts
Normal file
68
src/main/runtime/app-lifecycle-actions.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createOnWillQuitCleanupHandler,
|
||||
createRestoreWindowsOnActivateHandler,
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from './app-lifecycle-actions';
|
||||
|
||||
test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||
flushMpvLog: () => calls.push('flush-mpv-log'),
|
||||
destroyMpvSocket: () => calls.push('destroy-socket'),
|
||||
clearReconnectTimer: () => calls.push('clear-reconnect'),
|
||||
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
|
||||
destroyImmersionTracker: () => calls.push('destroy-immersion'),
|
||||
destroyAnkiIntegration: () => calls.push('destroy-anki'),
|
||||
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 21);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
const shouldRestore = createShouldRestoreWindowsOnActivateHandler({
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
getAllWindowCount: () => windowCount,
|
||||
});
|
||||
|
||||
assert.equal(shouldRestore(), false);
|
||||
initialized = true;
|
||||
assert.equal(shouldRestore(), false);
|
||||
windowCount = 0;
|
||||
assert.equal(shouldRestore(), true);
|
||||
});
|
||||
|
||||
test('restore windows on activate recreates windows then syncs visibility', () => {
|
||||
const calls: string[] = [];
|
||||
const restore = createRestoreWindowsOnActivateHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-sync'),
|
||||
});
|
||||
|
||||
restore();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible-sync', 'invisible-sync']);
|
||||
});
|
||||
68
src/main/runtime/app-lifecycle-actions.ts
Normal file
68
src/main/runtime/app-lifecycle-actions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export function createOnWillQuitCleanupHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
clearYomitanParserState: () => void;
|
||||
stopWindowTracker: () => void;
|
||||
flushMpvLog: () => void;
|
||||
destroyMpvSocket: () => void;
|
||||
clearReconnectTimer: () => void;
|
||||
destroySubtitleTimingTracker: () => void;
|
||||
destroyImmersionTracker: () => void;
|
||||
destroyAnkiIntegration: () => void;
|
||||
destroyAnilistSetupWindow: () => void;
|
||||
clearAnilistSetupWindow: () => void;
|
||||
destroyJellyfinSetupWindow: () => void;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.destroyTray();
|
||||
deps.stopConfigHotReload();
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.destroyYomitanParserWindow();
|
||||
deps.clearYomitanParserState();
|
||||
deps.stopWindowTracker();
|
||||
deps.flushMpvLog();
|
||||
deps.destroyMpvSocket();
|
||||
deps.clearReconnectTimer();
|
||||
deps.destroySubtitleTimingTracker();
|
||||
deps.destroyImmersionTracker();
|
||||
deps.destroyAnkiIntegration();
|
||||
deps.destroyAnilistSetupWindow();
|
||||
deps.clearAnilistSetupWindow();
|
||||
deps.destroyJellyfinSetupWindow();
|
||||
deps.clearJellyfinSetupWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
export function createShouldRestoreWindowsOnActivateHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
getAllWindowCount: () => number;
|
||||
}) {
|
||||
return (): boolean => deps.isOverlayRuntimeInitialized() && deps.getAllWindowCount() === 0;
|
||||
}
|
||||
|
||||
export function createRestoreWindowsOnActivateHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
};
|
||||
}
|
||||
32
src/main/runtime/app-lifecycle-main-activate.test.ts
Normal file
32
src/main/runtime/app-lifecycle-main-activate.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler,
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
|
||||
} from './app-lifecycle-main-activate';
|
||||
|
||||
test('should restore windows on activate deps builder maps visibility state checks', () => {
|
||||
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
getAllWindowCount: () => 0,
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.getAllWindowCount(), 0);
|
||||
});
|
||||
|
||||
test('restore windows on activate deps builder maps all restoration callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
|
||||
createMainWindow: () => calls.push('main'),
|
||||
createInvisibleWindow: () => calls.push('invisible'),
|
||||
updateVisibleOverlayVisibility: () => calls.push('visible'),
|
||||
updateInvisibleOverlayVisibility: () => calls.push('invisible-visible'),
|
||||
})();
|
||||
|
||||
deps.createMainWindow();
|
||||
deps.createInvisibleWindow();
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.updateInvisibleOverlayVisibility();
|
||||
assert.deepEqual(calls, ['main', 'invisible', 'visible', 'invisible-visible']);
|
||||
});
|
||||
23
src/main/runtime/app-lifecycle-main-activate.ts
Normal file
23
src/main/runtime/app-lifecycle-main-activate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function createBuildShouldRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
getAllWindowCount: () => number;
|
||||
}) {
|
||||
return () => ({
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
getAllWindowCount: () => deps.getAllWindowCount(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRestoreWindowsOnActivateMainDepsHandler(deps: {
|
||||
createMainWindow: () => void;
|
||||
createInvisibleWindow: () => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
updateInvisibleOverlayVisibility: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
createMainWindow: () => deps.createMainWindow(),
|
||||
createInvisibleWindow: () => deps.createInvisibleWindow(),
|
||||
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||
updateInvisibleOverlayVisibility: () => deps.updateInvisibleOverlayVisibility(),
|
||||
});
|
||||
}
|
||||
104
src/main/runtime/app-lifecycle-main-cleanup.test.ts
Normal file
104
src/main/runtime/app-lifecycle-main-cleanup.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
|
||||
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
|
||||
|
||||
test('cleanup deps builder returns handlers that guard optional runtime objects', () => {
|
||||
const calls: string[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
|
||||
let immersionTracker: { destroy: () => void } | null = {
|
||||
destroy: () => calls.push('destroy-immersion'),
|
||||
};
|
||||
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => calls.push('destroy-tray'),
|
||||
stopConfigHotReload: () => calls.push('stop-config'),
|
||||
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
|
||||
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
|
||||
flushMpvLog: () => calls.push('flush-mpv-log'),
|
||||
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
clearReconnectTimerRef: () => {
|
||||
reconnectTimer = null;
|
||||
calls.push('clear-reconnect-ref');
|
||||
},
|
||||
|
||||
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
|
||||
getImmersionTracker: () => immersionTracker,
|
||||
clearImmersionTracker: () => {
|
||||
immersionTracker = null;
|
||||
calls.push('clear-immersion-ref');
|
||||
},
|
||||
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
|
||||
|
||||
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.ok(calls.includes('destroy-tray'));
|
||||
assert.ok(calls.includes('destroy-yomitan-window'));
|
||||
assert.ok(calls.includes('flush-mpv-log'));
|
||||
assert.ok(calls.includes('destroy-socket'));
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
|
||||
test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
const calls: string[] = [];
|
||||
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
}),
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
|
||||
cleanup();
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
102
src/main/runtime/app-lifecycle-main-cleanup.ts
Normal file
102
src/main/runtime/app-lifecycle-main-cleanup.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Narrow structural types used by cleanup assembly.
|
||||
type Destroyable = {
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type DestroyableWindow = Destroyable & {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type Stoppable = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type SocketLike = {
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
type TimerLike = ReturnType<typeof setTimeout>;
|
||||
|
||||
export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
destroyTray: () => void;
|
||||
stopConfigHotReload: () => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
|
||||
getYomitanParserWindow: () => DestroyableWindow | null;
|
||||
clearYomitanParserState: () => void;
|
||||
|
||||
getWindowTracker: () => Stoppable | null;
|
||||
flushMpvLog: () => void;
|
||||
getMpvSocket: () => SocketLike | null;
|
||||
getReconnectTimer: () => TimerLike | null;
|
||||
clearReconnectTimerRef: () => void;
|
||||
|
||||
getSubtitleTimingTracker: () => Destroyable | null;
|
||||
getImmersionTracker: () => Destroyable | null;
|
||||
clearImmersionTracker: () => void;
|
||||
getAnkiIntegration: () => Destroyable | null;
|
||||
|
||||
getAnilistSetupWindow: () => Destroyable | null;
|
||||
clearAnilistSetupWindow: () => void;
|
||||
getJellyfinSetupWindow: () => Destroyable | null;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
destroyTray: () => deps.destroyTray(),
|
||||
stopConfigHotReload: () => deps.stopConfigHotReload(),
|
||||
restorePreviousSecondarySubVisibility: () => deps.restorePreviousSecondarySubVisibility(),
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
destroyYomitanParserWindow: () => {
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!window) return;
|
||||
if (window.isDestroyed()) return;
|
||||
window.destroy();
|
||||
},
|
||||
clearYomitanParserState: () => deps.clearYomitanParserState(),
|
||||
stopWindowTracker: () => {
|
||||
const tracker = deps.getWindowTracker();
|
||||
tracker?.stop();
|
||||
},
|
||||
flushMpvLog: () => deps.flushMpvLog(),
|
||||
destroyMpvSocket: () => {
|
||||
const socket = deps.getMpvSocket();
|
||||
socket?.destroy();
|
||||
},
|
||||
clearReconnectTimer: () => {
|
||||
const timer = deps.getReconnectTimer();
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
deps.clearReconnectTimerRef();
|
||||
},
|
||||
destroySubtitleTimingTracker: () => {
|
||||
deps.getSubtitleTimingTracker()?.destroy();
|
||||
},
|
||||
destroyImmersionTracker: () => {
|
||||
const tracker = deps.getImmersionTracker();
|
||||
if (!tracker) return;
|
||||
tracker.destroy();
|
||||
deps.clearImmersionTracker();
|
||||
},
|
||||
destroyAnkiIntegration: () => {
|
||||
deps.getAnkiIntegration()?.destroy();
|
||||
},
|
||||
destroyAnilistSetupWindow: () => {
|
||||
deps.getAnilistSetupWindow()?.destroy();
|
||||
},
|
||||
clearAnilistSetupWindow: () => deps.clearAnilistSetupWindow(),
|
||||
destroyJellyfinSetupWindow: () => {
|
||||
deps.getJellyfinSetupWindow()?.destroy();
|
||||
},
|
||||
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
71
src/main/runtime/app-ready-main-deps.test.ts
Normal file
71
src/main/runtime/app-ready-main-deps.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps';
|
||||
|
||||
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
|
||||
const calls: string[] = [];
|
||||
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
|
||||
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
|
||||
resolveKeybindings: () => calls.push('resolve-keybindings'),
|
||||
createMpvClient: () => calls.push('create-mpv-client'),
|
||||
reloadConfig: () => calls.push('reload-config'),
|
||||
getResolvedConfig: () => ({ websocket: {} }),
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => calls.push('log-config-warning'),
|
||||
initRuntimeOptionsManager: () => calls.push('init-runtime-options'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => calls.push('start-ws'),
|
||||
log: () => calls.push('log'),
|
||||
setLogLevel: () => calls.push('set-log-level'),
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
calls.push('create-mecab');
|
||||
},
|
||||
createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'),
|
||||
createImmersionTracker: () => calls.push('create-immersion'),
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('start-jellyfin');
|
||||
},
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
prewarmSubtitleDictionaries: async () => {
|
||||
calls.push('prewarm-dicts');
|
||||
},
|
||||
startBackgroundWarmups: () => calls.push('start-warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
handleInitialArgs: () => calls.push('handle-initial-args'),
|
||||
onCriticalConfigErrors: () => {
|
||||
throw new Error('should not call');
|
||||
},
|
||||
logDebug: () => calls.push('debug'),
|
||||
now: () => 123,
|
||||
})();
|
||||
|
||||
assert.equal(onReady.defaultSecondarySubMode, 'hover');
|
||||
assert.equal(onReady.defaultWebsocketPort, 5174);
|
||||
assert.equal(onReady.texthookerOnlyMode, false);
|
||||
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
|
||||
assert.equal(onReady.now?.(), 123);
|
||||
onReady.loadSubtitlePosition();
|
||||
onReady.resolveKeybindings();
|
||||
onReady.createMpvClient();
|
||||
await onReady.createMecabTokenizerAndCheck();
|
||||
await onReady.loadYomitanExtension();
|
||||
await onReady.prewarmSubtitleDictionaries?.();
|
||||
onReady.startBackgroundWarmups();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'load-subtitle-position',
|
||||
'resolve-keybindings',
|
||||
'create-mpv-client',
|
||||
'create-mecab',
|
||||
'load-yomitan',
|
||||
'prewarm-dicts',
|
||||
'start-warmups',
|
||||
]);
|
||||
});
|
||||
38
src/main/runtime/app-ready-main-deps.ts
Normal file
38
src/main/runtime/app-ready-main-deps.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
|
||||
|
||||
export function createBuildAppReadyRuntimeMainDepsHandler(
|
||||
deps: AppReadyRuntimeDepsFactoryInput,
|
||||
) {
|
||||
return (): AppReadyRuntimeDepsFactoryInput => ({
|
||||
loadSubtitlePosition: deps.loadSubtitlePosition,
|
||||
resolveKeybindings: deps.resolveKeybindings,
|
||||
createMpvClient: deps.createMpvClient,
|
||||
reloadConfig: deps.reloadConfig,
|
||||
getResolvedConfig: deps.getResolvedConfig,
|
||||
getConfigWarnings: deps.getConfigWarnings,
|
||||
logConfigWarning: deps.logConfigWarning,
|
||||
initRuntimeOptionsManager: deps.initRuntimeOptionsManager,
|
||||
setSecondarySubMode: deps.setSecondarySubMode,
|
||||
defaultSecondarySubMode: deps.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: deps.defaultWebsocketPort,
|
||||
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
|
||||
startSubtitleWebsocket: deps.startSubtitleWebsocket,
|
||||
log: deps.log,
|
||||
setLogLevel: deps.setLogLevel,
|
||||
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
|
||||
createSubtitleTimingTracker: deps.createSubtitleTimingTracker,
|
||||
createImmersionTracker: deps.createImmersionTracker,
|
||||
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: deps.loadYomitanExtension,
|
||||
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: deps.startBackgroundWarmups,
|
||||
texthookerOnlyMode: deps.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig:
|
||||
deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
handleInitialArgs: deps.handleInitialArgs,
|
||||
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
||||
logDebug: deps.logDebug,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
103
src/main/runtime/app-runtime-main-deps.test.ts
Normal file
103
src/main/runtime/app-runtime-main-deps.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildDestroyTrayMainDepsHandler,
|
||||
createBuildEnsureTrayMainDepsHandler,
|
||||
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
|
||||
createBuildOpenYomitanSettingsMainDepsHandler,
|
||||
} from './app-runtime-main-deps';
|
||||
|
||||
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildEnsureTrayMainDepsHandler({
|
||||
getTray: () => null,
|
||||
setTray: () => calls.push('set-tray'),
|
||||
buildTrayMenu: () => ({}),
|
||||
resolveTrayIconPath: () => null,
|
||||
createImageFromPath: () => ({}),
|
||||
createEmptyImage: () => ({}),
|
||||
createTray: () => ({}),
|
||||
trayTooltip: 'SubMiner',
|
||||
platform: 'darwin',
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
})();
|
||||
|
||||
deps.ensureOverlayVisibleFromTrayClick();
|
||||
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
|
||||
});
|
||||
|
||||
test('destroy tray main deps map passthrough getters/setters', () => {
|
||||
let tray: unknown = { id: 'tray' };
|
||||
const deps = createBuildDestroyTrayMainDepsHandler({
|
||||
getTray: () => tray,
|
||||
setTray: (next) => {
|
||||
tray = next;
|
||||
},
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getTray(), { id: 'tray' });
|
||||
deps.setTray(null);
|
||||
assert.equal(tray, null);
|
||||
});
|
||||
|
||||
test('initialize overlay runtime main deps map build options and callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const options = { id: 'opts' };
|
||||
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: (value) => {
|
||||
calls.push(`core:${JSON.stringify(value)}`);
|
||||
return { invisibleOverlayVisible: true };
|
||||
},
|
||||
buildOptions: () => options,
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), false);
|
||||
assert.equal(deps.buildOptions(), options);
|
||||
assert.deepEqual(deps.initializeOverlayRuntimeCore(options), { invisibleOverlayVisible: true });
|
||||
deps.setInvisibleOverlayVisible(true);
|
||||
deps.setOverlayRuntimeInitialized(true);
|
||||
deps.startBackgroundWarmups();
|
||||
assert.deepEqual(calls, [
|
||||
'core:{"id":"opts"}',
|
||||
'set-invisible:true',
|
||||
'set-initialized:true',
|
||||
'warmups',
|
||||
]);
|
||||
});
|
||||
|
||||
test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentWindow: unknown = null;
|
||||
const extension = { id: 'ext' };
|
||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => extension,
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) => calls.push(`open:${(yomitanExt as { id: string }).id}`),
|
||||
getExistingWindow: () => currentWindow,
|
||||
setWindow: (window) => {
|
||||
currentWindow = window;
|
||||
calls.push('set-window');
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
|
||||
assert.equal(deps.getExistingWindow(), null);
|
||||
deps.setWindow({ id: 'win' });
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window) => deps.setWindow(window),
|
||||
});
|
||||
deps.logWarn('warn');
|
||||
deps.logError('error', new Error('boom'));
|
||||
assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']);
|
||||
assert.deepEqual(currentWindow, { id: 'win' });
|
||||
});
|
||||
89
src/main/runtime/app-runtime-main-deps.ts
Normal file
89
src/main/runtime/app-runtime-main-deps.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export function createBuildEnsureTrayMainDepsHandler<TTray, TTrayMenu, TTrayIcon>(deps: {
|
||||
getTray: () => TTray | null;
|
||||
setTray: (tray: TTray | null) => void;
|
||||
buildTrayMenu: () => TTrayMenu;
|
||||
resolveTrayIconPath: () => string | null;
|
||||
createImageFromPath: (iconPath: string) => TTrayIcon;
|
||||
createEmptyImage: () => TTrayIcon;
|
||||
createTray: (icon: TTrayIcon) => TTray;
|
||||
trayTooltip: string;
|
||||
platform: string;
|
||||
logWarn: (message: string) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getTray: () => deps.getTray(),
|
||||
setTray: (tray: TTray | null) => deps.setTray(tray),
|
||||
buildTrayMenu: () => deps.buildTrayMenu(),
|
||||
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
|
||||
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath),
|
||||
createEmptyImage: () => deps.createEmptyImage(),
|
||||
createTray: (icon: TTrayIcon) => deps.createTray(icon),
|
||||
trayTooltip: deps.trayTooltip,
|
||||
platform: deps.platform,
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
ensureOverlayVisibleFromTrayClick: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
|
||||
getTray: () => TTray | null;
|
||||
setTray: (tray: TTray | null) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getTray: () => deps.getTray(),
|
||||
setTray: (tray: TTray | null) => deps.setTray(tray),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
|
||||
buildOptions: () => TOptions;
|
||||
setInvisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
|
||||
buildOptions: () => deps.buildOptions(),
|
||||
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) =>
|
||||
deps.setOverlayRuntimeInitialized(initialized),
|
||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
}) => void;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
setWindow: (window: TWindow | null) => void;
|
||||
}) => deps.openYomitanSettingsWindow(params),
|
||||
getExistingWindow: () => deps.getExistingWindow(),
|
||||
setWindow: (window: TWindow | null) => deps.setWindow(window),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
});
|
||||
}
|
||||
83
src/main/runtime/cli-command-context-deps.test.ts
Normal file
83
src/main/runtime/cli-command-context-deps.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
|
||||
|
||||
test('build cli command context deps maps handlers and values', () => {
|
||||
const calls: string[] = [];
|
||||
const buildDeps = createBuildCliCommandContextDepsHandler({
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`),
|
||||
getMpvClient: () => null,
|
||||
showOsd: (text) => calls.push(`osd:${text}`),
|
||||
texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
|
||||
getTexthookerPort: () => 5174,
|
||||
setTexthookerPort: (port) => calls.push(`port:${port}`),
|
||||
shouldOpenBrowser: () => true,
|
||||
openExternal: async (url) => calls.push(`open:${url}`),
|
||||
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => calls.push('init'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
|
||||
mineSentenceCard: async () => {
|
||||
calls.push('mine');
|
||||
},
|
||||
startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`),
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push('update');
|
||||
},
|
||||
refreshKnownWordCache: async () => {
|
||||
calls.push('refresh');
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push('group');
|
||||
},
|
||||
triggerSubsyncFromConfig: async () => {
|
||||
calls.push('subsync');
|
||||
},
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark');
|
||||
},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => calls.push('clear-token'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
stopApp: () => calls.push('stop'),
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => {
|
||||
fn();
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
const deps = buildDeps();
|
||||
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
|
||||
assert.equal(deps.getTexthookerPort(), 5174);
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
assert.equal(deps.isOverlayInitialized(), true);
|
||||
assert.equal(deps.hasMainWindow(), true);
|
||||
assert.equal(deps.getMultiCopyTimeoutMs(), 5000);
|
||||
|
||||
deps.setSocketPath('/tmp/next.sock');
|
||||
deps.showOsd('hello');
|
||||
deps.setTexthookerPort(5175);
|
||||
deps.printHelp();
|
||||
assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']);
|
||||
});
|
||||
94
src/main/runtime/cli-command-context-deps.ts
Normal file
94
src/main/runtime/cli-command-context-deps.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
||||
|
||||
export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getSocketPath: () => string;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
|
||||
showOsd: (text: string) => void;
|
||||
texthookerService: CliCommandContextFactoryDeps['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: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
|
||||
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['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;
|
||||
}) {
|
||||
return (): CliCommandContextFactoryDeps => ({
|
||||
getSocketPath: deps.getSocketPath,
|
||||
setSocketPath: deps.setSocketPath,
|
||||
getMpvClient: deps.getMpvClient,
|
||||
showOsd: deps.showOsd,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: deps.getTexthookerPort,
|
||||
setTexthookerPort: deps.setTexthookerPort,
|
||||
shouldOpenBrowser: deps.shouldOpenBrowser,
|
||||
openExternal: deps.openExternal,
|
||||
logBrowserOpenError: deps.logBrowserOpenError,
|
||||
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,
|
||||
logInfo: deps.logInfo,
|
||||
logWarn: deps.logWarn,
|
||||
logError: deps.logError,
|
||||
});
|
||||
}
|
||||
88
src/main/runtime/cli-command-context-factory.test.ts
Normal file
88
src/main/runtime/cli-command-context-factory.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createCliCommandContextFactory } from './cli-command-context-factory';
|
||||
|
||||
test('cli command context factory composes main deps and context handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const appState = {
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
mpvClient: null,
|
||||
texthookerPort: 5174,
|
||||
overlayRuntimeInitialized: false,
|
||||
};
|
||||
|
||||
const createContext = createCliCommandContextFactory({
|
||||
appState,
|
||||
texthookerService: { isRunning: () => false, start: () => null },
|
||||
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: () => {},
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
tokenSource: 'literal',
|
||||
tokenMessage: null,
|
||||
tokenResolvedAt: null,
|
||||
tokenErrorAt: null,
|
||||
queuePending: 0,
|
||||
queueReady: 0,
|
||||
queueDeadLetter: 0,
|
||||
queueLastAttemptAt: null,
|
||||
queueLastError: null,
|
||||
}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {},
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => setTimeout(fn, 0),
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
const context = createContext();
|
||||
context.setSocketPath('/tmp/new.sock');
|
||||
context.showOsd('hello');
|
||||
context.setVisibleOverlay(true);
|
||||
context.setInvisibleOverlay(false);
|
||||
context.toggleVisibleOverlay();
|
||||
context.toggleInvisibleOverlay();
|
||||
|
||||
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'set-visible:true',
|
||||
'set-invisible:false',
|
||||
'toggle-visible',
|
||||
'toggle-invisible',
|
||||
]);
|
||||
});
|
||||
16
src/main/runtime/cli-command-context-factory.ts
Normal file
16
src/main/runtime/cli-command-context-factory.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createCliCommandContext } from './cli-command-context';
|
||||
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
|
||||
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
|
||||
|
||||
type CliCommandContextMainDeps = Parameters<
|
||||
typeof createBuildCliCommandContextMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export function createCliCommandContextFactory(deps: CliCommandContextMainDeps) {
|
||||
const buildCliCommandContextMainDepsHandler = createBuildCliCommandContextMainDepsHandler(deps);
|
||||
const cliCommandContextMainDeps = buildCliCommandContextMainDepsHandler();
|
||||
const buildCliCommandContextDepsHandler =
|
||||
createBuildCliCommandContextDepsHandler(cliCommandContextMainDeps);
|
||||
|
||||
return () => createCliCommandContext(buildCliCommandContextDepsHandler());
|
||||
}
|
||||
119
src/main/runtime/cli-command-context-main-deps.test.ts
Normal file
119
src/main/runtime/cli-command-context-main-deps.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
|
||||
|
||||
test('cli command context main deps builder maps state and callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
const appState = {
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
mpvClient: null,
|
||||
texthookerPort: 5174,
|
||||
overlayRuntimeInitialized: false,
|
||||
};
|
||||
|
||||
const build = createBuildCliCommandContextMainDepsHandler({
|
||||
appState,
|
||||
texthookerService: { isRunning: () => false, start: () => null },
|
||||
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
|
||||
openExternal: async (url) => {
|
||||
calls.push(`open:${url}`);
|
||||
},
|
||||
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
setInvisibleOverlayVisible: (visible) => calls.push(`set-invisible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
|
||||
mineSentenceCard: async () => {
|
||||
calls.push('mine');
|
||||
},
|
||||
startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push('update-last-card');
|
||||
},
|
||||
refreshKnownWordCache: async () => {
|
||||
calls.push('refresh-known');
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push('field-grouping');
|
||||
},
|
||||
triggerSubsyncFromConfig: async () => {
|
||||
calls.push('subsync');
|
||||
},
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
},
|
||||
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
tokenSource: 'literal',
|
||||
tokenMessage: null,
|
||||
tokenResolvedAt: null,
|
||||
tokenErrorAt: null,
|
||||
queuePending: 0,
|
||||
queueReady: 0,
|
||||
queueDeadLetter: 0,
|
||||
queueLastAttemptAt: null,
|
||||
queueLastError: null,
|
||||
}),
|
||||
clearAnilistToken: () => calls.push('clear-token'),
|
||||
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
|
||||
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
|
||||
getAnilistQueueStatus: () => ({
|
||||
pending: 1,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
stopApp: () => calls.push('stop-app'),
|
||||
hasMainWindow: () => true,
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => {
|
||||
fn();
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
const deps = build();
|
||||
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
|
||||
deps.setSocketPath('/tmp/next.sock');
|
||||
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
|
||||
assert.equal(deps.getTexthookerPort(), 5174);
|
||||
deps.setTexthookerPort(5175);
|
||||
assert.equal(appState.texthookerPort, 5175);
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
deps.showOsd('hello');
|
||||
deps.initializeOverlay();
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.setInvisibleOverlay(false);
|
||||
deps.printHelp();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'init-overlay',
|
||||
'set-visible:true',
|
||||
'set-invisible:false',
|
||||
'help',
|
||||
]);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
});
|
||||
105
src/main/runtime/cli-command-context-main-deps.ts
Normal file
105
src/main/runtime/cli-command-context-main-deps.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
||||
|
||||
type CliCommandContextMainState = {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: ReturnType<CliCommandContextFactoryDeps['getMpvClient']>;
|
||||
texthookerPort: number;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
};
|
||||
|
||||
export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
appState: CliCommandContextMainState;
|
||||
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
|
||||
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
toggleInvisibleOverlay: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setInvisibleOverlayVisible: (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: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['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;
|
||||
}) {
|
||||
return (): CliCommandContextFactoryDeps => ({
|
||||
getSocketPath: () => deps.appState.mpvSocketPath,
|
||||
setSocketPath: (socketPath: string) => {
|
||||
deps.appState.mpvSocketPath = socketPath;
|
||||
},
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
showOsd: (text: string) => deps.showMpvOsd(text),
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: () => deps.appState.texthookerPort,
|
||||
setTexthookerPort: (port: number) => {
|
||||
deps.appState.texthookerPort = port;
|
||||
},
|
||||
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
|
||||
openExternal: (url: string) => deps.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
toggleInvisibleOverlay: () => deps.toggleInvisibleOverlay(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
setInvisibleOverlay: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => deps.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
deps.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => deps.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => deps.getAnilistStatus(),
|
||||
clearAnilistToken: () => deps.clearAnilistToken(),
|
||||
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
||||
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
stopApp: () => deps.stopApp(),
|
||||
hasMainWindow: () => deps.hasMainWindow(),
|
||||
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
21
src/main/runtime/cli-command-prechecks-main-deps.test.ts
Normal file
21
src/main/runtime/cli-command-prechecks-main-deps.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
|
||||
|
||||
test('cli prechecks main deps builder maps transition handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
|
||||
isTexthookerOnlyMode: () => true,
|
||||
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isTexthookerOnlyMode(), true);
|
||||
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
|
||||
deps.setTexthookerOnlyMode(false);
|
||||
deps.startBackgroundWarmups();
|
||||
deps.logInfo('x');
|
||||
assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
|
||||
});
|
||||
17
src/main/runtime/cli-command-prechecks-main-deps.ts
Normal file
17
src/main/runtime/cli-command-prechecks-main-deps.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
startBackgroundWarmups: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
|
||||
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
}
|
||||
59
src/main/runtime/cli-command-prechecks.test.ts
Normal file
59
src/main/runtime/cli-command-prechecks.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks';
|
||||
|
||||
test('texthooker precheck no-ops when mode is disabled', () => {
|
||||
let warmups = 0;
|
||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
startBackgroundWarmups: () => {
|
||||
warmups += 1;
|
||||
},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
handlePrecheck({ start: true, texthooker: false } as never);
|
||||
assert.equal(warmups, 0);
|
||||
});
|
||||
|
||||
test('texthooker precheck disables mode and warms up on start command', () => {
|
||||
let mode = true;
|
||||
let warmups = 0;
|
||||
let logs = 0;
|
||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||
isTexthookerOnlyMode: () => mode,
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
mode = enabled;
|
||||
},
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
startBackgroundWarmups: () => {
|
||||
warmups += 1;
|
||||
},
|
||||
logInfo: () => {
|
||||
logs += 1;
|
||||
},
|
||||
});
|
||||
|
||||
handlePrecheck({ start: true, texthooker: false } as never);
|
||||
assert.equal(mode, false);
|
||||
assert.equal(warmups, 1);
|
||||
assert.equal(logs, 1);
|
||||
});
|
||||
|
||||
test('texthooker precheck no-ops for texthooker command', () => {
|
||||
let mode = true;
|
||||
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
|
||||
isTexthookerOnlyMode: () => mode,
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
mode = enabled;
|
||||
},
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
startBackgroundWarmups: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
handlePrecheck({ start: true, texthooker: true } as never);
|
||||
assert.equal(mode, true);
|
||||
});
|
||||
21
src/main/runtime/cli-command-prechecks.ts
Normal file
21
src/main/runtime/cli-command-prechecks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
startBackgroundWarmups: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return (args: CliArgs): void => {
|
||||
if (
|
||||
deps.isTexthookerOnlyMode() &&
|
||||
!args.texthooker &&
|
||||
(args.start || deps.commandNeedsOverlayRuntime(args))
|
||||
) {
|
||||
deps.setTexthookerOnlyMode(false);
|
||||
deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
|
||||
deps.startBackgroundWarmups();
|
||||
}
|
||||
};
|
||||
}
|
||||
33
src/main/runtime/cli-command-runtime-handler.test.ts
Normal file
33
src/main/runtime/cli-command-runtime-handler.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createCliCommandRuntimeHandler } from './cli-command-runtime-handler';
|
||||
|
||||
test('cli command runtime handler applies precheck and forwards command with context', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createCliCommandRuntimeHandler({
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => true,
|
||||
setTexthookerOnlyMode: () => calls.push('set-mode'),
|
||||
commandNeedsOverlayRuntime: () => true,
|
||||
startBackgroundWarmups: () => calls.push('warmups'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
},
|
||||
createCliCommandContext: () => {
|
||||
calls.push('context');
|
||||
return { id: 'ctx' };
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
|
||||
calls.push(`cli:${source}:${context.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
handler({ start: true } as never);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set-mode',
|
||||
'log:Disabling texthooker-only mode after overlay/start command.',
|
||||
'warmups',
|
||||
'context',
|
||||
'cli:initial:ctx',
|
||||
]);
|
||||
});
|
||||
30
src/main/runtime/cli-command-runtime-handler.ts
Normal file
30
src/main/runtime/cli-command-runtime-handler.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
import { createHandleTexthookerOnlyModeTransitionHandler } from './cli-command-prechecks';
|
||||
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
|
||||
|
||||
type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
|
||||
typeof createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
|
||||
createCliCommandContext: () => TCliContext;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
cliContext: TCliContext,
|
||||
) => void;
|
||||
}) {
|
||||
const handleTexthookerOnlyModeTransitionHandler =
|
||||
createHandleTexthookerOnlyModeTransitionHandler(
|
||||
createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(
|
||||
deps.handleTexthookerOnlyModeTransitionMainDeps,
|
||||
)(),
|
||||
);
|
||||
|
||||
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
|
||||
handleTexthookerOnlyModeTransitionHandler(args);
|
||||
const cliContext = deps.createCliCommandContext();
|
||||
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
|
||||
};
|
||||
}
|
||||
47
src/main/runtime/clipboard-queue.test.ts
Normal file
47
src/main/runtime/clipboard-queue.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { appendClipboardVideoToQueueRuntime } from './clipboard-queue';
|
||||
|
||||
test('appendClipboardVideoToQueueRuntime returns disconnected when mpv unavailable', () => {
|
||||
const result = appendClipboardVideoToQueueRuntime({
|
||||
getMpvClient: () => null,
|
||||
readClipboardText: () => '',
|
||||
showMpvOsd: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
assert.deepEqual(result, { ok: false, message: 'MPV is not connected.' });
|
||||
});
|
||||
|
||||
test('appendClipboardVideoToQueueRuntime rejects unsupported clipboard path', () => {
|
||||
const osdMessages: string[] = [];
|
||||
const result = appendClipboardVideoToQueueRuntime({
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
readClipboardText: () => 'not a media path',
|
||||
showMpvOsd: (text) => osdMessages.push(text),
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(osdMessages[0], 'Clipboard does not contain a supported video path.');
|
||||
});
|
||||
|
||||
test('appendClipboardVideoToQueueRuntime queues readable media file', () => {
|
||||
const tempPath = path.join(process.cwd(), 'dist', 'clipboard-queue-test-video.mkv');
|
||||
fs.writeFileSync(tempPath, 'stub');
|
||||
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
const osdMessages: string[] = [];
|
||||
const result = appendClipboardVideoToQueueRuntime({
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
readClipboardText: () => tempPath,
|
||||
showMpvOsd: (text) => osdMessages.push(text),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(commands[0], ['loadfile', tempPath, 'append']);
|
||||
assert.equal(osdMessages[0], `Queued from clipboard: ${path.basename(tempPath)}`);
|
||||
|
||||
fs.unlinkSync(tempPath);
|
||||
});
|
||||
40
src/main/runtime/clipboard-queue.ts
Normal file
40
src/main/runtime/clipboard-queue.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseClipboardVideoPath } from '../../core/services';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export type AppendClipboardVideoToQueueRuntimeDeps = {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
};
|
||||
|
||||
export function appendClipboardVideoToQueueRuntime(
|
||||
deps: AppendClipboardVideoToQueueRuntimeDeps,
|
||||
): { ok: boolean; message: string } {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient || !mpvClient.connected) {
|
||||
return { ok: false, message: 'MPV is not connected.' };
|
||||
}
|
||||
|
||||
const clipboardText = deps.readClipboardText();
|
||||
const parsedPath = parseClipboardVideoPath(clipboardText);
|
||||
if (!parsedPath) {
|
||||
deps.showMpvOsd('Clipboard does not contain a supported video path.');
|
||||
return { ok: false, message: 'Clipboard does not contain a supported video path.' };
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(parsedPath);
|
||||
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
|
||||
deps.showMpvOsd('Clipboard path is not a readable file.');
|
||||
return { ok: false, message: 'Clipboard path is not a readable file.' };
|
||||
}
|
||||
|
||||
deps.sendMpvCommand(['loadfile', resolvedPath, 'append']);
|
||||
deps.showMpvOsd(`Queued from clipboard: ${path.basename(resolvedPath)}`);
|
||||
return { ok: true, message: `Queued ${resolvedPath}` };
|
||||
}
|
||||
40
src/main/runtime/composers/anilist-setup-composer.test.ts
Normal file
40
src/main/runtime/composers/anilist-setup-composer.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeAnilistSetupHandlers } from './anilist-setup-composer';
|
||||
|
||||
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
const composed = composeAnilistSetupHandlers({
|
||||
notifyDeps: {
|
||||
hasMpvClient: () => false,
|
||||
showMpvOsd: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
consumeTokenDeps: {
|
||||
consumeAnilistSetupCallbackUrl: () => false,
|
||||
saveToken: () => {},
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
onSuccess: () => {},
|
||||
closeWindow: () => {},
|
||||
},
|
||||
handleProtocolDeps: {
|
||||
consumeAnilistSetupTokenFromUrl: () => false,
|
||||
logWarn: () => {},
|
||||
},
|
||||
registerProtocolClientDeps: {
|
||||
isDefaultApp: () => false,
|
||||
getArgv: () => [],
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logWarn: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.notifyAnilistSetup, 'function');
|
||||
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
|
||||
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
|
||||
assert.equal(typeof composed.registerSubminerProtocolClient, 'function');
|
||||
});
|
||||
56
src/main/runtime/composers/anilist-setup-composer.ts
Normal file
56
src/main/runtime/composers/anilist-setup-composer.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
|
||||
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
|
||||
createBuildNotifyAnilistSetupMainDepsHandler,
|
||||
createBuildRegisterSubminerProtocolClientMainDepsHandler,
|
||||
createConsumeAnilistSetupTokenFromUrlHandler,
|
||||
createHandleAnilistSetupProtocolUrlHandler,
|
||||
createNotifyAnilistSetupHandler,
|
||||
createRegisterSubminerProtocolClientHandler,
|
||||
} from '../domains/anilist';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type NotifyHandler = ReturnType<typeof createNotifyAnilistSetupHandler>;
|
||||
type ConsumeHandler = ReturnType<typeof createConsumeAnilistSetupTokenFromUrlHandler>;
|
||||
type HandleProtocolHandler = ReturnType<typeof createHandleAnilistSetupProtocolUrlHandler>;
|
||||
type RegisterClientHandler = ReturnType<typeof createRegisterSubminerProtocolClientHandler>;
|
||||
|
||||
export type AnilistSetupComposerOptions = ComposerInputs<{
|
||||
notifyDeps: Parameters<typeof createBuildNotifyAnilistSetupMainDepsHandler>[0];
|
||||
consumeTokenDeps: Parameters<typeof createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler>[0];
|
||||
handleProtocolDeps: Parameters<typeof createBuildHandleAnilistSetupProtocolUrlMainDepsHandler>[0];
|
||||
registerProtocolClientDeps: Parameters<
|
||||
typeof createBuildRegisterSubminerProtocolClientMainDepsHandler
|
||||
>[0];
|
||||
}>;
|
||||
|
||||
export type AnilistSetupComposerResult = ComposerOutputs<{
|
||||
notifyAnilistSetup: NotifyHandler;
|
||||
consumeAnilistSetupTokenFromUrl: ConsumeHandler;
|
||||
handleAnilistSetupProtocolUrl: HandleProtocolHandler;
|
||||
registerSubminerProtocolClient: RegisterClientHandler;
|
||||
}>;
|
||||
|
||||
export function composeAnilistSetupHandlers(
|
||||
options: AnilistSetupComposerOptions,
|
||||
): AnilistSetupComposerResult {
|
||||
const notifyAnilistSetup = createNotifyAnilistSetupHandler(
|
||||
createBuildNotifyAnilistSetupMainDepsHandler(options.notifyDeps)(),
|
||||
);
|
||||
const consumeAnilistSetupTokenFromUrl = createConsumeAnilistSetupTokenFromUrlHandler(
|
||||
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler(options.consumeTokenDeps)(),
|
||||
);
|
||||
const handleAnilistSetupProtocolUrl = createHandleAnilistSetupProtocolUrlHandler(
|
||||
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler(options.handleProtocolDeps)(),
|
||||
);
|
||||
const registerSubminerProtocolClient = createRegisterSubminerProtocolClientHandler(
|
||||
createBuildRegisterSubminerProtocolClientMainDepsHandler(options.registerProtocolClientDeps)(),
|
||||
);
|
||||
|
||||
return {
|
||||
notifyAnilistSetup,
|
||||
consumeAnilistSetupTokenFromUrl,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
registerSubminerProtocolClient,
|
||||
};
|
||||
}
|
||||
237
src/main/runtime/composers/anilist-tracking-composer.test.ts
Normal file
237
src/main/runtime/composers/anilist-tracking-composer.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater';
|
||||
import { composeAnilistTrackingHandlers } from './anilist-tracking-composer';
|
||||
|
||||
test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => {
|
||||
const refreshSavedTokens: string[] = [];
|
||||
let refreshCachedToken: string | null = null;
|
||||
|
||||
let mediaKeyState: string | null = 'media-key';
|
||||
let mediaDurationSecState: number | null = null;
|
||||
let mediaGuessState: AnilistMediaGuess | null = null;
|
||||
let mediaGuessPromiseState: Promise<AnilistMediaGuess | null> | null = null;
|
||||
let lastDurationProbeAtMsState = 0;
|
||||
let requestMpvDurationCalls = 0;
|
||||
let guessAnilistMediaInfoCalls = 0;
|
||||
|
||||
let retryUpdateCalls = 0;
|
||||
let maybeRunUpdateCalls = 0;
|
||||
|
||||
const composed = composeAnilistTrackingHandlers({
|
||||
refreshClientSecretMainDeps: {
|
||||
getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCachedAccessToken: () => refreshCachedToken,
|
||||
setCachedAccessToken: (token) => {
|
||||
refreshCachedToken = token;
|
||||
},
|
||||
saveStoredToken: (token) => {
|
||||
refreshSavedTokens.push(token);
|
||||
},
|
||||
loadStoredToken: () => null,
|
||||
setClientSecretState: () => {},
|
||||
getAnilistSetupPageOpened: () => false,
|
||||
setAnilistSetupPageOpened: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
now: () => 100,
|
||||
},
|
||||
getCurrentMediaKeyMainDeps: {
|
||||
getCurrentMediaPath: () => ' media-key ',
|
||||
},
|
||||
resetMediaTrackingMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaKeyState = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaDurationSecState = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessState = value;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromiseState = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
lastDurationProbeAtMsState = value;
|
||||
},
|
||||
},
|
||||
getMediaGuessRuntimeStateMainDeps: {
|
||||
getMediaKey: () => mediaKeyState,
|
||||
getMediaDurationSec: () => mediaDurationSecState,
|
||||
getMediaGuess: () => mediaGuessState,
|
||||
getMediaGuessPromise: () => mediaGuessPromiseState,
|
||||
getLastDurationProbeAtMs: () => lastDurationProbeAtMsState,
|
||||
},
|
||||
setMediaGuessRuntimeStateMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaKeyState = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaDurationSecState = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessState = value;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromiseState = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
lastDurationProbeAtMsState = value;
|
||||
},
|
||||
},
|
||||
resetMediaGuessStateMainDeps: {
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessState = value;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromiseState = value;
|
||||
},
|
||||
},
|
||||
maybeProbeDurationMainDeps: {
|
||||
getState: () => ({
|
||||
mediaKey: mediaKeyState,
|
||||
mediaDurationSec: mediaDurationSecState,
|
||||
mediaGuess: mediaGuessState,
|
||||
mediaGuessPromise: mediaGuessPromiseState,
|
||||
lastDurationProbeAtMs: lastDurationProbeAtMsState,
|
||||
}),
|
||||
setState: (state) => {
|
||||
mediaKeyState = state.mediaKey;
|
||||
mediaDurationSecState = state.mediaDurationSec;
|
||||
mediaGuessState = state.mediaGuess;
|
||||
mediaGuessPromiseState = state.mediaGuessPromise;
|
||||
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
|
||||
},
|
||||
durationRetryIntervalMs: 0,
|
||||
now: () => 1000,
|
||||
requestMpvDuration: async () => {
|
||||
requestMpvDurationCalls += 1;
|
||||
return 120;
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
ensureMediaGuessMainDeps: {
|
||||
getState: () => ({
|
||||
mediaKey: mediaKeyState,
|
||||
mediaDurationSec: mediaDurationSecState,
|
||||
mediaGuess: mediaGuessState,
|
||||
mediaGuessPromise: mediaGuessPromiseState,
|
||||
lastDurationProbeAtMs: lastDurationProbeAtMsState,
|
||||
}),
|
||||
setState: (state) => {
|
||||
mediaKeyState = state.mediaKey;
|
||||
mediaDurationSecState = state.mediaDurationSec;
|
||||
mediaGuessState = state.mediaGuess;
|
||||
mediaGuessPromiseState = state.mediaGuessPromise;
|
||||
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
|
||||
},
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
getCurrentMediaPath: () => '/tmp/media.mkv',
|
||||
getCurrentMediaTitle: () => 'Episode title',
|
||||
guessAnilistMediaInfo: async () => {
|
||||
guessAnilistMediaInfoCalls += 1;
|
||||
return { title: 'Episode title', episode: 7, source: 'guessit' };
|
||||
},
|
||||
},
|
||||
processNextRetryUpdateMainDeps: {
|
||||
nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }),
|
||||
refreshRetryQueueState: () => {},
|
||||
setLastAttemptAt: () => {},
|
||||
setLastError: () => {},
|
||||
refreshAnilistClientSecretState: async () => 'retry-token',
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
retryUpdateCalls += 1;
|
||||
return { status: 'updated', message: 'ok' };
|
||||
},
|
||||
markSuccess: () => {},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
markFailure: () => {},
|
||||
logInfo: () => {},
|
||||
now: () => 1,
|
||||
},
|
||||
maybeRunPostWatchUpdateMainDeps: {
|
||||
getInFlight: () => false,
|
||||
setInFlight: () => {},
|
||||
getResolvedConfig: () => ({ tracking: true }),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => 'media-key',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => 'media-key',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 500,
|
||||
maybeProbeAnilistDuration: async () => 600,
|
||||
ensureAnilistMediaGuess: async () => ({
|
||||
title: 'Episode title',
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
}),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'run-token',
|
||||
enqueueRetry: () => {},
|
||||
markRetryFailure: () => {},
|
||||
markRetrySuccess: () => {},
|
||||
refreshRetryQueueState: () => {},
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
maybeRunUpdateCalls += 1;
|
||||
return { status: 'updated', message: 'updated from maybeRun' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => {},
|
||||
showMpvOsd: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
minWatchSeconds: 10,
|
||||
minWatchRatio: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.refreshAnilistClientSecretState, 'function');
|
||||
assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function');
|
||||
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
|
||||
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
|
||||
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
||||
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
||||
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
|
||||
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
|
||||
assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function');
|
||||
assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function');
|
||||
|
||||
const refreshed = await composed.refreshAnilistClientSecretState({ force: true });
|
||||
assert.equal(refreshed, 'refresh-token');
|
||||
assert.deepEqual(refreshSavedTokens, ['refresh-token']);
|
||||
|
||||
assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key');
|
||||
composed.resetAnilistMediaTracking('next-key');
|
||||
assert.equal(mediaKeyState, 'next-key');
|
||||
assert.equal(mediaDurationSecState, null);
|
||||
|
||||
composed.setAnilistMediaGuessRuntimeState({
|
||||
mediaKey: 'media-key',
|
||||
mediaDurationSec: 90,
|
||||
mediaGuess: { title: 'Known', episode: 3, source: 'fallback' },
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 11,
|
||||
});
|
||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
|
||||
|
||||
composed.resetAnilistMediaGuessState();
|
||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
||||
|
||||
mediaKeyState = 'media-key';
|
||||
mediaDurationSecState = null;
|
||||
const probedDuration = await composed.maybeProbeAnilistDuration('media-key');
|
||||
assert.equal(probedDuration, 120);
|
||||
assert.equal(requestMpvDurationCalls, 1);
|
||||
|
||||
mediaGuessState = null;
|
||||
await composed.ensureAnilistMediaGuess('media-key');
|
||||
assert.equal(guessAnilistMediaInfoCalls, 1);
|
||||
|
||||
const retryResult = await composed.processNextAnilistRetryUpdate();
|
||||
assert.deepEqual(retryResult, { ok: true, message: 'ok' });
|
||||
assert.equal(retryUpdateCalls, 1);
|
||||
|
||||
await composed.maybeRunAnilistPostWatchUpdate();
|
||||
assert.equal(maybeRunUpdateCalls, 1);
|
||||
});
|
||||
129
src/main/runtime/composers/anilist-tracking-composer.ts
Normal file
129
src/main/runtime/composers/anilist-tracking-composer.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createBuildEnsureAnilistMediaGuessMainDepsHandler,
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createEnsureAnilistMediaGuessHandler,
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createMaybeProbeAnilistDurationHandler,
|
||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||
createProcessNextAnilistRetryUpdateHandler,
|
||||
createRefreshAnilistClientSecretStateHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
} from '../domains/anilist';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
export type AnilistTrackingComposerOptions = ComposerInputs<{
|
||||
refreshClientSecretMainDeps: Parameters<
|
||||
typeof createBuildRefreshAnilistClientSecretStateMainDepsHandler
|
||||
>[0];
|
||||
getCurrentMediaKeyMainDeps: Parameters<
|
||||
typeof createBuildGetCurrentAnilistMediaKeyMainDepsHandler
|
||||
>[0];
|
||||
resetMediaTrackingMainDeps: Parameters<
|
||||
typeof createBuildResetAnilistMediaTrackingMainDepsHandler
|
||||
>[0];
|
||||
getMediaGuessRuntimeStateMainDeps: Parameters<
|
||||
typeof createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler
|
||||
>[0];
|
||||
setMediaGuessRuntimeStateMainDeps: Parameters<
|
||||
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
||||
>[0];
|
||||
resetMediaGuessStateMainDeps: Parameters<
|
||||
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
||||
>[0];
|
||||
maybeProbeDurationMainDeps: Parameters<
|
||||
typeof createBuildMaybeProbeAnilistDurationMainDepsHandler
|
||||
>[0];
|
||||
ensureMediaGuessMainDeps: Parameters<typeof createBuildEnsureAnilistMediaGuessMainDepsHandler>[0];
|
||||
processNextRetryUpdateMainDeps: Parameters<
|
||||
typeof createBuildProcessNextAnilistRetryUpdateMainDepsHandler
|
||||
>[0];
|
||||
maybeRunPostWatchUpdateMainDeps: Parameters<
|
||||
typeof createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler
|
||||
>[0];
|
||||
}>;
|
||||
|
||||
export type AnilistTrackingComposerResult = ComposerOutputs<{
|
||||
refreshAnilistClientSecretState: ReturnType<typeof createRefreshAnilistClientSecretStateHandler>;
|
||||
getCurrentAnilistMediaKey: ReturnType<typeof createGetCurrentAnilistMediaKeyHandler>;
|
||||
resetAnilistMediaTracking: ReturnType<typeof createResetAnilistMediaTrackingHandler>;
|
||||
getAnilistMediaGuessRuntimeState: ReturnType<
|
||||
typeof createGetAnilistMediaGuessRuntimeStateHandler
|
||||
>;
|
||||
setAnilistMediaGuessRuntimeState: ReturnType<
|
||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||
>;
|
||||
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
||||
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
||||
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
||||
processNextAnilistRetryUpdate: ReturnType<typeof createProcessNextAnilistRetryUpdateHandler>;
|
||||
maybeRunAnilistPostWatchUpdate: ReturnType<typeof createMaybeRunAnilistPostWatchUpdateHandler>;
|
||||
}>;
|
||||
|
||||
export function composeAnilistTrackingHandlers(
|
||||
options: AnilistTrackingComposerOptions,
|
||||
): AnilistTrackingComposerResult {
|
||||
const refreshAnilistClientSecretState = createRefreshAnilistClientSecretStateHandler(
|
||||
createBuildRefreshAnilistClientSecretStateMainDepsHandler(
|
||||
options.refreshClientSecretMainDeps,
|
||||
)(),
|
||||
);
|
||||
const getCurrentAnilistMediaKey = createGetCurrentAnilistMediaKeyHandler(
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler(options.getCurrentMediaKeyMainDeps)(),
|
||||
);
|
||||
const resetAnilistMediaTracking = createResetAnilistMediaTrackingHandler(
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler(options.resetMediaTrackingMainDeps)(),
|
||||
);
|
||||
const getAnilistMediaGuessRuntimeState = createGetAnilistMediaGuessRuntimeStateHandler(
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
||||
options.getMediaGuessRuntimeStateMainDeps,
|
||||
)(),
|
||||
);
|
||||
const setAnilistMediaGuessRuntimeState = createSetAnilistMediaGuessRuntimeStateHandler(
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
||||
options.setMediaGuessRuntimeStateMainDeps,
|
||||
)(),
|
||||
);
|
||||
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
||||
);
|
||||
const maybeProbeAnilistDuration = createMaybeProbeAnilistDurationHandler(
|
||||
createBuildMaybeProbeAnilistDurationMainDepsHandler(options.maybeProbeDurationMainDeps)(),
|
||||
);
|
||||
const ensureAnilistMediaGuess = createEnsureAnilistMediaGuessHandler(
|
||||
createBuildEnsureAnilistMediaGuessMainDepsHandler(options.ensureMediaGuessMainDeps)(),
|
||||
);
|
||||
const processNextAnilistRetryUpdate = createProcessNextAnilistRetryUpdateHandler(
|
||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
||||
options.processNextRetryUpdateMainDeps,
|
||||
)(),
|
||||
);
|
||||
const maybeRunAnilistPostWatchUpdate = createMaybeRunAnilistPostWatchUpdateHandler(
|
||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
options.maybeRunPostWatchUpdateMainDeps,
|
||||
)(),
|
||||
);
|
||||
|
||||
return {
|
||||
refreshAnilistClientSecretState,
|
||||
getCurrentAnilistMediaKey,
|
||||
resetAnilistMediaTracking,
|
||||
getAnilistMediaGuessRuntimeState,
|
||||
setAnilistMediaGuessRuntimeState,
|
||||
resetAnilistMediaGuessState,
|
||||
maybeProbeAnilistDuration,
|
||||
ensureAnilistMediaGuess,
|
||||
processNextAnilistRetryUpdate,
|
||||
maybeRunAnilistPostWatchUpdate,
|
||||
};
|
||||
}
|
||||
75
src/main/runtime/composers/app-ready-composer.test.ts
Normal file
75
src/main/runtime/composers/app-ready-composer.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeAppReadyRuntime } from './app-ready-composer';
|
||||
|
||||
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
|
||||
const composed = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => {},
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfigErrorMainDeps: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
appReadyRuntimeMainDeps: {
|
||||
loadSubtitlePosition: () => {},
|
||||
resolveKeybindings: () => {},
|
||||
createMpvClient: () => {},
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
initRuntimeOptionsManager: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
initializeOverlayRuntime: () => {},
|
||||
handleInitialArgs: () => {},
|
||||
logDebug: () => {},
|
||||
now: () => Date.now(),
|
||||
},
|
||||
immersionTrackerStartupMainDeps: {
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () =>
|
||||
({
|
||||
startSession: () => {},
|
||||
}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.reloadConfig, 'function');
|
||||
assert.equal(typeof composed.criticalConfigError, 'function');
|
||||
assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
|
||||
});
|
||||
59
src/main/runtime/composers/app-ready-composer.ts
Normal file
59
src/main/runtime/composers/app-ready-composer.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createAppReadyRuntimeRunner } from '../../app-lifecycle';
|
||||
import { createBuildAppReadyRuntimeMainDepsHandler } from '../app-ready-main-deps';
|
||||
import {
|
||||
createBuildCriticalConfigErrorMainDepsHandler,
|
||||
createBuildReloadConfigMainDepsHandler,
|
||||
} from '../startup-config-main-deps';
|
||||
import { createCriticalConfigErrorHandler, createReloadConfigHandler } from '../startup-config';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from '../immersion-startup-main-deps';
|
||||
import { createImmersionTrackerStartupHandler } from '../immersion-startup';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type ReloadConfigMainDeps = Parameters<typeof createBuildReloadConfigMainDepsHandler>[0];
|
||||
type CriticalConfigErrorMainDeps = Parameters<
|
||||
typeof createBuildCriticalConfigErrorMainDepsHandler
|
||||
>[0];
|
||||
type AppReadyRuntimeMainDeps = Parameters<typeof createBuildAppReadyRuntimeMainDepsHandler>[0];
|
||||
|
||||
export type AppReadyComposerOptions = ComposerInputs<{
|
||||
reloadConfigMainDeps: ReloadConfigMainDeps;
|
||||
criticalConfigErrorMainDeps: CriticalConfigErrorMainDeps;
|
||||
appReadyRuntimeMainDeps: Omit<AppReadyRuntimeMainDeps, 'reloadConfig' | 'onCriticalConfigErrors'>;
|
||||
immersionTrackerStartupMainDeps: Parameters<
|
||||
typeof createBuildImmersionTrackerStartupMainDepsHandler
|
||||
>[0];
|
||||
}>;
|
||||
|
||||
export type AppReadyComposerResult = ComposerOutputs<{
|
||||
reloadConfig: ReturnType<typeof createReloadConfigHandler>;
|
||||
criticalConfigError: ReturnType<typeof createCriticalConfigErrorHandler>;
|
||||
appReadyRuntimeRunner: ReturnType<typeof createAppReadyRuntimeRunner>;
|
||||
}>;
|
||||
|
||||
export function composeAppReadyRuntime(options: AppReadyComposerOptions): AppReadyComposerResult {
|
||||
const reloadConfig = createReloadConfigHandler(
|
||||
createBuildReloadConfigMainDepsHandler(options.reloadConfigMainDeps)(),
|
||||
);
|
||||
const criticalConfigError = createCriticalConfigErrorHandler(
|
||||
createBuildCriticalConfigErrorMainDepsHandler(options.criticalConfigErrorMainDeps)(),
|
||||
);
|
||||
|
||||
const appReadyRuntimeRunner = createAppReadyRuntimeRunner(
|
||||
createBuildAppReadyRuntimeMainDepsHandler({
|
||||
...options.appReadyRuntimeMainDeps,
|
||||
reloadConfig,
|
||||
createImmersionTracker: createImmersionTrackerStartupHandler(
|
||||
createBuildImmersionTrackerStartupMainDepsHandler(
|
||||
options.immersionTrackerStartupMainDeps,
|
||||
)(),
|
||||
),
|
||||
onCriticalConfigErrors: criticalConfigError,
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
reloadConfig,
|
||||
criticalConfigError,
|
||||
appReadyRuntimeRunner,
|
||||
};
|
||||
}
|
||||
95
src/main/runtime/composers/composer-contracts.type-test.ts
Normal file
95
src/main/runtime/composers/composer-contracts.type-test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ComposerInputs } from './contracts';
|
||||
import type { IpcRuntimeComposerOptions } from './ipc-runtime-composer';
|
||||
import type { JellyfinRemoteComposerOptions } from './jellyfin-remote-composer';
|
||||
import type { MpvRuntimeComposerOptions } from './mpv-runtime-composer';
|
||||
import type { AnilistSetupComposerOptions } from './anilist-setup-composer';
|
||||
|
||||
type Assert<T extends true> = T;
|
||||
type IsAssignable<From, To> = [From] extends [To] ? true : false;
|
||||
|
||||
type FakeMpvClient = {
|
||||
on: (...args: unknown[]) => unknown;
|
||||
connect: () => void;
|
||||
};
|
||||
|
||||
type FakeTokenizerDeps = { isKnownWord: (text: string) => boolean };
|
||||
type FakeTokenizedSubtitle = { text: string };
|
||||
|
||||
type RequiredAnilistSetupInputKeys = keyof ComposerInputs<AnilistSetupComposerOptions>;
|
||||
type RequiredJellyfinInputKeys = keyof ComposerInputs<JellyfinRemoteComposerOptions>;
|
||||
type RequiredIpcInputKeys = keyof ComposerInputs<IpcRuntimeComposerOptions>;
|
||||
type RequiredMpvInputKeys = keyof ComposerInputs<
|
||||
MpvRuntimeComposerOptions<FakeMpvClient, FakeTokenizerDeps, FakeTokenizedSubtitle>
|
||||
>;
|
||||
|
||||
type _anilistHasNotifyDeps = Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>;
|
||||
type _jellyfinHasGetMpvClient = Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>;
|
||||
type _ipcHasRegistration = Assert<IsAssignable<'registration', RequiredIpcInputKeys>>;
|
||||
type _mpvHasTokenizer = Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>;
|
||||
|
||||
// @ts-expect-error missing required notifyDeps should fail compile-time contract
|
||||
const anilistMissingRequired: AnilistSetupComposerOptions = {
|
||||
consumeTokenDeps: {} as AnilistSetupComposerOptions['consumeTokenDeps'],
|
||||
handleProtocolDeps: {} as AnilistSetupComposerOptions['handleProtocolDeps'],
|
||||
registerProtocolClientDeps: {} as AnilistSetupComposerOptions['registerProtocolClientDeps'],
|
||||
};
|
||||
|
||||
// @ts-expect-error missing required getMpvClient should fail compile-time contract
|
||||
const jellyfinMissingRequired: JellyfinRemoteComposerOptions = {
|
||||
getConfiguredSession: {} as JellyfinRemoteComposerOptions['getConfiguredSession'],
|
||||
getClientInfo: {} as JellyfinRemoteComposerOptions['getClientInfo'],
|
||||
getJellyfinConfig: {} as JellyfinRemoteComposerOptions['getJellyfinConfig'],
|
||||
playJellyfinItem: {} as JellyfinRemoteComposerOptions['playJellyfinItem'],
|
||||
logWarn: {} as JellyfinRemoteComposerOptions['logWarn'],
|
||||
sendMpvCommand: {} as JellyfinRemoteComposerOptions['sendMpvCommand'],
|
||||
jellyfinTicksToSeconds: {} as JellyfinRemoteComposerOptions['jellyfinTicksToSeconds'],
|
||||
getActivePlayback: {} as JellyfinRemoteComposerOptions['getActivePlayback'],
|
||||
clearActivePlayback: {} as JellyfinRemoteComposerOptions['clearActivePlayback'],
|
||||
getSession: {} as JellyfinRemoteComposerOptions['getSession'],
|
||||
getNow: {} as JellyfinRemoteComposerOptions['getNow'],
|
||||
getLastProgressAtMs: {} as JellyfinRemoteComposerOptions['getLastProgressAtMs'],
|
||||
setLastProgressAtMs: {} as JellyfinRemoteComposerOptions['setLastProgressAtMs'],
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: {} as JellyfinRemoteComposerOptions['logDebug'],
|
||||
};
|
||||
|
||||
// @ts-expect-error missing required registration should fail compile-time contract
|
||||
const ipcMissingRequired: IpcRuntimeComposerOptions = {
|
||||
mpvCommandMainDeps: {} as IpcRuntimeComposerOptions['mpvCommandMainDeps'],
|
||||
handleMpvCommandFromIpcRuntime: {} as IpcRuntimeComposerOptions['handleMpvCommandFromIpcRuntime'],
|
||||
runSubsyncManualFromIpc: {} as IpcRuntimeComposerOptions['runSubsyncManualFromIpc'],
|
||||
};
|
||||
|
||||
// @ts-expect-error missing required tokenizer should fail compile-time contract
|
||||
const mpvMissingRequired: MpvRuntimeComposerOptions<
|
||||
FakeMpvClient,
|
||||
FakeTokenizerDeps,
|
||||
FakeTokenizedSubtitle
|
||||
> = {
|
||||
bindMpvMainEventHandlersMainDeps: {} as MpvRuntimeComposerOptions<
|
||||
FakeMpvClient,
|
||||
FakeTokenizerDeps,
|
||||
FakeTokenizedSubtitle
|
||||
>['bindMpvMainEventHandlersMainDeps'],
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {} as MpvRuntimeComposerOptions<
|
||||
FakeMpvClient,
|
||||
FakeTokenizerDeps,
|
||||
FakeTokenizedSubtitle
|
||||
>['mpvClientRuntimeServiceFactoryMainDeps'],
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {} as MpvRuntimeComposerOptions<
|
||||
FakeMpvClient,
|
||||
FakeTokenizerDeps,
|
||||
FakeTokenizedSubtitle
|
||||
>['updateMpvSubtitleRenderMetricsMainDeps'],
|
||||
warmups: {} as MpvRuntimeComposerOptions<
|
||||
FakeMpvClient,
|
||||
FakeTokenizerDeps,
|
||||
FakeTokenizedSubtitle
|
||||
>['warmups'],
|
||||
};
|
||||
|
||||
void anilistMissingRequired;
|
||||
void jellyfinMissingRequired;
|
||||
void ipcMissingRequired;
|
||||
void mpvMissingRequired;
|
||||
13
src/main/runtime/composers/contracts.ts
Normal file
13
src/main/runtime/composers/contracts.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
type ComposerShape = Record<string, unknown>;
|
||||
|
||||
export type ComposerInputs<T extends ComposerShape> = Readonly<Required<T>>;
|
||||
|
||||
export type ComposerOutputs<T extends ComposerShape> = Readonly<T>;
|
||||
|
||||
export type BuiltMainDeps<TFactory> = TFactory extends (
|
||||
...args: infer _TFactoryArgs
|
||||
) => infer TBuilder
|
||||
? TBuilder extends (...args: infer _TBuilderArgs) => infer TDeps
|
||||
? TDeps
|
||||
: never
|
||||
: never;
|
||||
10
src/main/runtime/composers/index.ts
Normal file
10
src/main/runtime/composers/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './anilist-setup-composer';
|
||||
export * from './anilist-tracking-composer';
|
||||
export * from './app-ready-composer';
|
||||
export * from './contracts';
|
||||
export * from './ipc-runtime-composer';
|
||||
export * from './jellyfin-remote-composer';
|
||||
export * from './jellyfin-runtime-composer';
|
||||
export * from './mpv-runtime-composer';
|
||||
export * from './shortcuts-runtime-composer';
|
||||
export * from './startup-lifecycle-composer';
|
||||
109
src/main/runtime/composers/ipc-runtime-composer.test.ts
Normal file
109
src/main/runtime/composers/ipc-runtime-composer.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
|
||||
|
||||
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
||||
let registered = false;
|
||||
let receivedSourceTrackId: number | null | undefined;
|
||||
|
||||
const composed = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
isMpvConnected: () => false,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
},
|
||||
handleMpvCommandFromIpcRuntime: () => {},
|
||||
runSubsyncManualFromIpc: async (request) => {
|
||||
receivedSourceTrackId = request.sourceTrackId;
|
||||
return {
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
};
|
||||
},
|
||||
registration: {
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
showMpvOsd: () => {},
|
||||
},
|
||||
mainDeps: {
|
||||
getInvisibleWindow: () => null,
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
focusMainWindow: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => ({}) as never,
|
||||
getSubtitlePosition: () => ({}) as never,
|
||||
getSubtitleStyle: () => ({}) as never,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
getSecondarySubMode: () => 'hover' as never,
|
||||
getMpvClient: () => null,
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
reportOverlayContentBounds: () => {},
|
||||
reportHoveredSubtitleToken: () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
ankiJimakuDeps: {
|
||||
patchAnkiConnectEnabled: () => {},
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getAnkiIntegration: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
getKnownWordCacheStatePath: () => '',
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => (() => {}) as never,
|
||||
broadcastRuntimeOptionsChanged: () => {},
|
||||
getFieldGroupingResolver: () => null,
|
||||
setFieldGroupingResolver: () => {},
|
||||
parseMediaInfo: () => ({}) as never,
|
||||
getCurrentMediaPath: () => null,
|
||||
jimakuFetchJson: async () => ({ data: null }) as never,
|
||||
getJimakuMaxEntryResults: () => 0,
|
||||
getJimakuLanguagePreference: () => 'ja' as never,
|
||||
resolveJimakuApiKey: async () => null,
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }),
|
||||
},
|
||||
registerIpcRuntimeServices: () => {
|
||||
registered = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.handleMpvCommandFromIpc, 'function');
|
||||
assert.equal(typeof composed.runSubsyncManualFromIpc, 'function');
|
||||
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
|
||||
|
||||
const result = await composed.runSubsyncManualFromIpc({
|
||||
engine: 'alass',
|
||||
sourceTrackId: 7,
|
||||
});
|
||||
assert.deepEqual(result, { ok: true, message: 'ok' });
|
||||
assert.equal(receivedSourceTrackId, 7);
|
||||
|
||||
composed.registerIpcRuntimeHandlers();
|
||||
assert.equal(registered, true);
|
||||
});
|
||||
73
src/main/runtime/composers/ipc-runtime-composer.ts
Normal file
73
src/main/runtime/composers/ipc-runtime-composer.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime';
|
||||
import type { SubsyncManualRunRequest, SubsyncResult } from '../../../types';
|
||||
import {
|
||||
createBuildMpvCommandFromIpcRuntimeMainDepsHandler,
|
||||
createIpcRuntimeHandlers,
|
||||
} from '../domains/ipc';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type MpvCommand = (string | number)[];
|
||||
|
||||
type IpcMainDeps = RegisterIpcRuntimeServicesParams['mainDeps'];
|
||||
type IpcMainDepsWithoutHandlers = Omit<IpcMainDeps, 'handleMpvCommand' | 'runSubsyncManual'>;
|
||||
type RunSubsyncManual = IpcMainDeps['runSubsyncManual'];
|
||||
|
||||
type IpcRuntimeDeps = Parameters<
|
||||
typeof createIpcRuntimeHandlers<SubsyncManualRunRequest, SubsyncResult>
|
||||
>[0];
|
||||
|
||||
export type IpcRuntimeComposerOptions = ComposerInputs<{
|
||||
mpvCommandMainDeps: Parameters<typeof createBuildMpvCommandFromIpcRuntimeMainDepsHandler>[0];
|
||||
handleMpvCommandFromIpcRuntime: IpcRuntimeDeps['handleMpvCommandFromIpcDeps']['handleMpvCommandFromIpcRuntime'];
|
||||
runSubsyncManualFromIpc: RunSubsyncManual;
|
||||
registration: {
|
||||
runtimeOptions: RegisterIpcRuntimeServicesParams['runtimeOptions'];
|
||||
mainDeps: IpcMainDepsWithoutHandlers;
|
||||
ankiJimakuDeps: RegisterIpcRuntimeServicesParams['ankiJimakuDeps'];
|
||||
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type IpcRuntimeComposerResult = ComposerOutputs<{
|
||||
handleMpvCommandFromIpc: (command: MpvCommand) => void;
|
||||
runSubsyncManualFromIpc: RunSubsyncManual;
|
||||
registerIpcRuntimeHandlers: () => void;
|
||||
}>;
|
||||
|
||||
export function composeIpcRuntimeHandlers(
|
||||
options: IpcRuntimeComposerOptions,
|
||||
): IpcRuntimeComposerResult {
|
||||
const mpvCommandFromIpcRuntimeMainDeps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
options.mpvCommandMainDeps,
|
||||
)();
|
||||
const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers<
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult
|
||||
>({
|
||||
handleMpvCommandFromIpcDeps: {
|
||||
handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime,
|
||||
buildMpvCommandDeps: () => mpvCommandFromIpcRuntimeMainDeps,
|
||||
},
|
||||
runSubsyncManualFromIpcDeps: {
|
||||
runManualFromIpc: (request) => options.runSubsyncManualFromIpc(request),
|
||||
},
|
||||
});
|
||||
|
||||
const registerIpcRuntimeHandlers = (): void => {
|
||||
options.registration.registerIpcRuntimeServices({
|
||||
runtimeOptions: options.registration.runtimeOptions,
|
||||
mainDeps: {
|
||||
...options.registration.mainDeps,
|
||||
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
|
||||
runSubsyncManual: (request) => runSubsyncManualFromIpc(request),
|
||||
},
|
||||
ankiJimakuDeps: options.registration.ankiJimakuDeps,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleMpvCommandFromIpc: (command) => handleMpvCommandFromIpc(command),
|
||||
runSubsyncManualFromIpc: (request) => runSubsyncManualFromIpc(request),
|
||||
registerIpcRuntimeHandlers,
|
||||
};
|
||||
}
|
||||
34
src/main/runtime/composers/jellyfin-remote-composer.test.ts
Normal file
34
src/main/runtime/composers/jellyfin-remote-composer.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
|
||||
|
||||
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => {
|
||||
let lastProgressAt = 0;
|
||||
const composed = composeJellyfinRemoteHandlers({
|
||||
getConfiguredSession: () => null,
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never,
|
||||
getJellyfinConfig: () => ({ enabled: false }) as never,
|
||||
playJellyfinItem: async () => {},
|
||||
logWarn: () => {},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
jellyfinTicksToSeconds: () => 0,
|
||||
getActivePlayback: () => null,
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => null,
|
||||
getNow: () => 0,
|
||||
getLastProgressAtMs: () => lastProgressAt,
|
||||
setLastProgressAtMs: (next) => {
|
||||
lastProgressAt = next;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
|
||||
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
});
|
||||
137
src/main/runtime/composers/jellyfin-remote-composer.ts
Normal file
137
src/main/runtime/composers/jellyfin-remote-composer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
|
||||
createBuildHandleJellyfinRemotePlayMainDepsHandler,
|
||||
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
|
||||
createBuildReportJellyfinRemoteProgressMainDepsHandler,
|
||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
|
||||
createHandleJellyfinRemoteGeneralCommand,
|
||||
createHandleJellyfinRemotePlay,
|
||||
createHandleJellyfinRemotePlaystate,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
} from '../domains/jellyfin';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type RemotePlayPayload = Parameters<ReturnType<typeof createHandleJellyfinRemotePlay>>[0];
|
||||
type RemotePlaystatePayload = Parameters<ReturnType<typeof createHandleJellyfinRemotePlaystate>>[0];
|
||||
type RemoteGeneralPayload = Parameters<
|
||||
ReturnType<typeof createHandleJellyfinRemoteGeneralCommand>
|
||||
>[0];
|
||||
type JellyfinRemotePlayMainDeps = Parameters<
|
||||
typeof createBuildHandleJellyfinRemotePlayMainDepsHandler
|
||||
>[0];
|
||||
type JellyfinRemotePlaystateMainDeps = Parameters<
|
||||
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
|
||||
>[0];
|
||||
type JellyfinRemoteGeneralMainDeps = Parameters<
|
||||
typeof createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler
|
||||
>[0];
|
||||
type JellyfinRemoteProgressMainDeps = Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export type JellyfinRemoteComposerOptions = ComposerInputs<{
|
||||
getConfiguredSession: JellyfinRemotePlayMainDeps['getConfiguredSession'];
|
||||
getClientInfo: JellyfinRemotePlayMainDeps['getClientInfo'];
|
||||
getJellyfinConfig: JellyfinRemotePlayMainDeps['getJellyfinConfig'];
|
||||
playJellyfinItem: JellyfinRemotePlayMainDeps['playJellyfinItem'];
|
||||
logWarn: JellyfinRemotePlayMainDeps['logWarn'];
|
||||
getMpvClient: JellyfinRemoteProgressMainDeps['getMpvClient'];
|
||||
sendMpvCommand: JellyfinRemotePlaystateMainDeps['sendMpvCommand'];
|
||||
jellyfinTicksToSeconds: Parameters<
|
||||
typeof createBuildHandleJellyfinRemotePlaystateMainDepsHandler
|
||||
>[0]['jellyfinTicksToSeconds'];
|
||||
getActivePlayback: JellyfinRemoteGeneralMainDeps['getActivePlayback'];
|
||||
clearActivePlayback: JellyfinRemoteProgressMainDeps['clearActivePlayback'];
|
||||
getSession: JellyfinRemoteProgressMainDeps['getSession'];
|
||||
getNow: JellyfinRemoteProgressMainDeps['getNow'];
|
||||
getLastProgressAtMs: Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
>[0]['getLastProgressAtMs'];
|
||||
setLastProgressAtMs: Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
>[0]['setLastProgressAtMs'];
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: Parameters<
|
||||
typeof createBuildReportJellyfinRemoteProgressMainDepsHandler
|
||||
>[0]['logDebug'];
|
||||
}>;
|
||||
|
||||
export type JellyfinRemoteComposerResult = ComposerOutputs<{
|
||||
reportJellyfinRemoteProgress: ReturnType<typeof createReportJellyfinRemoteProgressHandler>;
|
||||
reportJellyfinRemoteStopped: ReturnType<typeof createReportJellyfinRemoteStoppedHandler>;
|
||||
handleJellyfinRemotePlay: (payload: RemotePlayPayload) => Promise<void>;
|
||||
handleJellyfinRemotePlaystate: (payload: RemotePlaystatePayload) => Promise<void>;
|
||||
handleJellyfinRemoteGeneralCommand: (payload: RemoteGeneralPayload) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export function composeJellyfinRemoteHandlers(
|
||||
options: JellyfinRemoteComposerOptions,
|
||||
): JellyfinRemoteComposerResult {
|
||||
const buildReportJellyfinRemoteProgressMainDepsHandler =
|
||||
createBuildReportJellyfinRemoteProgressMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
getLastProgressAtMs: options.getLastProgressAtMs,
|
||||
setLastProgressAtMs: options.setLastProgressAtMs,
|
||||
progressIntervalMs: options.progressIntervalMs,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
});
|
||||
const buildReportJellyfinRemoteStoppedMainDepsHandler =
|
||||
createBuildReportJellyfinRemoteStoppedMainDepsHandler({
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
logDebug: options.logDebug,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||
);
|
||||
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
buildReportJellyfinRemoteStoppedMainDepsHandler(),
|
||||
);
|
||||
|
||||
const buildHandleJellyfinRemotePlayMainDepsHandler =
|
||||
createBuildHandleJellyfinRemotePlayMainDepsHandler({
|
||||
getConfiguredSession: options.getConfiguredSession,
|
||||
getClientInfo: options.getClientInfo,
|
||||
getJellyfinConfig: options.getJellyfinConfig,
|
||||
playJellyfinItem: options.playJellyfinItem,
|
||||
logWarn: options.logWarn,
|
||||
});
|
||||
const buildHandleJellyfinRemotePlaystateMainDepsHandler =
|
||||
createBuildHandleJellyfinRemotePlaystateMainDepsHandler({
|
||||
getMpvClient: options.getMpvClient,
|
||||
sendMpvCommand: options.sendMpvCommand,
|
||||
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
|
||||
reportJellyfinRemoteStopped: () => reportJellyfinRemoteStopped(),
|
||||
jellyfinTicksToSeconds: options.jellyfinTicksToSeconds,
|
||||
});
|
||||
const buildHandleJellyfinRemoteGeneralCommandMainDepsHandler =
|
||||
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({
|
||||
getMpvClient: options.getMpvClient,
|
||||
sendMpvCommand: options.sendMpvCommand,
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
reportJellyfinRemoteProgress: (force) => reportJellyfinRemoteProgress(force),
|
||||
logDebug: (message) => options.logDebug(message, undefined),
|
||||
});
|
||||
|
||||
return {
|
||||
reportJellyfinRemoteProgress,
|
||||
reportJellyfinRemoteStopped,
|
||||
handleJellyfinRemotePlay: createHandleJellyfinRemotePlay(
|
||||
buildHandleJellyfinRemotePlayMainDepsHandler(),
|
||||
),
|
||||
handleJellyfinRemotePlaystate: createHandleJellyfinRemotePlaystate(
|
||||
buildHandleJellyfinRemotePlaystateMainDepsHandler(),
|
||||
),
|
||||
handleJellyfinRemoteGeneralCommand: createHandleJellyfinRemoteGeneralCommand(
|
||||
buildHandleJellyfinRemoteGeneralCommandMainDepsHandler(),
|
||||
),
|
||||
};
|
||||
}
|
||||
192
src/main/runtime/composers/jellyfin-runtime-composer.test.ts
Normal file
192
src/main/runtime/composers/jellyfin-runtime-composer.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
|
||||
|
||||
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
|
||||
let activePlayback: unknown = null;
|
||||
let lastProgressAtMs = 0;
|
||||
const composed = composeJellyfinRuntimeHandlers({
|
||||
getResolvedJellyfinConfigMainDeps: {
|
||||
getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never,
|
||||
loadStoredSession: () => null,
|
||||
getEnv: () => undefined,
|
||||
},
|
||||
getJellyfinClientInfoMainDeps: {
|
||||
getResolvedJellyfinConfig: () => ({}) as never,
|
||||
getDefaultJellyfinConfig: () => ({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: 'test',
|
||||
deviceId: 'dev',
|
||||
}),
|
||||
},
|
||||
waitForMpvConnectedMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
now: () => Date.now(),
|
||||
sleep: async () => {},
|
||||
},
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||
getSocketPath: () => '/tmp/test-mpv.sock',
|
||||
platform: 'linux',
|
||||
execPath: process.execPath,
|
||||
defaultMpvLogPath: '/tmp/test-mpv.log',
|
||||
defaultMpvArgs: [],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: () => ({ unref: () => {} }) as never,
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
setMpvClient: () => {},
|
||||
createMpvClient: () => ({}) as never,
|
||||
getAutoLaunchInFlight: () => null,
|
||||
setAutoLaunchInFlight: () => {},
|
||||
connectTimeoutMs: 10,
|
||||
autoLaunchTimeoutMs: 10,
|
||||
},
|
||||
preloadJellyfinExternalSubtitlesMainDeps: {
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
playJellyfinItemInMpvMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
resolvePlaybackPlan: async () => ({
|
||||
mode: 'direct',
|
||||
url: 'https://example.test/video.m3u8',
|
||||
title: 'Episode 1',
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => undefined,
|
||||
convertTicksToSeconds: () => 0,
|
||||
setActivePlayback: (value) => {
|
||||
activePlayback = value;
|
||||
},
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
},
|
||||
remoteComposerOptions: {
|
||||
getConfiguredSession: () => null,
|
||||
logWarn: () => {},
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
jellyfinTicksToSeconds: () => 0,
|
||||
getActivePlayback: () => activePlayback as never,
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
getSession: () => null,
|
||||
getNow: () => Date.now(),
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
},
|
||||
handleJellyfinAuthCommandsMainDeps: {
|
||||
patchRawConfig: () => {},
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'https://example.test',
|
||||
username: 'user',
|
||||
accessToken: 'token',
|
||||
userId: 'id',
|
||||
}),
|
||||
saveStoredSession: () => {},
|
||||
clearStoredSession: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
handleJellyfinListCommandsMainDeps: {
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
logInfo: () => {},
|
||||
},
|
||||
handleJellyfinPlayCommandMainDeps: {
|
||||
logWarn: () => {},
|
||||
},
|
||||
handleJellyfinRemoteAnnounceCommandMainDeps: {
|
||||
getRemoteSession: () => null,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
startJellyfinRemoteSessionMainDeps: {
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
createRemoteSessionService: () =>
|
||||
({
|
||||
start: async () => {},
|
||||
}) as never,
|
||||
defaultDeviceId: 'dev',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
stopJellyfinRemoteSessionMainDeps: {
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: 'https://example.test',
|
||||
},
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps: {
|
||||
getSetupWindow: () => null,
|
||||
},
|
||||
openJellyfinSetupWindowMainDeps: {
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
focus: () => {},
|
||||
webContents: { on: () => {} },
|
||||
loadURL: () => {},
|
||||
on: () => {},
|
||||
isDestroyed: () => false,
|
||||
close: () => {},
|
||||
}) as never,
|
||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
||||
`<html>${defaultServer}${defaultUser}</html>`,
|
||||
parseSubmissionUrl: () => null,
|
||||
authenticateWithPassword: async () => ({
|
||||
serverUrl: 'https://example.test',
|
||||
username: 'user',
|
||||
accessToken: 'token',
|
||||
userId: 'id',
|
||||
}),
|
||||
saveStoredSession: () => {},
|
||||
patchJellyfinConfig: () => {},
|
||||
logInfo: () => {},
|
||||
logError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {},
|
||||
encodeURIComponent,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.getResolvedJellyfinConfig, 'function');
|
||||
assert.equal(typeof composed.getJellyfinClientInfo, 'function');
|
||||
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
|
||||
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
|
||||
});
|
||||
290
src/main/runtime/composers/jellyfin-runtime-composer.ts
Normal file
290
src/main/runtime/composers/jellyfin-runtime-composer.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
buildJellyfinSetupFormHtml,
|
||||
createEnsureMpvConnectedForJellyfinPlaybackHandler,
|
||||
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler,
|
||||
createBuildGetJellyfinClientInfoMainDepsHandler,
|
||||
createBuildGetResolvedJellyfinConfigMainDepsHandler,
|
||||
createBuildHandleJellyfinAuthCommandsMainDepsHandler,
|
||||
createBuildHandleJellyfinListCommandsMainDepsHandler,
|
||||
createBuildHandleJellyfinPlayCommandMainDepsHandler,
|
||||
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler,
|
||||
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler,
|
||||
createBuildOpenJellyfinSetupWindowMainDepsHandler,
|
||||
createBuildPlayJellyfinItemInMpvMainDepsHandler,
|
||||
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler,
|
||||
createBuildRunJellyfinCommandMainDepsHandler,
|
||||
createBuildStartJellyfinRemoteSessionMainDepsHandler,
|
||||
createBuildStopJellyfinRemoteSessionMainDepsHandler,
|
||||
createBuildWaitForMpvConnectedMainDepsHandler,
|
||||
createGetJellyfinClientInfoHandler,
|
||||
createGetResolvedJellyfinConfigHandler,
|
||||
createHandleJellyfinAuthCommands,
|
||||
createHandleJellyfinListCommands,
|
||||
createHandleJellyfinPlayCommand,
|
||||
createHandleJellyfinRemoteAnnounceCommand,
|
||||
createLaunchMpvIdleForJellyfinPlaybackHandler,
|
||||
createOpenJellyfinSetupWindowHandler,
|
||||
createPlayJellyfinItemInMpvHandler,
|
||||
createPreloadJellyfinExternalSubtitlesHandler,
|
||||
createRunJellyfinCommandHandler,
|
||||
createStartJellyfinRemoteSessionHandler,
|
||||
createStopJellyfinRemoteSessionHandler,
|
||||
createWaitForMpvConnectedHandler,
|
||||
createMaybeFocusExistingJellyfinSetupWindowHandler,
|
||||
parseJellyfinSetupSubmissionUrl,
|
||||
} from '../domains/jellyfin';
|
||||
import {
|
||||
composeJellyfinRemoteHandlers,
|
||||
type JellyfinRemoteComposerOptions,
|
||||
} from './jellyfin-remote-composer';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type EnsureMpvConnectedMainDeps = Parameters<
|
||||
typeof createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler
|
||||
>[0];
|
||||
type PlayJellyfinItemMainDeps = Parameters<
|
||||
typeof createBuildPlayJellyfinItemInMpvMainDepsHandler
|
||||
>[0];
|
||||
type HandlePlayCommandMainDeps = Parameters<
|
||||
typeof createBuildHandleJellyfinPlayCommandMainDepsHandler
|
||||
>[0];
|
||||
type HandleRemoteAnnounceMainDeps = Parameters<
|
||||
typeof createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler
|
||||
>[0];
|
||||
type StartRemoteSessionMainDeps = Parameters<
|
||||
typeof createBuildStartJellyfinRemoteSessionMainDepsHandler
|
||||
>[0];
|
||||
type RunJellyfinCommandMainDeps = Parameters<
|
||||
typeof createBuildRunJellyfinCommandMainDepsHandler
|
||||
>[0];
|
||||
type OpenJellyfinSetupWindowMainDeps = Parameters<
|
||||
typeof createBuildOpenJellyfinSetupWindowMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
getResolvedJellyfinConfigMainDeps: Parameters<
|
||||
typeof createBuildGetResolvedJellyfinConfigMainDepsHandler
|
||||
>[0];
|
||||
getJellyfinClientInfoMainDeps: Parameters<
|
||||
typeof createBuildGetJellyfinClientInfoMainDepsHandler
|
||||
>[0];
|
||||
waitForMpvConnectedMainDeps: Parameters<typeof createBuildWaitForMpvConnectedMainDepsHandler>[0];
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: Parameters<
|
||||
typeof createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler
|
||||
>[0];
|
||||
ensureMpvConnectedForJellyfinPlaybackMainDeps: Omit<
|
||||
EnsureMpvConnectedMainDeps,
|
||||
'waitForMpvConnected' | 'launchMpvIdleForJellyfinPlayback'
|
||||
>;
|
||||
preloadJellyfinExternalSubtitlesMainDeps: Parameters<
|
||||
typeof createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler
|
||||
>[0];
|
||||
playJellyfinItemInMpvMainDeps: Omit<
|
||||
PlayJellyfinItemMainDeps,
|
||||
'ensureMpvConnectedForPlayback' | 'preloadExternalSubtitles'
|
||||
>;
|
||||
remoteComposerOptions: Omit<
|
||||
JellyfinRemoteComposerOptions,
|
||||
'getClientInfo' | 'getJellyfinConfig' | 'playJellyfinItem'
|
||||
>;
|
||||
handleJellyfinAuthCommandsMainDeps: Parameters<
|
||||
typeof createBuildHandleJellyfinAuthCommandsMainDepsHandler
|
||||
>[0];
|
||||
handleJellyfinListCommandsMainDeps: Parameters<
|
||||
typeof createBuildHandleJellyfinListCommandsMainDepsHandler
|
||||
>[0];
|
||||
handleJellyfinPlayCommandMainDeps: Omit<HandlePlayCommandMainDeps, 'playJellyfinItemInMpv'>;
|
||||
handleJellyfinRemoteAnnounceCommandMainDeps: Omit<
|
||||
HandleRemoteAnnounceMainDeps,
|
||||
'startJellyfinRemoteSession'
|
||||
>;
|
||||
startJellyfinRemoteSessionMainDeps: Omit<
|
||||
StartRemoteSessionMainDeps,
|
||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
||||
>;
|
||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||
>[0];
|
||||
runJellyfinCommandMainDeps: Omit<
|
||||
RunJellyfinCommandMainDeps,
|
||||
| 'getJellyfinConfig'
|
||||
| 'getJellyfinClientInfo'
|
||||
| 'handleAuthCommands'
|
||||
| 'handleRemoteAnnounceCommand'
|
||||
| 'handleListCommands'
|
||||
| 'handlePlayCommand'
|
||||
>;
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps: Parameters<
|
||||
typeof createMaybeFocusExistingJellyfinSetupWindowHandler
|
||||
>[0];
|
||||
openJellyfinSetupWindowMainDeps: Omit<
|
||||
OpenJellyfinSetupWindowMainDeps,
|
||||
'maybeFocusExistingSetupWindow' | 'getResolvedJellyfinConfig' | 'getJellyfinClientInfo'
|
||||
>;
|
||||
}>;
|
||||
|
||||
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
|
||||
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
|
||||
reportJellyfinRemoteProgress: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['reportJellyfinRemoteProgress'];
|
||||
reportJellyfinRemoteStopped: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['reportJellyfinRemoteStopped'];
|
||||
handleJellyfinRemotePlay: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemotePlay'];
|
||||
handleJellyfinRemotePlaystate: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemotePlaystate'];
|
||||
handleJellyfinRemoteGeneralCommand: ReturnType<
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
|
||||
}>;
|
||||
|
||||
export function composeJellyfinRuntimeHandlers(
|
||||
options: JellyfinRuntimeComposerOptions,
|
||||
): JellyfinRuntimeComposerResult {
|
||||
const getResolvedJellyfinConfig = createGetResolvedJellyfinConfigHandler(
|
||||
createBuildGetResolvedJellyfinConfigMainDepsHandler(
|
||||
options.getResolvedJellyfinConfigMainDeps,
|
||||
)(),
|
||||
);
|
||||
const getJellyfinClientInfo = createGetJellyfinClientInfoHandler(
|
||||
createBuildGetJellyfinClientInfoMainDepsHandler(options.getJellyfinClientInfoMainDeps)(),
|
||||
);
|
||||
|
||||
const waitForMpvConnected = createWaitForMpvConnectedHandler(
|
||||
createBuildWaitForMpvConnectedMainDepsHandler(options.waitForMpvConnectedMainDeps)(),
|
||||
);
|
||||
const launchMpvIdleForJellyfinPlayback = createLaunchMpvIdleForJellyfinPlaybackHandler(
|
||||
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
options.launchMpvIdleForJellyfinPlaybackMainDeps,
|
||||
)(),
|
||||
);
|
||||
const ensureMpvConnectedForJellyfinPlayback = createEnsureMpvConnectedForJellyfinPlaybackHandler(
|
||||
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({
|
||||
...options.ensureMpvConnectedForJellyfinPlaybackMainDeps,
|
||||
waitForMpvConnected: (timeoutMs) => waitForMpvConnected(timeoutMs),
|
||||
launchMpvIdleForJellyfinPlayback: () => launchMpvIdleForJellyfinPlayback(),
|
||||
})(),
|
||||
);
|
||||
|
||||
const preloadJellyfinExternalSubtitles = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
options.preloadJellyfinExternalSubtitlesMainDeps,
|
||||
)(),
|
||||
);
|
||||
const playJellyfinItemInMpv = createPlayJellyfinItemInMpvHandler(
|
||||
createBuildPlayJellyfinItemInMpvMainDepsHandler({
|
||||
...options.playJellyfinItemInMpvMainDeps,
|
||||
ensureMpvConnectedForPlayback: () => ensureMpvConnectedForJellyfinPlayback(),
|
||||
preloadExternalSubtitles: (params) => {
|
||||
void preloadJellyfinExternalSubtitles(params);
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
const {
|
||||
reportJellyfinRemoteProgress,
|
||||
reportJellyfinRemoteStopped,
|
||||
handleJellyfinRemotePlay,
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
} = composeJellyfinRemoteHandlers({
|
||||
...options.remoteComposerOptions,
|
||||
getClientInfo: () => getJellyfinClientInfo(),
|
||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
playJellyfinItem: (params) =>
|
||||
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
|
||||
});
|
||||
|
||||
const handleJellyfinAuthCommands = createHandleJellyfinAuthCommands(
|
||||
createBuildHandleJellyfinAuthCommandsMainDepsHandler(
|
||||
options.handleJellyfinAuthCommandsMainDeps,
|
||||
)(),
|
||||
);
|
||||
const handleJellyfinListCommands = createHandleJellyfinListCommands(
|
||||
createBuildHandleJellyfinListCommandsMainDepsHandler(
|
||||
options.handleJellyfinListCommandsMainDeps,
|
||||
)(),
|
||||
);
|
||||
const handleJellyfinPlayCommand = createHandleJellyfinPlayCommand(
|
||||
createBuildHandleJellyfinPlayCommandMainDepsHandler({
|
||||
...options.handleJellyfinPlayCommandMainDeps,
|
||||
playJellyfinItemInMpv: (params) =>
|
||||
playJellyfinItemInMpv(params as Parameters<typeof playJellyfinItemInMpv>[0]),
|
||||
})(),
|
||||
);
|
||||
|
||||
let startJellyfinRemoteSession!: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
const handleJellyfinRemoteAnnounceCommand = createHandleJellyfinRemoteAnnounceCommand(
|
||||
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
|
||||
...options.handleJellyfinRemoteAnnounceCommandMainDeps,
|
||||
startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
|
||||
})(),
|
||||
);
|
||||
|
||||
startJellyfinRemoteSession = createStartJellyfinRemoteSessionHandler(
|
||||
createBuildStartJellyfinRemoteSessionMainDepsHandler({
|
||||
...options.startJellyfinRemoteSessionMainDeps,
|
||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
|
||||
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
|
||||
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
|
||||
})(),
|
||||
);
|
||||
|
||||
const stopJellyfinRemoteSession = createStopJellyfinRemoteSessionHandler(
|
||||
createBuildStopJellyfinRemoteSessionMainDepsHandler(
|
||||
options.stopJellyfinRemoteSessionMainDeps,
|
||||
)(),
|
||||
);
|
||||
|
||||
const runJellyfinCommand = createRunJellyfinCommandHandler(
|
||||
createBuildRunJellyfinCommandMainDepsHandler({
|
||||
...options.runJellyfinCommandMainDeps,
|
||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getJellyfinClientInfo: (jellyfinConfig) => getJellyfinClientInfo(jellyfinConfig),
|
||||
handleAuthCommands: (params) => handleJellyfinAuthCommands(params),
|
||||
handleRemoteAnnounceCommand: (args) => handleJellyfinRemoteAnnounceCommand(args),
|
||||
handleListCommands: (params) => handleJellyfinListCommands(params),
|
||||
handlePlayCommand: (params) => handleJellyfinPlayCommand(params),
|
||||
})(),
|
||||
);
|
||||
|
||||
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
|
||||
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||
);
|
||||
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
|
||||
createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
||||
...options.openJellyfinSetupWindowMainDeps,
|
||||
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
|
||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getJellyfinClientInfo: () => getJellyfinClientInfo(),
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
getResolvedJellyfinConfig,
|
||||
getJellyfinClientInfo,
|
||||
reportJellyfinRemoteProgress,
|
||||
reportJellyfinRemoteStopped,
|
||||
handleJellyfinRemotePlay,
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildJellyfinSetupFormHtml, parseJellyfinSetupSubmissionUrl };
|
||||
219
src/main/runtime/composers/mpv-runtime-composer.test.ts
Normal file
219
src/main/runtime/composers/mpv-runtime-composer.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { MpvSubtitleRenderMetrics } from '../../../types';
|
||||
import { composeMpvRuntimeHandlers } from './mpv-runtime-composer';
|
||||
|
||||
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('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
|
||||
const calls: string[] = [];
|
||||
let started = false;
|
||||
let metrics = BASE_METRICS;
|
||||
|
||||
class FakeMpvClient {
|
||||
connected = false;
|
||||
|
||||
constructor(
|
||||
public socketPath: string,
|
||||
public options: unknown,
|
||||
) {
|
||||
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
|
||||
calls.push(`create-client:${socketPath}`);
|
||||
calls.push(`auto-start:${String(autoStartOverlay)}`);
|
||||
}
|
||||
|
||||
on(): void {}
|
||||
|
||||
connect(): void {
|
||||
this.connected = true;
|
||||
calls.push('client-connect');
|
||||
}
|
||||
}
|
||||
|
||||
const composed = composeMpvRuntimeHandlers<
|
||||
FakeMpvClient,
|
||||
{ isKnownWord: (text: string) => boolean },
|
||||
{ text: string }
|
||||
>({
|
||||
bindMpvMainEventHandlersMainDeps: {
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: null,
|
||||
immersionTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: null,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
},
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
||||
createClient: FakeMpvClient,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getResolvedConfig: () => ({ auto_start_overlay: false }),
|
||||
isAutoStartOverlayEnabled: () => true,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => metrics,
|
||||
setCurrentMetrics: (next) => {
|
||||
metrics = next;
|
||||
calls.push('set-metrics');
|
||||
},
|
||||
applyPatch: (current, patch) => {
|
||||
calls.push('apply-metrics-patch');
|
||||
return { next: { ...current, ...patch }, changed: true };
|
||||
},
|
||||
broadcastMetrics: () => {
|
||||
calls.push('broadcast-metrics');
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => null,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
isKnownWord: (text) => text === 'known',
|
||||
recordLookup: () => {},
|
||||
getKnownWordMatchMode: () => 'headword',
|
||||
getMinSentenceWordsForNPlusOne: () => 3,
|
||||
getJlptLevel: () => null,
|
||||
getJlptEnabled: () => true,
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: () => null,
|
||||
getYomitanGroupDebugEnabled: () => false,
|
||||
getMecabTokenizer: () => null,
|
||||
},
|
||||
createTokenizerRuntimeDeps: (deps) => {
|
||||
calls.push('create-tokenizer-runtime-deps');
|
||||
return { isKnownWord: (text: string) => deps.isKnownWord(text) };
|
||||
},
|
||||
tokenizeSubtitle: async (text, deps) => {
|
||||
calls.push(`tokenize:${text}`);
|
||||
deps.isKnownWord('known');
|
||||
return { text };
|
||||
},
|
||||
createMecabTokenizerAndCheckMainDeps: {
|
||||
getMecabTokenizer: () => ({ id: 'mecab' }),
|
||||
setMecabTokenizer: () => {},
|
||||
createMecabTokenizer: () => ({ id: 'mecab' }),
|
||||
checkAvailability: async () => {
|
||||
calls.push('check-mecab');
|
||||
},
|
||||
},
|
||||
prewarmSubtitleDictionariesMainDeps: {
|
||||
ensureJlptDictionaryLookup: async () => {
|
||||
calls.push('prewarm-jlpt');
|
||||
},
|
||||
ensureFrequencyDictionaryLookup: async () => {
|
||||
calls.push('prewarm-frequency');
|
||||
},
|
||||
},
|
||||
},
|
||||
warmups: {
|
||||
launchBackgroundWarmupTaskMainDeps: {
|
||||
now: () => 100,
|
||||
logDebug: () => {
|
||||
calls.push('warmup-debug');
|
||||
},
|
||||
logWarn: () => {
|
||||
calls.push('warmup-warn');
|
||||
},
|
||||
},
|
||||
startBackgroundWarmupsMainDeps: {
|
||||
getStarted: () => started,
|
||||
setStarted: (next) => {
|
||||
started = next;
|
||||
calls.push(`set-started:${String(next)}`);
|
||||
},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('warmup-yomitan');
|
||||
},
|
||||
shouldAutoConnectJellyfinRemote: () => false,
|
||||
startJellyfinRemoteSession: async () => {
|
||||
calls.push('warmup-jellyfin');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.bindMpvClientEventHandlers, 'function');
|
||||
assert.equal(typeof composed.createMpvClientRuntimeService, 'function');
|
||||
assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function');
|
||||
assert.equal(typeof composed.tokenizeSubtitle, 'function');
|
||||
assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function');
|
||||
assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function');
|
||||
assert.equal(typeof composed.launchBackgroundWarmupTask, 'function');
|
||||
assert.equal(typeof composed.startBackgroundWarmups, 'function');
|
||||
|
||||
const client = composed.createMpvClientRuntimeService();
|
||||
assert.equal(client.connected, true);
|
||||
|
||||
composed.updateMpvSubtitleRenderMetrics({ subPos: 90 });
|
||||
const tokenized = await composed.tokenizeSubtitle('subtitle text');
|
||||
await composed.createMecabTokenizerAndCheck();
|
||||
await composed.prewarmSubtitleDictionaries();
|
||||
composed.startBackgroundWarmups();
|
||||
|
||||
assert.deepEqual(tokenized, { text: 'subtitle text' });
|
||||
assert.equal(metrics.subPos, 90);
|
||||
assert.ok(calls.includes('create-client:/tmp/mpv.sock'));
|
||||
assert.ok(calls.includes('auto-start:true'));
|
||||
assert.ok(calls.includes('client-connect'));
|
||||
assert.ok(calls.includes('apply-metrics-patch'));
|
||||
assert.ok(calls.includes('set-metrics'));
|
||||
assert.ok(calls.includes('broadcast-metrics'));
|
||||
assert.ok(calls.includes('create-tokenizer-runtime-deps'));
|
||||
assert.ok(calls.includes('tokenize:subtitle text'));
|
||||
assert.ok(calls.includes('check-mecab'));
|
||||
assert.ok(calls.includes('prewarm-jlpt'));
|
||||
assert.ok(calls.includes('prewarm-frequency'));
|
||||
assert.ok(calls.includes('set-started:true'));
|
||||
assert.ok(calls.includes('warmup-yomitan'));
|
||||
});
|
||||
167
src/main/runtime/composers/mpv-runtime-composer.ts
Normal file
167
src/main/runtime/composers/mpv-runtime-composer.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createBindMpvMainEventHandlersHandler } from '../mpv-main-event-bindings';
|
||||
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-event-main-deps';
|
||||
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps';
|
||||
import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service';
|
||||
import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service';
|
||||
import type { Config } from '../../../types';
|
||||
import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from '../mpv-subtitle-render-metrics-main-deps';
|
||||
import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics';
|
||||
import {
|
||||
createBuildTokenizerDepsMainHandler,
|
||||
createCreateMecabTokenizerAndCheckMainHandler,
|
||||
createPrewarmSubtitleDictionariesMainHandler,
|
||||
} from '../subtitle-tokenization-main-deps';
|
||||
import {
|
||||
createBuildLaunchBackgroundWarmupTaskMainDepsHandler,
|
||||
createBuildStartBackgroundWarmupsMainDepsHandler,
|
||||
} from '../startup-warmups-main-deps';
|
||||
import {
|
||||
createLaunchBackgroundWarmupTaskHandler as createLaunchBackgroundWarmupTaskFromStartup,
|
||||
createStartBackgroundWarmupsHandler as createStartBackgroundWarmupsFromStartup,
|
||||
} from '../startup-warmups';
|
||||
import type { BuiltMainDeps, ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type BindMpvMainEventHandlersMainDeps = Parameters<
|
||||
typeof createBuildBindMpvMainEventHandlersMainDepsHandler
|
||||
>[0];
|
||||
type BindMpvMainEventHandlers = ReturnType<typeof createBindMpvMainEventHandlersHandler>;
|
||||
type BoundMpvClient = Parameters<BindMpvMainEventHandlers>[0];
|
||||
type RuntimeMpvClient = BoundMpvClient & { connect: () => void };
|
||||
type MpvClientRuntimeServiceFactoryMainDeps<TMpvClient extends RuntimeMpvClient> = Omit<
|
||||
Parameters<
|
||||
typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
TMpvClient,
|
||||
Config,
|
||||
MpvClientRuntimeServiceOptions
|
||||
>
|
||||
>[0],
|
||||
'bindEventHandlers'
|
||||
>;
|
||||
type UpdateMpvSubtitleRenderMetricsMainDeps = Parameters<
|
||||
typeof createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler
|
||||
>[0];
|
||||
type BuildTokenizerDepsMainDeps = Parameters<typeof createBuildTokenizerDepsMainHandler>[0];
|
||||
type TokenizerMainDeps = BuiltMainDeps<typeof createBuildTokenizerDepsMainHandler>;
|
||||
type CreateMecabTokenizerAndCheckMainDeps = Parameters<
|
||||
typeof createCreateMecabTokenizerAndCheckMainHandler
|
||||
>[0];
|
||||
type PrewarmSubtitleDictionariesMainDeps = Parameters<
|
||||
typeof createPrewarmSubtitleDictionariesMainHandler
|
||||
>[0];
|
||||
type LaunchBackgroundWarmupTaskMainDeps = Parameters<
|
||||
typeof createBuildLaunchBackgroundWarmupTaskMainDepsHandler
|
||||
>[0];
|
||||
type StartBackgroundWarmupsMainDeps = Omit<
|
||||
Parameters<typeof createBuildStartBackgroundWarmupsMainDepsHandler>[0],
|
||||
'launchTask' | 'createMecabTokenizerAndCheck' | 'prewarmSubtitleDictionaries'
|
||||
>;
|
||||
|
||||
export type MpvRuntimeComposerOptions<
|
||||
TMpvClient extends RuntimeMpvClient,
|
||||
TTokenizerRuntimeDeps,
|
||||
TTokenizedSubtitle,
|
||||
> = ComposerInputs<{
|
||||
bindMpvMainEventHandlersMainDeps: BindMpvMainEventHandlersMainDeps;
|
||||
mpvClientRuntimeServiceFactoryMainDeps: MpvClientRuntimeServiceFactoryMainDeps<TMpvClient>;
|
||||
updateMpvSubtitleRenderMetricsMainDeps: UpdateMpvSubtitleRenderMetricsMainDeps;
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: BuildTokenizerDepsMainDeps;
|
||||
createTokenizerRuntimeDeps: (deps: TokenizerMainDeps) => TTokenizerRuntimeDeps;
|
||||
tokenizeSubtitle: (text: string, deps: TTokenizerRuntimeDeps) => Promise<TTokenizedSubtitle>;
|
||||
createMecabTokenizerAndCheckMainDeps: CreateMecabTokenizerAndCheckMainDeps;
|
||||
prewarmSubtitleDictionariesMainDeps: PrewarmSubtitleDictionariesMainDeps;
|
||||
};
|
||||
warmups: {
|
||||
launchBackgroundWarmupTaskMainDeps: LaunchBackgroundWarmupTaskMainDeps;
|
||||
startBackgroundWarmupsMainDeps: StartBackgroundWarmupsMainDeps;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type MpvRuntimeComposerResult<
|
||||
TMpvClient extends RuntimeMpvClient,
|
||||
TTokenizedSubtitle,
|
||||
> = ComposerOutputs<{
|
||||
bindMpvClientEventHandlers: BindMpvMainEventHandlers;
|
||||
createMpvClientRuntimeService: () => TMpvClient;
|
||||
updateMpvSubtitleRenderMetrics: ReturnType<typeof createUpdateMpvSubtitleRenderMetricsHandler>;
|
||||
tokenizeSubtitle: (text: string) => Promise<TTokenizedSubtitle>;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
launchBackgroundWarmupTask: ReturnType<typeof createLaunchBackgroundWarmupTaskFromStartup>;
|
||||
startBackgroundWarmups: ReturnType<typeof createStartBackgroundWarmupsFromStartup>;
|
||||
}>;
|
||||
|
||||
export function composeMpvRuntimeHandlers<
|
||||
TMpvClient extends RuntimeMpvClient,
|
||||
TTokenizerRuntimeDeps,
|
||||
TTokenizedSubtitle,
|
||||
>(
|
||||
options: MpvRuntimeComposerOptions<TMpvClient, TTokenizerRuntimeDeps, TTokenizedSubtitle>,
|
||||
): MpvRuntimeComposerResult<TMpvClient, TTokenizedSubtitle> {
|
||||
const bindMpvMainEventHandlersMainDeps = createBuildBindMpvMainEventHandlersMainDepsHandler(
|
||||
options.bindMpvMainEventHandlersMainDeps,
|
||||
)();
|
||||
const bindMpvClientEventHandlers = createBindMpvMainEventHandlersHandler(
|
||||
bindMpvMainEventHandlersMainDeps,
|
||||
);
|
||||
|
||||
const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
|
||||
createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
TMpvClient,
|
||||
Config,
|
||||
MpvClientRuntimeServiceOptions
|
||||
>({
|
||||
...options.mpvClientRuntimeServiceFactoryMainDeps,
|
||||
bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
|
||||
});
|
||||
const createMpvClientRuntimeService = (): TMpvClient =>
|
||||
createMpvClientRuntimeServiceFactory(buildMpvClientRuntimeServiceFactoryMainDepsHandler())();
|
||||
|
||||
const updateMpvSubtitleRenderMetrics = createUpdateMpvSubtitleRenderMetricsHandler(
|
||||
createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler(
|
||||
options.updateMpvSubtitleRenderMetricsMainDeps,
|
||||
)(),
|
||||
);
|
||||
|
||||
const buildTokenizerDepsHandler = createBuildTokenizerDepsMainHandler(
|
||||
options.tokenizer.buildTokenizerDepsMainDeps,
|
||||
);
|
||||
const createMecabTokenizerAndCheck = createCreateMecabTokenizerAndCheckMainHandler(
|
||||
options.tokenizer.createMecabTokenizerAndCheckMainDeps,
|
||||
);
|
||||
const prewarmSubtitleDictionaries = createPrewarmSubtitleDictionariesMainHandler(
|
||||
options.tokenizer.prewarmSubtitleDictionariesMainDeps,
|
||||
);
|
||||
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
||||
await prewarmSubtitleDictionaries();
|
||||
return options.tokenizer.tokenizeSubtitle(
|
||||
text,
|
||||
options.tokenizer.createTokenizerRuntimeDeps(buildTokenizerDepsHandler()),
|
||||
);
|
||||
};
|
||||
|
||||
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
|
||||
createBuildLaunchBackgroundWarmupTaskMainDepsHandler(
|
||||
options.warmups.launchBackgroundWarmupTaskMainDeps,
|
||||
)(),
|
||||
);
|
||||
const startBackgroundWarmups = createStartBackgroundWarmupsFromStartup(
|
||||
createBuildStartBackgroundWarmupsMainDepsHandler({
|
||||
...options.warmups.startBackgroundWarmupsMainDeps,
|
||||
launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
|
||||
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
bindMpvClientEventHandlers: (client) => bindMpvClientEventHandlers(client),
|
||||
createMpvClientRuntimeService,
|
||||
updateMpvSubtitleRenderMetrics: (patch) => updateMpvSubtitleRenderMetrics(patch),
|
||||
tokenizeSubtitle,
|
||||
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
|
||||
launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task),
|
||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
|
||||
|
||||
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
|
||||
const composed = composeShortcutRuntimes({
|
||||
globalShortcuts: {
|
||||
getConfiguredShortcutsMainDeps: {
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
defaultConfig: {} as never,
|
||||
resolveConfiguredShortcuts: () => ({}) as never,
|
||||
},
|
||||
buildRegisterGlobalShortcutsMainDeps: () => ({
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
registerGlobalShortcutsCore: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
toggleInvisibleOverlay: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
isDev: false,
|
||||
getMainWindow: () => null,
|
||||
}),
|
||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
}),
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut: {
|
||||
register: () => true,
|
||||
unregister: () => {},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: () => {},
|
||||
onMineSentenceDigit: () => {},
|
||||
},
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime: {
|
||||
registerOverlayShortcuts: () => {},
|
||||
unregisterOverlayShortcuts: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
refreshOverlayShortcuts: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.getConfiguredShortcuts, 'function');
|
||||
assert.equal(typeof composed.registerGlobalShortcuts, 'function');
|
||||
assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function');
|
||||
assert.equal(typeof composed.cancelPendingMultiCopy, 'function');
|
||||
assert.equal(typeof composed.startPendingMultiCopy, 'function');
|
||||
assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function');
|
||||
assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function');
|
||||
assert.equal(typeof composed.registerOverlayShortcuts, 'function');
|
||||
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
|
||||
assert.equal(typeof composed.syncOverlayShortcuts, 'function');
|
||||
assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
|
||||
});
|
||||
60
src/main/runtime/composers/shortcuts-runtime-composer.ts
Normal file
60
src/main/runtime/composers/shortcuts-runtime-composer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createNumericShortcutRuntime } from '../../../core/services/numeric-shortcut';
|
||||
import {
|
||||
createBuildNumericShortcutRuntimeMainDepsHandler,
|
||||
createGlobalShortcutsRuntimeHandlers,
|
||||
createNumericShortcutSessionRuntimeHandlers,
|
||||
createOverlayShortcutsRuntimeHandlers,
|
||||
} from '../domains/shortcuts';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type GlobalShortcutsOptions = Parameters<typeof createGlobalShortcutsRuntimeHandlers>[0];
|
||||
type NumericShortcutRuntimeMainDeps = Parameters<
|
||||
typeof createBuildNumericShortcutRuntimeMainDepsHandler
|
||||
>[0];
|
||||
type NumericSessionOptions = Omit<
|
||||
Parameters<typeof createNumericShortcutSessionRuntimeHandlers>[0],
|
||||
'multiCopySession' | 'mineSentenceSession'
|
||||
>;
|
||||
type OverlayShortcutsMainDeps = Parameters<
|
||||
typeof createOverlayShortcutsRuntimeHandlers
|
||||
>[0]['overlayShortcutsRuntimeMainDeps'];
|
||||
|
||||
export type ShortcutsRuntimeComposerOptions = ComposerInputs<{
|
||||
globalShortcuts: GlobalShortcutsOptions;
|
||||
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDeps;
|
||||
numericSessions: NumericSessionOptions;
|
||||
overlayShortcutsRuntimeMainDeps: OverlayShortcutsMainDeps;
|
||||
}>;
|
||||
|
||||
export type ShortcutsRuntimeComposerResult = ComposerOutputs<
|
||||
ReturnType<typeof createGlobalShortcutsRuntimeHandlers> &
|
||||
ReturnType<typeof createNumericShortcutSessionRuntimeHandlers> &
|
||||
ReturnType<typeof createOverlayShortcutsRuntimeHandlers>
|
||||
>;
|
||||
|
||||
export function composeShortcutRuntimes(
|
||||
options: ShortcutsRuntimeComposerOptions,
|
||||
): ShortcutsRuntimeComposerResult {
|
||||
const globalShortcuts = createGlobalShortcutsRuntimeHandlers(options.globalShortcuts);
|
||||
|
||||
const numericShortcutRuntimeMainDeps = createBuildNumericShortcutRuntimeMainDepsHandler(
|
||||
options.numericShortcutRuntimeMainDeps,
|
||||
)();
|
||||
const numericShortcutRuntime = createNumericShortcutRuntime(numericShortcutRuntimeMainDeps);
|
||||
const numericSessions = createNumericShortcutSessionRuntimeHandlers({
|
||||
multiCopySession: numericShortcutRuntime.createSession(),
|
||||
mineSentenceSession: numericShortcutRuntime.createSession(),
|
||||
onMultiCopyDigit: options.numericSessions.onMultiCopyDigit,
|
||||
onMineSentenceDigit: options.numericSessions.onMineSentenceDigit,
|
||||
});
|
||||
|
||||
const overlayShortcuts = createOverlayShortcutsRuntimeHandlers({
|
||||
overlayShortcutsRuntimeMainDeps: options.overlayShortcutsRuntimeMainDeps,
|
||||
});
|
||||
|
||||
return {
|
||||
...globalShortcuts,
|
||||
...numericSessions,
|
||||
...overlayShortcuts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
|
||||
|
||||
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
|
||||
const composed = composeStartupLifecycleHandlers({
|
||||
registerProtocolUrlHandlersMainDeps: {
|
||||
registerOpenUrl: () => {},
|
||||
registerSecondInstance: () => {},
|
||||
handleAnilistSetupProtocolUrl: () => false,
|
||||
findAnilistSetupDeepLinkArgvUrl: () => null,
|
||||
logUnhandledOpenUrl: () => {},
|
||||
logUnhandledSecondInstanceUrl: () => {},
|
||||
},
|
||||
onWillQuitCleanupMainDeps: {
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
flushMpvLog: () => {},
|
||||
getMpvSocket: () => null,
|
||||
getReconnectTimer: () => null,
|
||||
clearReconnectTimerRef: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getImmersionTracker: () => null,
|
||||
clearImmersionTracker: () => {},
|
||||
getAnkiIntegration: () => null,
|
||||
getAnilistSetupWindow: () => null,
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
getAllWindowCount: () => 0,
|
||||
},
|
||||
restoreWindowsOnActivateMainDeps: {
|
||||
createMainWindow: () => {},
|
||||
createInvisibleWindow: () => {},
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
updateInvisibleOverlayVisibility: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.registerProtocolUrlHandlers, 'function');
|
||||
assert.equal(typeof composed.onWillQuitCleanup, 'function');
|
||||
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
|
||||
assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
|
||||
});
|
||||
66
src/main/runtime/composers/startup-lifecycle-composer.ts
Normal file
66
src/main/runtime/composers/startup-lifecycle-composer.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
createOnWillQuitCleanupHandler,
|
||||
createRestoreWindowsOnActivateHandler,
|
||||
createShouldRestoreWindowsOnActivateHandler,
|
||||
} from '../app-lifecycle-actions';
|
||||
import { createBuildOnWillQuitCleanupDepsHandler } from '../app-lifecycle-main-cleanup';
|
||||
import {
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler,
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
|
||||
} from '../app-lifecycle-main-activate';
|
||||
import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
|
||||
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type RegisterProtocolUrlHandlersMainDeps = Parameters<
|
||||
typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
|
||||
>[0];
|
||||
type OnWillQuitCleanupDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
|
||||
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
|
||||
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
|
||||
>[0];
|
||||
type RestoreWindowsOnActivateMainDeps = Parameters<
|
||||
typeof createBuildRestoreWindowsOnActivateMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export type StartupLifecycleComposerOptions = ComposerInputs<{
|
||||
registerProtocolUrlHandlersMainDeps: RegisterProtocolUrlHandlersMainDeps;
|
||||
onWillQuitCleanupMainDeps: OnWillQuitCleanupDeps;
|
||||
shouldRestoreWindowsOnActivateMainDeps: ShouldRestoreWindowsOnActivateMainDeps;
|
||||
restoreWindowsOnActivateMainDeps: RestoreWindowsOnActivateMainDeps;
|
||||
}>;
|
||||
|
||||
export type StartupLifecycleComposerResult = ComposerOutputs<{
|
||||
registerProtocolUrlHandlers: () => void;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
}>;
|
||||
|
||||
export function composeStartupLifecycleHandlers(
|
||||
options: StartupLifecycleComposerOptions,
|
||||
): StartupLifecycleComposerResult {
|
||||
const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
|
||||
options.registerProtocolUrlHandlersMainDeps,
|
||||
)();
|
||||
|
||||
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
|
||||
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
|
||||
);
|
||||
const shouldRestoreWindowsOnActivateHandler = createShouldRestoreWindowsOnActivateHandler(
|
||||
createBuildShouldRestoreWindowsOnActivateMainDepsHandler(
|
||||
options.shouldRestoreWindowsOnActivateMainDeps,
|
||||
)(),
|
||||
);
|
||||
const restoreWindowsOnActivateHandler = createRestoreWindowsOnActivateHandler(
|
||||
createBuildRestoreWindowsOnActivateMainDepsHandler(options.restoreWindowsOnActivateMainDeps)(),
|
||||
);
|
||||
|
||||
return {
|
||||
registerProtocolUrlHandlers: () =>
|
||||
registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
|
||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
|
||||
};
|
||||
}
|
||||
64
src/main/runtime/config-derived.ts
Normal file
64
src/main/runtime/config-derived.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RuntimeOptionsManager } from '../../runtime-options';
|
||||
import type { JimakuApiResponse, JimakuLanguagePreference, ResolvedConfig } from '../../types';
|
||||
import {
|
||||
getInitialInvisibleOverlayVisibility as getInitialInvisibleOverlayVisibilityCore,
|
||||
getJimakuLanguagePreference as getJimakuLanguagePreferenceCore,
|
||||
getJimakuMaxEntryResults as getJimakuMaxEntryResultsCore,
|
||||
isAutoUpdateEnabledRuntime as isAutoUpdateEnabledRuntimeCore,
|
||||
jimakuFetchJson as jimakuFetchJsonCore,
|
||||
resolveJimakuApiKey as resolveJimakuApiKeyCore,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig as shouldAutoInitializeOverlayRuntimeFromConfigCore,
|
||||
shouldBindVisibleOverlayToMpvSubVisibility as shouldBindVisibleOverlayToMpvSubVisibilityCore,
|
||||
} from '../../core/services';
|
||||
|
||||
export type ConfigDerivedRuntimeDeps = {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||
platform: NodeJS.Platform;
|
||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
};
|
||||
|
||||
export function createConfigDerivedRuntime(deps: ConfigDerivedRuntimeDeps): {
|
||||
getInitialInvisibleOverlayVisibility: () => boolean;
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||
isAutoUpdateEnabledRuntime: () => boolean;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
} {
|
||||
return {
|
||||
getInitialInvisibleOverlayVisibility: () =>
|
||||
getInitialInvisibleOverlayVisibilityCore(deps.getResolvedConfig(), deps.platform),
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
shouldAutoInitializeOverlayRuntimeFromConfigCore(deps.getResolvedConfig()),
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () =>
|
||||
shouldBindVisibleOverlayToMpvSubVisibilityCore(deps.getResolvedConfig()),
|
||||
isAutoUpdateEnabledRuntime: () =>
|
||||
isAutoUpdateEnabledRuntimeCore(deps.getResolvedConfig(), deps.getRuntimeOptionsManager()),
|
||||
getJimakuLanguagePreference: () =>
|
||||
getJimakuLanguagePreferenceCore(
|
||||
() => deps.getResolvedConfig(),
|
||||
deps.defaultJimakuLanguagePreference,
|
||||
),
|
||||
getJimakuMaxEntryResults: () =>
|
||||
getJimakuMaxEntryResultsCore(() => deps.getResolvedConfig(), deps.defaultJimakuMaxEntryResults),
|
||||
resolveJimakuApiKey: () => resolveJimakuApiKeyCore(() => deps.getResolvedConfig()),
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined> = {},
|
||||
): Promise<JimakuApiResponse<T>> =>
|
||||
jimakuFetchJsonCore<T>(endpoint, query, {
|
||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||
defaultBaseUrl: deps.defaultJimakuApiBaseUrl,
|
||||
defaultMaxEntryResults: deps.defaultJimakuMaxEntryResults,
|
||||
defaultLanguagePreference: deps.defaultJimakuLanguagePreference,
|
||||
}),
|
||||
};
|
||||
}
|
||||
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal file
81
src/main/runtime/config-hot-reload-handlers.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import {
|
||||
buildRestartRequiredConfigMessage,
|
||||
createConfigHotReloadAppliedHandler,
|
||||
createConfigHotReloadMessageHandler,
|
||||
} from './config-hot-reload-handlers';
|
||||
|
||||
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
ankiPatches.push({ enabled: patch.ai.enabled });
|
||||
},
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('set:keybindings'));
|
||||
assert.ok(calls.includes('refresh:shortcuts'));
|
||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: () => calls.push('anki:patch'),
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['set:keybindings']);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
||||
const calls: string[] = [];
|
||||
const handleMessage = createConfigHotReloadMessageHandler({
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
});
|
||||
|
||||
handleMessage('Config reload failed');
|
||||
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
|
||||
});
|
||||
|
||||
test('buildRestartRequiredConfigMessage formats changed fields', () => {
|
||||
assert.equal(
|
||||
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
|
||||
'Config updated; restart required for: websocket, subtitleStyle',
|
||||
);
|
||||
});
|
||||
73
src/main/runtime/config-hot-reload-handlers.ts
Normal file
73
src/main/runtime/config-hot-reload-handlers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import { resolveKeybindings } from '../../core/utils';
|
||||
import { DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
};
|
||||
|
||||
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
if (!config.subtitleStyle) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||
return {
|
||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
|
||||
deps.setSecondarySubMode(payload.secondarySubMode);
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai });
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
|
||||
return (message: string): void => {
|
||||
deps.showMpvOsd(message);
|
||||
deps.showDesktopNotification('SubMiner', { body: message });
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestartRequiredConfigMessage(fields: string[]): string {
|
||||
return `Config updated; restart required for: ${fields.join(', ')}`;
|
||||
}
|
||||
148
src/main/runtime/config-hot-reload-main-deps.test.ts
Normal file
148
src/main/runtime/config-hot-reload-main-deps.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import {
|
||||
createBuildConfigHotReloadMessageMainDepsHandler,
|
||||
createBuildConfigHotReloadAppliedMainDepsHandler,
|
||||
createBuildConfigHotReloadRuntimeMainDepsHandler,
|
||||
createBuildWatchConfigPathMainDepsHandler,
|
||||
createWatchConfigPathHandler,
|
||||
} from './config-hot-reload-main-deps';
|
||||
|
||||
test('watch config path handler watches file directly when config exists', () => {
|
||||
const calls: string[] = [];
|
||||
const watchConfigPath = createWatchConfigPathHandler({
|
||||
fileExists: () => true,
|
||||
dirname: (path) => path.split('/').slice(0, -1).join('/'),
|
||||
watchPath: (targetPath, nextListener) => {
|
||||
calls.push(`watch:${targetPath}`);
|
||||
nextListener('change', 'ignored');
|
||||
return { close: () => calls.push('close') };
|
||||
},
|
||||
});
|
||||
|
||||
const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
|
||||
watcher.close();
|
||||
assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']);
|
||||
});
|
||||
|
||||
test('watch config path handler filters directory events to config files only', () => {
|
||||
const calls: string[] = [];
|
||||
const watchConfigPath = createWatchConfigPathHandler({
|
||||
fileExists: () => false,
|
||||
dirname: (path) => path.split('/').slice(0, -1).join('/'),
|
||||
watchPath: (_targetPath, nextListener) => {
|
||||
nextListener('change', 'foo.txt');
|
||||
nextListener('change', 'config.json');
|
||||
nextListener('change', 'config.jsonc');
|
||||
nextListener('change', null);
|
||||
return { close: () => {} };
|
||||
},
|
||||
});
|
||||
|
||||
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
|
||||
assert.deepEqual(calls, ['change', 'change', 'change']);
|
||||
});
|
||||
|
||||
test('watch config path main deps builder maps filesystem callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildWatchConfigPathMainDepsHandler({
|
||||
fileExists: () => true,
|
||||
dirname: (targetPath) => {
|
||||
calls.push(`dirname:${targetPath}`);
|
||||
return '/tmp';
|
||||
},
|
||||
watchPath: (targetPath, listener) => {
|
||||
calls.push(`watch:${targetPath}`);
|
||||
listener('change', 'config.jsonc');
|
||||
return { close: () => calls.push('close') };
|
||||
},
|
||||
})();
|
||||
|
||||
assert.equal(deps.fileExists('/tmp/config.jsonc'), true);
|
||||
assert.equal(deps.dirname('/tmp/config.jsonc'), '/tmp');
|
||||
const watcher = deps.watchPath('/tmp/config.jsonc', () => calls.push('listener'));
|
||||
watcher.close();
|
||||
assert.deepEqual(calls, [
|
||||
'dirname:/tmp/config.jsonc',
|
||||
'watch:/tmp/config.jsonc',
|
||||
'listener',
|
||||
'close',
|
||||
]);
|
||||
});
|
||||
|
||||
test('config hot reload message main deps builder maps notifications', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConfigHotReloadMessageMainDepsHandler({
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showDesktopNotification: (title) => calls.push(`notify:${title}`),
|
||||
})();
|
||||
|
||||
deps.showMpvOsd('updated');
|
||||
deps.showDesktopNotification('SubMiner', { body: 'updated' });
|
||||
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
|
||||
});
|
||||
|
||||
test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
setKeybindings: () => calls.push('keybindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
|
||||
})();
|
||||
|
||||
deps.setKeybindings([]);
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
deps.setSecondarySubMode('hover');
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: {} as never });
|
||||
assert.deepEqual(calls, [
|
||||
'keybindings',
|
||||
'refresh-shortcuts',
|
||||
'set-secondary',
|
||||
'broadcast:config:hot-reload',
|
||||
'apply-anki',
|
||||
]);
|
||||
});
|
||||
|
||||
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({
|
||||
getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig),
|
||||
reloadConfigStrict: () =>
|
||||
({
|
||||
ok: true,
|
||||
config: { id: 1 } as never as ResolvedConfig,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}) as ReloadConfigStrictResult,
|
||||
watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }),
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as never;
|
||||
},
|
||||
clearTimeout: () => calls.push('clear-timeout'),
|
||||
debounceMs: 250,
|
||||
onHotReloadApplied: () => calls.push('hot-reload'),
|
||||
onRestartRequired: () => calls.push('restart-required'),
|
||||
onInvalidConfig: () => calls.push('invalid-config'),
|
||||
onValidationWarnings: () => calls.push('validation-warnings'),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getCurrentConfig(), { id: 1 });
|
||||
assert.deepEqual(deps.reloadConfigStrict(), {
|
||||
ok: true,
|
||||
config: { id: 1 },
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
});
|
||||
assert.equal(deps.debounceMs, 250);
|
||||
deps.onHotReloadApplied({} as never, {} as never);
|
||||
deps.onRestartRequired([]);
|
||||
deps.onInvalidConfig('bad');
|
||||
deps.onValidationWarnings('/tmp/config.jsonc', []);
|
||||
assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']);
|
||||
});
|
||||
102
src/main/runtime/config-hot-reload-main-deps.ts
Normal file
102
src/main/runtime/config-hot-reload-main-deps.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
ConfigHotReloadDiff,
|
||||
ConfigHotReloadRuntimeDeps,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
import type { createConfigHotReloadMessageHandler } from './config-hot-reload-handlers';
|
||||
|
||||
type ConfigWatchListener = (eventType: string, filename: string | null) => void;
|
||||
|
||||
export function createWatchConfigPathHandler(deps: {
|
||||
fileExists: (path: string) => boolean;
|
||||
dirname: (path: string) => string;
|
||||
watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void };
|
||||
}) {
|
||||
return (configPath: string, onChange: () => void): { close: () => void } => {
|
||||
const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath);
|
||||
const watcher = deps.watchPath(watchTarget, (_eventType, filename) => {
|
||||
if (watchTarget === configPath) {
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized =
|
||||
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
|
||||
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
return {
|
||||
close: () => watcher.close(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type WatchConfigPathMainDeps = Parameters<typeof createWatchConfigPathHandler>[0];
|
||||
type ConfigHotReloadMessageMainDeps = Parameters<typeof createConfigHotReloadMessageHandler>[0];
|
||||
|
||||
export function createBuildWatchConfigPathMainDepsHandler(deps: WatchConfigPathMainDeps) {
|
||||
return (): WatchConfigPathMainDeps => ({
|
||||
fileExists: (targetPath: string) => deps.fileExists(targetPath),
|
||||
dirname: (targetPath: string) => deps.dirname(targetPath),
|
||||
watchPath: (targetPath: string, listener: ConfigWatchListener) =>
|
||||
deps.watchPath(targetPath, listener),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
deps: ConfigHotReloadMessageMainDeps,
|
||||
) {
|
||||
return (): ConfigHotReloadMessageMainDeps => ({
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
deps.setKeybindings(keybindings),
|
||||
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
deps.broadcastToOverlayWindows(channel, payload),
|
||||
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) =>
|
||||
deps.applyAnkiRuntimeConfigPatch(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: {
|
||||
getCurrentConfig: () => ResolvedConfig;
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath'];
|
||||
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
||||
clearTimeout: (timeout: NodeJS.Timeout) => void;
|
||||
debounceMs: number;
|
||||
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
|
||||
onRestartRequired: (fields: string[]) => void;
|
||||
onInvalidConfig: (message: string) => void;
|
||||
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
|
||||
}) {
|
||||
return (): ConfigHotReloadRuntimeDeps => ({
|
||||
getCurrentConfig: () => deps.getCurrentConfig(),
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange),
|
||||
setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout),
|
||||
debounceMs: deps.debounceMs,
|
||||
onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config),
|
||||
onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields),
|
||||
onInvalidConfig: (message: string) => deps.onInvalidConfig(message),
|
||||
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) =>
|
||||
deps.onValidationWarnings(configPath, warnings),
|
||||
});
|
||||
}
|
||||
80
src/main/runtime/dictionary-runtime-main-deps.test.ts
Normal file
80
src/main/runtime/dictionary-runtime-main-deps.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBuildDictionaryRootsMainHandler,
|
||||
createBuildFrequencyDictionaryRootsMainHandler,
|
||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
|
||||
createBuildJlptDictionaryRuntimeMainDepsHandler,
|
||||
} from './dictionary-runtime-main-deps';
|
||||
|
||||
test('dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildDictionaryRootsMainHandler({
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
userDataPath: '/Users/a/.config/SubMiner',
|
||||
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
homeDir: '/Users/a',
|
||||
cwd: '/repo',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.length, 11);
|
||||
assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab');
|
||||
assert.equal(roots[10], '/repo');
|
||||
});
|
||||
|
||||
test('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({
|
||||
isJlptEnabled: () => true,
|
||||
getDictionaryRoots: () => ['/root/a'],
|
||||
getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`),
|
||||
setJlptLevelLookup: () => calls.push('set-lookup'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isJlptEnabled(), true);
|
||||
assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']);
|
||||
deps.setJlptLevelLookup(() => null);
|
||||
deps.log('loaded');
|
||||
assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']);
|
||||
});
|
||||
|
||||
test('frequency dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
userDataPath: '/Users/a/.config/SubMiner',
|
||||
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
homeDir: '/Users/a',
|
||||
cwd: '/repo',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.length, 15);
|
||||
assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global');
|
||||
assert.equal(roots[14], '/repo');
|
||||
});
|
||||
|
||||
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
||||
isFrequencyDictionaryEnabled: () => true,
|
||||
getDictionaryRoots: () => ['/root/a', ''],
|
||||
getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [
|
||||
...getDictionaryRoots().map((path) => `${path}/freq`),
|
||||
getSourcePath() || '',
|
||||
],
|
||||
getSourcePath: () => '/custom/freq.json',
|
||||
setFrequencyRankLookup: () => calls.push('set-rank'),
|
||||
logInfo: (message) => calls.push(`log:${message}`),
|
||||
})();
|
||||
|
||||
assert.equal(deps.isFrequencyDictionaryEnabled(), true);
|
||||
assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']);
|
||||
deps.setFrequencyRankLookup(() => null);
|
||||
deps.log('loaded');
|
||||
assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user