fix(config): improve startup validation and config error reporting

This commit is contained in:
2026-02-19 00:47:01 -08:00
parent 2c2f342854
commit 07cedabfe3
6 changed files with 803 additions and 90 deletions

View File

@@ -368,6 +368,29 @@ test('falls back for invalid logging.level and reports warning', () => {
assert.ok(warnings.some((warning) => warning.path === 'logging.level')); 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', () => { test('parses invisible overlay config and new global shortcuts', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( 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<string, unknown>).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<string, unknown>;
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', () => { test('accepts valid ankiConnect n+1 deck list', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -212,6 +212,12 @@ export class ConfigService {
}; };
const src = isObject(raw) ? raw : {}; 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)) { if (isObject(src.texthooker)) {
const openBrowser = asBoolean(src.texthooker.openBrowser); const openBrowser = asBoolean(src.texthooker.openBrowser);
@@ -849,14 +855,57 @@ export class ConfigService {
if (isObject(src.ankiConnect)) { if (isObject(src.ankiConnect)) {
const ac = src.ankiConnect; const ac = src.ankiConnect;
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {}; const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; 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 } = const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
ac as Record<string, unknown>; ac as Record<string, unknown>;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)),
);
resolved.ankiConnect = { resolved.ankiConnect = {
...resolved.ankiConnect, ...resolved.ankiConnect,
...(isObject(ankiConnectWithoutNPlusOne) ...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig['ankiConnect']>) ? (ankiConnectWithoutLegacy as Partial<ResolvedConfig['ankiConnect']>)
: {}), : {}),
fields: { fields: {
...resolved.ankiConnect.fields, ...resolved.ankiConnect.fields,
@@ -884,7 +933,6 @@ export class ConfigService {
}, },
isLapis: { isLapis: {
...resolved.ankiConnect.isLapis, ...resolved.ankiConnect.isLapis,
...(isObject(ac.isLapis) ? (ac.isLapis as ResolvedConfig['ankiConnect']['isLapis']) : {}),
}, },
isKiku: { isKiku: {
...resolved.ankiConnect.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)) { if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string') .filter((entry): entry is string => typeof entry === 'string')
@@ -919,89 +1013,344 @@ export class ConfigService {
} }
const legacy = ac as Record<string, unknown>; const legacy = ac as Record<string, unknown>;
const mapLegacy = (key: string, apply: (value: unknown) => void): void => { const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
if (legacy[key] !== undefined) apply(legacy[key]); 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 = <T>(
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) => { if (!hasOwn(fields, 'audio')) {
resolved.ankiConnect.fields.audio = value as string; mapLegacy(
}); 'audioField',
mapLegacy('imageField', (value) => { asString,
resolved.ankiConnect.fields.image = value as string; (value) => {
}); resolved.ankiConnect.fields.audio = value;
mapLegacy('sentenceField', (value) => { },
resolved.ankiConnect.fields.sentence = value as string; resolved.ankiConnect.fields.audio,
}); 'Expected string.',
mapLegacy('miscInfoField', (value) => { );
resolved.ankiConnect.fields.miscInfo = value as string; }
}); if (!hasOwn(fields, 'image')) {
mapLegacy('miscInfoPattern', (value) => { mapLegacy(
resolved.ankiConnect.metadata.pattern = value as string; 'imageField',
}); asString,
mapLegacy('generateAudio', (value) => { (value) => {
resolved.ankiConnect.media.generateAudio = value as boolean; resolved.ankiConnect.fields.image = value;
}); },
mapLegacy('generateImage', (value) => { resolved.ankiConnect.fields.image,
resolved.ankiConnect.media.generateImage = value as boolean; 'Expected string.',
}); );
mapLegacy('imageType', (value) => { }
resolved.ankiConnect.media.imageType = value as 'static' | 'avif'; if (!hasOwn(fields, 'sentence')) {
}); mapLegacy(
mapLegacy('imageFormat', (value) => { 'sentenceField',
resolved.ankiConnect.media.imageFormat = value as 'jpg' | 'png' | 'webp'; asString,
}); (value) => {
mapLegacy('imageQuality', (value) => { resolved.ankiConnect.fields.sentence = value;
resolved.ankiConnect.media.imageQuality = value as number; },
}); resolved.ankiConnect.fields.sentence,
mapLegacy('imageMaxWidth', (value) => { 'Expected string.',
resolved.ankiConnect.media.imageMaxWidth = value as number; );
}); }
mapLegacy('imageMaxHeight', (value) => { if (!hasOwn(fields, 'miscInfo')) {
resolved.ankiConnect.media.imageMaxHeight = value as number; mapLegacy(
}); 'miscInfoField',
mapLegacy('animatedFps', (value) => { asString,
resolved.ankiConnect.media.animatedFps = value as number; (value) => {
}); resolved.ankiConnect.fields.miscInfo = value;
mapLegacy('animatedMaxWidth', (value) => { },
resolved.ankiConnect.media.animatedMaxWidth = value as number; resolved.ankiConnect.fields.miscInfo,
}); 'Expected string.',
mapLegacy('animatedMaxHeight', (value) => { );
resolved.ankiConnect.media.animatedMaxHeight = value as number; }
}); if (!hasOwn(metadata, 'pattern')) {
mapLegacy('animatedCrf', (value) => { mapLegacy(
resolved.ankiConnect.media.animatedCrf = value as number; 'miscInfoPattern',
}); asString,
mapLegacy('audioPadding', (value) => { (value) => {
resolved.ankiConnect.media.audioPadding = value as number; resolved.ankiConnect.metadata.pattern = value;
}); },
mapLegacy('fallbackDuration', (value) => { resolved.ankiConnect.metadata.pattern,
resolved.ankiConnect.media.fallbackDuration = value as number; 'Expected string.',
}); );
mapLegacy('maxMediaDuration', (value) => { }
resolved.ankiConnect.media.maxMediaDuration = value as number; if (!hasOwn(media, 'generateAudio')) {
}); mapLegacy(
mapLegacy('overwriteAudio', (value) => { 'generateAudio',
resolved.ankiConnect.behavior.overwriteAudio = value as boolean; asBoolean,
}); (value) => {
mapLegacy('overwriteImage', (value) => { resolved.ankiConnect.media.generateAudio = value;
resolved.ankiConnect.behavior.overwriteImage = value as boolean; },
}); resolved.ankiConnect.media.generateAudio,
mapLegacy('mediaInsertMode', (value) => { 'Expected boolean.',
resolved.ankiConnect.behavior.mediaInsertMode = value as 'append' | 'prepend'; );
}); }
mapLegacy('highlightWord', (value) => { if (!hasOwn(media, 'generateImage')) {
resolved.ankiConnect.behavior.highlightWord = value as boolean; mapLegacy(
}); 'generateImage',
mapLegacy('notificationType', (value) => { asBoolean,
resolved.ankiConnect.behavior.notificationType = value as (value) => {
| 'osd' resolved.ankiConnect.media.generateImage = value;
| 'system' },
| 'both' resolved.ankiConnect.media.generateImage,
| 'none'; 'Expected boolean.',
}); );
mapLegacy('autoUpdateNewCards', (value) => { }
resolved.ankiConnect.behavior.autoUpdateNewCards = value as 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<string, unknown>) : {}; const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};

View File

@@ -149,3 +149,92 @@ test('runAppReadyRuntime does not await background warmups', async () => {
assert.ok(releaseWarmup); assert.ok(releaseWarmup);
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);
});

View File

@@ -76,6 +76,16 @@ interface AppReadyConfigLike {
secondarySub?: { secondarySub?: {
defaultMode?: SecondarySubMode; defaultMode?: SecondarySubMode;
}; };
ankiConnect?: {
enabled?: boolean;
fields?: {
audio?: string;
image?: string;
sentence?: string;
miscInfo?: string;
translation?: string;
};
};
websocket?: { websocket?: {
enabled?: boolean | 'auto'; enabled?: boolean | 'auto';
port?: number; port?: number;
@@ -113,9 +123,38 @@ export interface AppReadyRuntimeDeps {
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
handleInitialArgs: () => void; handleInitialArgs: () => void;
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number; 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( export function getInitialInvisibleOverlayVisibility(
config: RuntimeConfigLike, config: RuntimeConfigLike,
platform: NodeJS.Platform, platform: NodeJS.Platform,
@@ -151,16 +190,25 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now(); const startupStartedAtMs = now();
deps.logDebug?.('App-ready critical path started.'); deps.logDebug?.('App-ready critical path started.');
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
deps.reloadConfig(); deps.reloadConfig();
const config = deps.getResolvedConfig(); const config = deps.getResolvedConfig();
const criticalConfigErrors = getStartupCriticalConfigErrors(config);
if (criticalConfigErrors.length > 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'); deps.setLogLevel(config.logging?.level ?? 'info', 'config');
for (const warning of deps.getConfigWarnings()) { for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning); deps.logConfigWarning(warning);
} }
deps.loadSubtitlePosition();
deps.resolveKeybindings();
deps.createMpvClient();
deps.initRuntimeOptionsManager(); deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode); deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);

View File

@@ -26,6 +26,7 @@ import {
Menu, Menu,
Tray, Tray,
nativeImage, nativeImage,
dialog,
} from 'electron'; } from 'electron';
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
@@ -61,6 +62,7 @@ import type {
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig, ResolvedConfig,
ConfigHotReloadPayload, ConfigHotReloadPayload,
ConfigValidationWarning,
} from './types'; } from './types';
import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { AnkiIntegration } from './anki-integration'; 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 { function getDefaultSocketPath(): string {
if (process.platform === 'win32') { if (process.platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket'; return '\\\\.\\pipe\\subminer-socket';
@@ -2187,8 +2223,22 @@ const startupState = runStartupBootstrapRuntime(
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
}, },
reloadConfig: () => { reloadConfig: () => {
configService.reloadConfig(); const result = configService.reloadConfigStrict();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); 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(); configHotReloadRuntime.start();
void refreshAnilistClientSecretState({ force: true }); void refreshAnilistClientSecretState({ force: true });
}, },
@@ -2285,6 +2335,17 @@ const startupState = runStartupBootstrapRuntime(
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(), appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), 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) => { logDebug: (message: string) => {
logger.debug(message); logger.debug(message);
}, },

View File

@@ -45,6 +45,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug']; logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now']; now?: AppReadyRuntimeDeps['now'];
} }
@@ -99,6 +100,7 @@ export function createAppReadyRuntimeDeps(
params.shouldAutoInitializeOverlayRuntimeFromConfig, params.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: params.initializeOverlayRuntime, initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs, handleInitialArgs: params.handleInitialArgs,
onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug, logDebug: params.logDebug,
now: params.now, now: params.now,
}; };