mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(config): improve startup validation and config error reporting
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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>) : {};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
65
src/main.ts
65
src/main.ts
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user