fix(runtime): avoid loading disabled integrations

This commit is contained in:
2026-02-27 21:21:26 -08:00
parent 192672c051
commit c5d4a67f39
4 changed files with 287 additions and 31 deletions

View File

@@ -209,6 +209,24 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes',
}
});
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
const integration = new AnkiIntegration(
{
enabled: true,
proxy: {
enabled: false,
},
} as never,
{} as never,
{} as never,
);
const privateState = integration as unknown as {
proxyServer: unknown | null;
};
assert.equal(privateState.proxyServer, null);
});
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator();

View File

@@ -41,6 +41,7 @@ import {
} from './anki-integration/ui-feedback';
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
import { PollingRunner } from './anki-integration/polling';
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
import { CardCreationService } from './anki-integration/card-creation';
import { FieldGroupingService } from './anki-integration/field-grouping';
@@ -63,6 +64,8 @@ export class AnkiIntegration {
private timingTracker: SubtitleTimingTracker;
private config: AnkiConnectConfig;
private pollingRunner!: PollingRunner;
private proxyServer: AnkiConnectProxyServer | null = null;
private started = false;
private previousNoteIds = new Set<number>();
private mpvClient: MpvClient;
private osdCallback: ((text: string) => void) | null = null;
@@ -131,13 +134,46 @@ export class AnkiIntegration {
}
private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig {
const resolvedUrl =
typeof config.url === 'string' && config.url.trim().length > 0
? config.url.trim()
: DEFAULT_ANKI_CONNECT_CONFIG.url;
const proxySource =
config.proxy && typeof config.proxy === 'object'
? (config.proxy as NonNullable<AnkiConnectConfig['proxy']>)
: {};
const normalizedProxyPort =
typeof proxySource.port === 'number' &&
Number.isInteger(proxySource.port) &&
proxySource.port >= 1 &&
proxySource.port <= 65535
? proxySource.port
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port;
const normalizedProxyHost =
typeof proxySource.host === 'string' && proxySource.host.trim().length > 0
? proxySource.host.trim()
: DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host;
const normalizedProxyUpstreamUrl =
typeof proxySource.upstreamUrl === 'string' && proxySource.upstreamUrl.trim().length > 0
? proxySource.upstreamUrl.trim()
: resolvedUrl;
return {
...DEFAULT_ANKI_CONNECT_CONFIG,
...config,
url: resolvedUrl,
fields: {
...DEFAULT_ANKI_CONNECT_CONFIG.fields,
...(config.fields ?? {}),
},
proxy: {
...DEFAULT_ANKI_CONNECT_CONFIG.proxy,
...(config.proxy ?? {}),
enabled: proxySource.enabled === true,
host: normalizedProxyHost,
port: normalizedProxyPort,
upstreamUrl: normalizedProxyUpstreamUrl,
},
ai: {
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...(config.openRouter ?? {}),
@@ -202,6 +238,24 @@ export class AnkiIntegration {
});
}
private createProxyServer(): AnkiConnectProxyServer {
const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
return new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
processNewCard: (noteId: number) => this.processNewCard(noteId),
logInfo: (message, ...args) => log.info(message, ...args),
logWarn: (message, ...args) => log.warn(message, ...args),
logError: (message, ...args) => log.error(message, ...args),
});
}
private getOrCreateProxyServer(): AnkiConnectProxyServer {
if (!this.proxyServer) {
this.proxyServer = this.createProxyServer();
}
return this.proxyServer;
}
private createCardCreationService(): CardCreationService {
return new CardCreationService({
getConfig: () => this.config,
@@ -499,19 +553,63 @@ export class AnkiIntegration {
};
}
start(): void {
if (this.pollingRunner.isRunning) {
this.stop();
private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean {
return config.proxy?.enabled === true;
}
private getTransportConfigKey(config: AnkiConnectConfig = this.config): string {
if (this.isProxyTransportEnabled(config)) {
return [
'proxy',
config.proxy?.host ?? '',
String(config.proxy?.port ?? ''),
config.proxy?.upstreamUrl ?? '',
].join(':');
}
return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join(
':',
);
}
private startTransport(): void {
if (this.isProxyTransportEnabled()) {
const proxyHost = this.config.proxy?.host ?? '127.0.0.1';
const proxyPort = this.config.proxy?.port ?? 8766;
const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? '';
this.getOrCreateProxyServer().start({
host: proxyHost,
port: proxyPort,
upstreamUrl,
});
log.info(
`Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`,
);
return;
}
log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate);
this.startKnownWordCacheLifecycle();
this.pollingRunner.start();
}
stop(): void {
private stopTransport(): void {
this.pollingRunner.stop();
this.proxyServer?.stop();
}
start(): void {
if (this.started) {
this.stop();
}
this.startKnownWordCacheLifecycle();
this.startTransport();
this.started = true;
}
stop(): void {
this.stopTransport();
this.stopKnownWordCacheLifecycle();
this.started = false;
log.info('Stopped AnkiConnect integration');
}
@@ -1062,8 +1160,9 @@ export class AnkiIntegration {
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
const previousPollingRate = this.config.pollingRate;
this.config = {
const previousTransportKey = this.getTransportConfigKey(this.config);
const mergedConfig: AnkiConnectConfig = {
...this.config,
...patch,
nPlusOne:
@@ -1083,6 +1182,8 @@ export class AnkiIntegration {
patch.behavior !== undefined
? { ...this.config.behavior, ...patch.behavior }
: this.config.behavior,
proxy:
patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy,
metadata:
patch.metadata !== undefined
? { ...this.config.metadata, ...patch.metadata }
@@ -1096,6 +1197,7 @@ export class AnkiIntegration {
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
};
this.config = this.normalizeConfig(mergedConfig);
if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
this.stopKnownWordCacheLifecycle();
@@ -1104,12 +1206,10 @@ export class AnkiIntegration {
this.startKnownWordCacheLifecycle();
}
if (
patch.pollingRate !== undefined &&
previousPollingRate !== this.config.pollingRate &&
this.pollingRunner.isRunning
) {
this.pollingRunner.start();
const nextTransportKey = this.getTransportConfigKey(this.config);
if (this.started && previousTransportKey !== nextTransportKey) {
this.stopTransport();
this.startTransport();
}
}

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { initializeOverlayRuntime } from './overlay-runtime-init';
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
let createdIntegrations = 0;
let startedIntegrations = 0;
let setIntegrationCalls = 0;
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
getResolvedConfig: () => ({
ankiConnect: { enabled: false } as never,
}),
getSubtitleTimingTracker: () => ({}),
getMpvClient: () => ({
send: () => {},
}),
getRuntimeOptionsManager: () => ({
getEffectiveAnkiConnectConfig: (config) => config as never,
}),
createAnkiIntegration: () => {
createdIntegrations += 1;
return {
start: () => {
startedIntegrations += 1;
},
};
},
setAnkiIntegration: () => {
setIntegrationCalls += 1;
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => 'auto',
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
assert.equal(createdIntegrations, 0);
assert.equal(startedIntegrations, 0);
assert.equal(setIntegrationCalls, 0);
});
test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => {
let createdIntegrations = 0;
let startedIntegrations = 0;
let setIntegrationCalls = 0;
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
getResolvedConfig: () => ({
ankiConnect: { enabled: true } as never,
}),
getSubtitleTimingTracker: () => ({}),
getMpvClient: () => ({
send: () => {},
}),
getRuntimeOptionsManager: () => ({
getEffectiveAnkiConnectConfig: (config) => config as never,
}),
createAnkiIntegration: (args) => {
createdIntegrations += 1;
assert.equal(args.config.enabled, true);
return {
start: () => {
startedIntegrations += 1;
},
};
},
setAnkiIntegration: () => {
setIntegrationCalls += 1;
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => 'manual',
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
assert.equal(createdIntegrations, 1);
assert.equal(startedIntegrations, 1);
assert.equal(setIntegrationCalls, 1);
});

View File

@@ -1,5 +1,4 @@
import { BrowserWindow } from 'electron';
import { AnkiIntegration } from '../../anki-integration';
import { BaseWindowTracker, createWindowTracker } from '../../window-trackers';
import {
AnkiConnectConfig,
@@ -8,6 +7,40 @@ import {
WindowGeometry,
} from '../../types';
type AnkiIntegrationLike = {
start: () => void;
};
type CreateAnkiIntegrationArgs = {
config: AnkiConnectConfig;
subtitleTimingTracker: unknown;
mpvClient: { send?: (payload: { command: string[] }) => void };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
knownWordCacheStatePath: string;
};
function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike {
const { AnkiIntegration } = require('../../anki-integration') as typeof import('../../anki-integration');
return new AnkiIntegration(
args.config,
args.subtitleTimingTracker as never,
args.mpvClient as never,
(text: string) => {
if (args.mpvClient && typeof args.mpvClient.send === 'function') {
args.mpvClient.send({
command: ['show-text', text, '3000'],
});
}
},
args.showDesktopNotification,
args.createFieldGroupingCallback(),
args.knownWordCacheStatePath,
);
}
export function initializeOverlayRuntime(options: {
backendOverride: string | null;
createMainWindow: () => void;
@@ -18,6 +51,10 @@ export function initializeOverlayRuntime(options: {
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
@@ -33,11 +70,13 @@ export function initializeOverlayRuntime(options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): void {
options.createMainWindow();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath());
const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker;
const windowTracker = createWindowTrackerHandler(options.backendOverride, options.getMpvSocketPath());
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
@@ -63,25 +102,24 @@ export function initializeOverlayRuntime(options: {
const mpvClient = options.getMpvClient();
const runtimeOptionsManager = options.getRuntimeOptionsManager();
if (config.ankiConnect && subtitleTimingTracker && mpvClient && runtimeOptionsManager) {
if (
config.ankiConnect?.enabled === true &&
subtitleTimingTracker &&
mpvClient &&
runtimeOptionsManager
) {
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
config.ankiConnect,
);
const integration = new AnkiIntegration(
effectiveAnkiConfig,
subtitleTimingTracker as never,
mpvClient as never,
(text: string) => {
if (mpvClient && typeof mpvClient.send === 'function') {
mpvClient.send({
command: ['show-text', text, '3000'],
});
}
},
options.showDesktopNotification,
options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(),
);
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
const integration = createAnkiIntegration({
config: effectiveAnkiConfig,
subtitleTimingTracker,
mpvClient,
showDesktopNotification: options.showDesktopNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
});
integration.start();
options.setAnkiIntegration(integration);
}