From 07cedabfe317a8be5a8aadbc480887c8df830f96 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 00:47:01 -0800 Subject: [PATCH] fix(config): improve startup validation and config error reporting --- src/config/config.test.ts | 164 +++++++++ src/config/service.ts | 517 +++++++++++++++++++++++----- src/core/services/app-ready.test.ts | 89 +++++ src/core/services/startup.ts | 56 ++- src/main.ts | 65 +++- src/main/app-lifecycle.ts | 2 + 6 files changed, 803 insertions(+), 90 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 87027c9..3941489 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -368,6 +368,29 @@ test('falls back for invalid logging.level and reports warning', () => { assert.ok(warnings.some((warning) => warning.path === 'logging.level')); }); +test('warns and ignores unknown top-level config keys', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "websocket": { + "port": 7788 + }, + "unknownFeatureFlag": { + "enabled": true + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.websocket.port, 7788); + assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag')); +}); + test('parses invisible overlay config and new global shortcuts', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -634,6 +657,147 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { ); }); +test('warns when ankiConnect.openRouter is used and migrates to ai', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "openRouter": { + "model": "openrouter/test-model" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal((config.ankiConnect.ai as Record).model, 'openrouter/test-model'); + assert.ok( + warnings.some( + (warning) => + warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'), + ), + ); +}); + +test('falls back and warns when legacy ankiConnect migration values are invalid', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "audioField": 123, + "generateAudio": "yes", + "imageType": "gif", + "imageQuality": -1, + "mediaInsertMode": "middle", + "notificationType": "toast" + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.fields.audio, DEFAULT_CONFIG.ankiConnect.fields.audio); + assert.equal( + config.ankiConnect.media.generateAudio, + DEFAULT_CONFIG.ankiConnect.media.generateAudio, + ); + assert.equal(config.ankiConnect.media.imageType, DEFAULT_CONFIG.ankiConnect.media.imageType); + assert.equal( + config.ankiConnect.media.imageQuality, + DEFAULT_CONFIG.ankiConnect.media.imageQuality, + ); + assert.equal( + config.ankiConnect.behavior.mediaInsertMode, + DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode, + ); + assert.equal( + config.ankiConnect.behavior.notificationType, + DEFAULT_CONFIG.ankiConnect.behavior.notificationType, + ); + + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.audioField')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.generateAudio')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageType')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageQuality')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.mediaInsertMode')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.notificationType')); +}); + +test('maps valid legacy ankiConnect values to equivalent modern config', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "audioField": "AudioLegacy", + "imageField": "ImageLegacy", + "generateAudio": false, + "imageType": "avif", + "imageFormat": "webp", + "imageQuality": 88, + "mediaInsertMode": "prepend", + "notificationType": "both", + "autoUpdateNewCards": false + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.ankiConnect.fields.audio, 'AudioLegacy'); + assert.equal(config.ankiConnect.fields.image, 'ImageLegacy'); + assert.equal(config.ankiConnect.media.generateAudio, false); + assert.equal(config.ankiConnect.media.imageType, 'avif'); + assert.equal(config.ankiConnect.media.imageFormat, 'webp'); + assert.equal(config.ankiConnect.media.imageQuality, 88); + assert.equal(config.ankiConnect.behavior.mediaInsertMode, 'prepend'); + assert.equal(config.ankiConnect.behavior.notificationType, 'both'); + assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, false); +}); + +test('ignores deprecated isLapis sentence-card field overrides', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences", + "sentenceCardSentenceField": "CustomSentence", + "sentenceCardAudioField": "CustomAudio" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + const lapisConfig = config.ankiConnect.isLapis as Record; + assert.equal(lapisConfig.sentenceCardSentenceField, undefined); + assert.equal(lapisConfig.sentenceCardAudioField, undefined); + assert.ok( + warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardSentenceField'), + ); + assert.ok( + warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardAudioField'), + ); +}); + test('accepts valid ankiConnect n+1 deck list', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/service.ts b/src/config/service.ts index 145d554..9b5a0f1 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -212,6 +212,12 @@ export class ConfigService { }; const src = isObject(raw) ? raw : {}; + const knownTopLevelKeys = new Set(Object.keys(resolved)); + for (const key of Object.keys(src)) { + if (!knownTopLevelKeys.has(key)) { + warn(key, src[key], undefined, 'Unknown top-level config key; ignored.'); + } + } if (isObject(src.texthooker)) { const openBrowser = asBoolean(src.texthooker.openBrowser); @@ -849,14 +855,57 @@ export class ConfigService { if (isObject(src.ankiConnect)) { const ac = src.ankiConnect; const behavior = isObject(ac.behavior) ? (ac.behavior as Record) : {}; + const fields = isObject(ac.fields) ? (ac.fields as Record) : {}; + const media = isObject(ac.media) ? (ac.media as Record) : {}; + const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; + const legacyKeys = new Set([ + 'audioField', + 'imageField', + 'sentenceField', + 'miscInfoField', + 'miscInfoPattern', + 'generateAudio', + 'generateImage', + 'imageType', + 'imageFormat', + 'imageQuality', + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedFps', + 'animatedMaxWidth', + 'animatedMaxHeight', + 'animatedCrf', + 'audioPadding', + 'fallbackDuration', + 'maxMediaDuration', + 'overwriteAudio', + 'overwriteImage', + 'mediaInsertMode', + 'highlightWord', + 'notificationType', + 'autoUpdateNewCards', + ]); + + if (ac.openRouter !== undefined) { + warn( + 'ankiConnect.openRouter', + ac.openRouter, + resolved.ankiConnect.ai, + 'Deprecated key; use ankiConnect.ai instead.', + ); + } + const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = ac as Record; + const ankiConnectWithoutLegacy = Object.fromEntries( + Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), + ); resolved.ankiConnect = { ...resolved.ankiConnect, - ...(isObject(ankiConnectWithoutNPlusOne) - ? (ankiConnectWithoutNPlusOne as Partial) + ...(isObject(ankiConnectWithoutLegacy) + ? (ankiConnectWithoutLegacy as Partial) : {}), fields: { ...resolved.ankiConnect.fields, @@ -884,7 +933,6 @@ export class ConfigService { }, isLapis: { ...resolved.ankiConnect.isLapis, - ...(isObject(ac.isLapis) ? (ac.isLapis as ResolvedConfig['ankiConnect']['isLapis']) : {}), }, isKiku: { ...resolved.ankiConnect.isKiku, @@ -892,6 +940,52 @@ export class ConfigService { }, }; + if (isObject(ac.isLapis)) { + const lapisEnabled = asBoolean(ac.isLapis.enabled); + if (lapisEnabled !== undefined) { + resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ac.isLapis.enabled !== undefined) { + warn( + 'ankiConnect.isLapis.enabled', + ac.isLapis.enabled, + resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ac.isLapis.sentenceCardModel !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardModel', + ac.isLapis.sentenceCardModel, + resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ac.isLapis.sentenceCardSentenceField !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ac.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ac.isLapis.sentenceCardAudioField !== undefined) { + warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ac.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ac.isLapis !== undefined) { + warn('ankiConnect.isLapis', ac.isLapis, resolved.ankiConnect.isLapis, 'Expected object.'); + } + if (Array.isArray(ac.tags)) { const normalizedTags = ac.tags .filter((entry): entry is string => typeof entry === 'string') @@ -919,89 +1013,344 @@ export class ConfigService { } const legacy = ac as Record; - const mapLegacy = (key: string, apply: (value: unknown) => void): void => { - if (legacy[key] !== undefined) apply(legacy[key]); + const hasOwn = (obj: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(obj, key); + const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) { + return undefined; + } + return parsed; + }; + const asPositiveInteger = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asPositiveNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed <= 0) { + return undefined; + } + return parsed; + }; + const asNonNegativeNumber = (value: unknown): number | undefined => { + const parsed = asNumber(value); + if (parsed === undefined || parsed < 0) { + return undefined; + } + return parsed; + }; + const asImageType = (value: unknown): 'static' | 'avif' | undefined => { + return value === 'static' || value === 'avif' ? value : undefined; + }; + const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => { + return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined; + }; + const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => { + return value === 'append' || value === 'prepend' ? value : undefined; + }; + const asNotificationType = ( + value: unknown, + ): 'osd' | 'system' | 'both' | 'none' | undefined => { + return value === 'osd' || value === 'system' || value === 'both' || value === 'none' + ? value + : undefined; + }; + const mapLegacy = ( + key: string, + parse: (value: unknown) => T | undefined, + apply: (value: T) => void, + fallback: unknown, + message: string, + ): void => { + const value = legacy[key]; + if (value === undefined) return; + const parsed = parse(value); + if (parsed === undefined) { + warn(`ankiConnect.${key}`, value, fallback, message); + return; + } + apply(parsed); }; - mapLegacy('audioField', (value) => { - resolved.ankiConnect.fields.audio = value as string; - }); - mapLegacy('imageField', (value) => { - resolved.ankiConnect.fields.image = value as string; - }); - mapLegacy('sentenceField', (value) => { - resolved.ankiConnect.fields.sentence = value as string; - }); - mapLegacy('miscInfoField', (value) => { - resolved.ankiConnect.fields.miscInfo = value as string; - }); - mapLegacy('miscInfoPattern', (value) => { - resolved.ankiConnect.metadata.pattern = value as string; - }); - mapLegacy('generateAudio', (value) => { - resolved.ankiConnect.media.generateAudio = value as boolean; - }); - mapLegacy('generateImage', (value) => { - resolved.ankiConnect.media.generateImage = value as boolean; - }); - mapLegacy('imageType', (value) => { - resolved.ankiConnect.media.imageType = value as 'static' | 'avif'; - }); - mapLegacy('imageFormat', (value) => { - resolved.ankiConnect.media.imageFormat = value as 'jpg' | 'png' | 'webp'; - }); - mapLegacy('imageQuality', (value) => { - resolved.ankiConnect.media.imageQuality = value as number; - }); - mapLegacy('imageMaxWidth', (value) => { - resolved.ankiConnect.media.imageMaxWidth = value as number; - }); - mapLegacy('imageMaxHeight', (value) => { - resolved.ankiConnect.media.imageMaxHeight = value as number; - }); - mapLegacy('animatedFps', (value) => { - resolved.ankiConnect.media.animatedFps = value as number; - }); - mapLegacy('animatedMaxWidth', (value) => { - resolved.ankiConnect.media.animatedMaxWidth = value as number; - }); - mapLegacy('animatedMaxHeight', (value) => { - resolved.ankiConnect.media.animatedMaxHeight = value as number; - }); - mapLegacy('animatedCrf', (value) => { - resolved.ankiConnect.media.animatedCrf = value as number; - }); - mapLegacy('audioPadding', (value) => { - resolved.ankiConnect.media.audioPadding = value as number; - }); - mapLegacy('fallbackDuration', (value) => { - resolved.ankiConnect.media.fallbackDuration = value as number; - }); - mapLegacy('maxMediaDuration', (value) => { - resolved.ankiConnect.media.maxMediaDuration = value as number; - }); - mapLegacy('overwriteAudio', (value) => { - resolved.ankiConnect.behavior.overwriteAudio = value as boolean; - }); - mapLegacy('overwriteImage', (value) => { - resolved.ankiConnect.behavior.overwriteImage = value as boolean; - }); - mapLegacy('mediaInsertMode', (value) => { - resolved.ankiConnect.behavior.mediaInsertMode = value as 'append' | 'prepend'; - }); - mapLegacy('highlightWord', (value) => { - resolved.ankiConnect.behavior.highlightWord = value as boolean; - }); - mapLegacy('notificationType', (value) => { - resolved.ankiConnect.behavior.notificationType = value as - | 'osd' - | 'system' - | 'both' - | 'none'; - }); - mapLegacy('autoUpdateNewCards', (value) => { - resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean; - }); + if (!hasOwn(fields, 'audio')) { + mapLegacy( + 'audioField', + asString, + (value) => { + resolved.ankiConnect.fields.audio = value; + }, + resolved.ankiConnect.fields.audio, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'image')) { + mapLegacy( + 'imageField', + asString, + (value) => { + resolved.ankiConnect.fields.image = value; + }, + resolved.ankiConnect.fields.image, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'sentence')) { + mapLegacy( + 'sentenceField', + asString, + (value) => { + resolved.ankiConnect.fields.sentence = value; + }, + resolved.ankiConnect.fields.sentence, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'miscInfo')) { + mapLegacy( + 'miscInfoField', + asString, + (value) => { + resolved.ankiConnect.fields.miscInfo = value; + }, + resolved.ankiConnect.fields.miscInfo, + 'Expected string.', + ); + } + if (!hasOwn(metadata, 'pattern')) { + mapLegacy( + 'miscInfoPattern', + asString, + (value) => { + resolved.ankiConnect.metadata.pattern = value; + }, + resolved.ankiConnect.metadata.pattern, + 'Expected string.', + ); + } + if (!hasOwn(media, 'generateAudio')) { + mapLegacy( + 'generateAudio', + asBoolean, + (value) => { + resolved.ankiConnect.media.generateAudio = value; + }, + resolved.ankiConnect.media.generateAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'generateImage')) { + mapLegacy( + 'generateImage', + asBoolean, + (value) => { + resolved.ankiConnect.media.generateImage = value; + }, + resolved.ankiConnect.media.generateImage, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'imageType')) { + mapLegacy( + 'imageType', + asImageType, + (value) => { + resolved.ankiConnect.media.imageType = value; + }, + resolved.ankiConnect.media.imageType, + "Expected 'static' or 'avif'.", + ); + } + if (!hasOwn(media, 'imageFormat')) { + mapLegacy( + 'imageFormat', + asImageFormat, + (value) => { + resolved.ankiConnect.media.imageFormat = value; + }, + resolved.ankiConnect.media.imageFormat, + "Expected 'jpg', 'png', or 'webp'.", + ); + } + if (!hasOwn(media, 'imageQuality')) { + mapLegacy( + 'imageQuality', + (value) => asIntegerInRange(value, 1, 100), + (value) => { + resolved.ankiConnect.media.imageQuality = value; + }, + resolved.ankiConnect.media.imageQuality, + 'Expected integer between 1 and 100.', + ); + } + if (!hasOwn(media, 'imageMaxWidth')) { + mapLegacy( + 'imageMaxWidth', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.imageMaxWidth = value; + }, + resolved.ankiConnect.media.imageMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'imageMaxHeight')) { + mapLegacy( + 'imageMaxHeight', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.imageMaxHeight = value; + }, + resolved.ankiConnect.media.imageMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedFps')) { + mapLegacy( + 'animatedFps', + (value) => asIntegerInRange(value, 1, 60), + (value) => { + resolved.ankiConnect.media.animatedFps = value; + }, + resolved.ankiConnect.media.animatedFps, + 'Expected integer between 1 and 60.', + ); + } + if (!hasOwn(media, 'animatedMaxWidth')) { + mapLegacy( + 'animatedMaxWidth', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.animatedMaxWidth = value; + }, + resolved.ankiConnect.media.animatedMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedMaxHeight')) { + mapLegacy( + 'animatedMaxHeight', + asPositiveInteger, + (value) => { + resolved.ankiConnect.media.animatedMaxHeight = value; + }, + resolved.ankiConnect.media.animatedMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedCrf')) { + mapLegacy( + 'animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + (value) => { + resolved.ankiConnect.media.animatedCrf = value; + }, + resolved.ankiConnect.media.animatedCrf, + 'Expected integer between 0 and 63.', + ); + } + if (!hasOwn(media, 'audioPadding')) { + mapLegacy( + 'audioPadding', + asNonNegativeNumber, + (value) => { + resolved.ankiConnect.media.audioPadding = value; + }, + resolved.ankiConnect.media.audioPadding, + 'Expected non-negative number.', + ); + } + if (!hasOwn(media, 'fallbackDuration')) { + mapLegacy( + 'fallbackDuration', + asPositiveNumber, + (value) => { + resolved.ankiConnect.media.fallbackDuration = value; + }, + resolved.ankiConnect.media.fallbackDuration, + 'Expected positive number.', + ); + } + if (!hasOwn(media, 'maxMediaDuration')) { + mapLegacy( + 'maxMediaDuration', + asNonNegativeNumber, + (value) => { + resolved.ankiConnect.media.maxMediaDuration = value; + }, + resolved.ankiConnect.media.maxMediaDuration, + 'Expected non-negative number.', + ); + } + if (!hasOwn(behavior, 'overwriteAudio')) { + mapLegacy( + 'overwriteAudio', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.overwriteAudio = value; + }, + resolved.ankiConnect.behavior.overwriteAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'overwriteImage')) { + mapLegacy( + 'overwriteImage', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.overwriteImage = value; + }, + resolved.ankiConnect.behavior.overwriteImage, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'mediaInsertMode')) { + mapLegacy( + 'mediaInsertMode', + asMediaInsertMode, + (value) => { + resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + resolved.ankiConnect.behavior.mediaInsertMode, + "Expected 'append' or 'prepend'.", + ); + } + if (!hasOwn(behavior, 'highlightWord')) { + mapLegacy( + 'highlightWord', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.highlightWord = value; + }, + resolved.ankiConnect.behavior.highlightWord, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'notificationType')) { + mapLegacy( + 'notificationType', + asNotificationType, + (value) => { + resolved.ankiConnect.behavior.notificationType = value; + }, + resolved.ankiConnect.behavior.notificationType, + "Expected 'osd', 'system', 'both', or 'none'.", + ); + } + if (!hasOwn(behavior, 'autoUpdateNewCards')) { + mapLegacy( + 'autoUpdateNewCards', + asBoolean, + (value) => { + resolved.ankiConnect.behavior.autoUpdateNewCards = value; + }, + resolved.ankiConnect.behavior.autoUpdateNewCards, + 'Expected boolean.', + ); + } const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 55e2f73..ffe723a 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -149,3 +149,92 @@ test('runAppReadyRuntime does not await background warmups', async () => { assert.ok(releaseWarmup); releaseWarmup(); }); + +test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { + const capturedErrors: string[][] = []; + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + ankiConnect: { + enabled: true, + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: ' ', + miscInfo: 'MiscInfo', + translation: '', + }, + }, + }), + onCriticalConfigErrors: (errors) => { + capturedErrors.push(errors); + }, + }); + + await runAppReadyRuntime(deps); + + assert.equal(capturedErrors.length, 1); + assert.deepEqual(capturedErrors[0], [ + 'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.', + 'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.', + ]); + assert.ok(calls.includes('reloadConfig')); + assert.equal(calls.includes('createMpvClient'), false); + assert.equal(calls.includes('initRuntimeOptionsManager'), false); + assert.equal(calls.includes('startBackgroundWarmups'), false); +}); + +test('runAppReadyRuntime aggregates multiple critical anki mapping errors', async () => { + const capturedErrors: string[][] = []; + const { deps, calls } = makeDeps({ + getResolvedConfig: () => ({ + websocket: { enabled: 'auto' }, + secondarySub: {}, + ankiConnect: { + enabled: true, + fields: { + audio: ' ', + image: '', + sentence: '\t', + miscInfo: ' ', + translation: '', + }, + }, + }), + onCriticalConfigErrors: (errors) => { + capturedErrors.push(errors); + }, + }); + + await runAppReadyRuntime(deps); + + assert.equal(capturedErrors.length, 1); + assert.equal(capturedErrors[0].length, 5); + assert.ok( + capturedErrors[0].includes( + 'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + capturedErrors[0].includes( + 'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + capturedErrors[0].includes( + 'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + capturedErrors[0].includes( + 'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.ok( + capturedErrors[0].includes( + 'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.', + ), + ); + assert.equal(calls.includes('loadSubtitlePosition'), false); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 556ffdb..7b14dc1 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -76,6 +76,16 @@ interface AppReadyConfigLike { secondarySub?: { defaultMode?: SecondarySubMode; }; + ankiConnect?: { + enabled?: boolean; + fields?: { + audio?: string; + image?: string; + sentence?: string; + miscInfo?: string; + translation?: string; + }; + }; websocket?: { enabled?: boolean | 'auto'; port?: number; @@ -113,9 +123,38 @@ export interface AppReadyRuntimeDeps { initializeOverlayRuntime: () => void; handleInitialArgs: () => void; logDebug?: (message: string) => void; + onCriticalConfigErrors?: (errors: string[]) => void; now?: () => number; } +const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ + 'audio', + 'image', + 'sentence', + 'miscInfo', + 'translation', +] as const; + +function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] { + if (!config.ankiConnect?.enabled) { + return []; + } + + const errors: string[] = []; + const fields = config.ankiConnect.fields ?? {}; + + for (const key of REQUIRED_ANKI_FIELD_MAPPING_KEYS) { + const value = fields[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push( + `ankiConnect.fields.${key} must be a non-empty string when ankiConnect is enabled.`, + ); + } + } + + return errors; +} + export function getInitialInvisibleOverlayVisibility( config: RuntimeConfigLike, platform: NodeJS.Platform, @@ -151,16 +190,25 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise 0) { + deps.onCriticalConfigErrors?.(criticalConfigErrors); + deps.logDebug?.( + `App-ready critical path aborted after config validation in ${now() - startupStartedAtMs}ms.`, + ); + return; + } + deps.setLogLevel(config.logging?.level ?? 'info', 'config'); for (const warning of deps.getConfigWarnings()) { deps.logConfigWarning(warning); } + + deps.loadSubtitlePosition(); + deps.resolveKeybindings(); + deps.createMpvClient(); deps.initRuntimeOptionsManager(); deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode); diff --git a/src/main.ts b/src/main.ts index 3a42b6d..ba54c2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import { Menu, Tray, nativeImage, + dialog, } from 'electron'; protocol.registerSchemesAsPrivileged([ @@ -61,6 +62,7 @@ import type { MpvSubtitleRenderMetrics, ResolvedConfig, ConfigHotReloadPayload, + ConfigValidationWarning, } from './types'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { AnkiIntegration } from './anki-integration'; @@ -334,6 +336,40 @@ const appLogger = { }, }; +function formatConfigValue(value: unknown): string { + if (value === undefined) { + return 'undefined'; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +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'); +} + +function failStartupFromConfig(title: string, details: string): never { + logger.error(details); + dialog.showErrorBox(title, details); + process.exitCode = 1; + app.quit(); + throw new Error(details); +} + function getDefaultSocketPath(): string { if (process.platform === 'win32') { return '\\\\.\\pipe\\subminer-socket'; @@ -2187,8 +2223,22 @@ const startupState = runStartupBootstrapRuntime( appState.mpvClient = createMpvClientRuntimeService(); }, reloadConfig: () => { - configService.reloadConfig(); - appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); + const result = configService.reloadConfigStrict(); + if (!result.ok) { + failStartupFromConfig( + 'SubMiner config parse error', + `Failed to parse config file at:\n${result.path}\n\nError: ${result.error}\n\nFix the config file and restart SubMiner.`, + ); + } + + appLogger.logInfo(`Using config file: ${result.path}`); + if (result.warnings.length > 0) { + appLogger.logWarning(buildConfigWarningSummary(result.path, result.warnings)); + showDesktopNotification('SubMiner', { + body: `${result.warnings.length} config validation issue(s) detected. Defaults were applied where possible. File: ${result.path}`, + }); + } + configHotReloadRuntime.start(); void refreshAnilistClientSecretState({ force: true }); }, @@ -2285,6 +2335,17 @@ const startupState = runStartupBootstrapRuntime( appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), + onCriticalConfigErrors: (errors: string[]) => { + const configPath = configService.getConfigPath(); + const details = [ + `Critical config validation failed. File: ${configPath}`, + '', + ...errors.map((error, index) => `${index + 1}. ${error}`), + '', + 'Fix the config file and restart SubMiner.', + ].join('\n'); + failStartupFromConfig('SubMiner config validation error', details); + }, logDebug: (message: string) => { logger.debug(message); }, diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 51968b2..4829220 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -45,6 +45,7 @@ export interface AppReadyRuntimeDepsFactoryInput { shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; + onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; logDebug?: AppReadyRuntimeDeps['logDebug']; now?: AppReadyRuntimeDeps['now']; } @@ -99,6 +100,7 @@ export function createAppReadyRuntimeDeps( params.shouldAutoInitializeOverlayRuntimeFromConfig, initializeOverlayRuntime: params.initializeOverlayRuntime, handleInitialArgs: params.handleInitialArgs, + onCriticalConfigErrors: params.onCriticalConfigErrors, logDebug: params.logDebug, now: params.now, };