feat(macos): configuration window + curl-backed macOS updater (#71)

This commit is contained in:
2026-05-17 02:23:44 -07:00
committed by GitHub
parent 6ca5cede3e
commit e84674e3b5
100 changed files with 13890 additions and 235 deletions
+94
View File
@@ -0,0 +1,94 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parse } from 'jsonc-parser';
import { DEFAULT_CONFIG } from '../definitions';
import { applyConfigSettingsPatchToContent, buildConfigSettingsSnapshot } from './jsonc-edit';
import { buildConfigSettingsRegistry } from './registry';
test('applyConfigSettingsPatchToContent preserves JSONC comments while setting nested values', () => {
const input = `{
// keep this comment
"subtitleStyle": {
"fontSize": 35,
},
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'subtitleStyle.autoPauseVideoOnHover',
value: false,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
assert.match(result.content, /keep this comment/);
const parsed = parse(result.content);
assert.equal(parsed.subtitleStyle.autoPauseVideoOnHover, false);
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
"fontSize": 41,
"autoPauseVideoOnHover": false
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [{ op: 'reset', path: 'subtitleStyle.autoPauseVideoOnHover' }],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'autoPauseVideoOnHover'), false);
assert.equal(parsed.subtitleStyle.fontSize, 41);
});
test('applyConfigSettingsPatchToContent rejects warnings caused by modified fields', () => {
const result = applyConfigSettingsPatchToContent({
content: '{}',
operations: [
{
op: 'set',
path: 'subtitleStyle.autoPauseVideoOnHover',
value: 'bad',
},
],
previousWarnings: [],
});
assert.equal(result.ok, false);
assert.equal(result.warnings[0]?.path, 'subtitleStyle.autoPauseVideoOnHover');
});
test('buildConfigSettingsSnapshot masks configured secret values', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const snapshot = buildConfigSettingsSnapshot({
configPath: '/tmp/config.jsonc',
rawConfig: {
ai: {
apiKey: 'secret-key',
},
},
resolvedConfig: {
...DEFAULT_CONFIG,
ai: {
...DEFAULT_CONFIG.ai,
apiKey: 'secret-key',
},
},
warnings: [],
fields,
});
const apiKey = snapshot.values['ai.apiKey'];
assert.deepEqual(apiKey, { configured: true });
});
+200
View File
@@ -0,0 +1,200 @@
import {
applyEdits,
modify,
parse as parseJsonc,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsField,
ConfigSettingsPatchOperation,
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
tabSize: 2,
eol: '\n',
};
export type ConfigSettingsPatchApplyResult =
| {
ok: true;
content: string;
rawConfig: RawConfig;
resolvedConfig: ResolvedConfig;
warnings: ConfigValidationWarning[];
}
| {
ok: false;
content: string;
warnings: ConfigValidationWarning[];
error: string;
};
interface ApplyConfigSettingsPatchOptions {
content: string;
operations: ConfigSettingsPatchOperation[];
previousWarnings: ConfigValidationWarning[];
}
interface BuildConfigSettingsSnapshotOptions {
configPath: string;
rawConfig: RawConfig;
resolvedConfig: ResolvedConfig;
warnings: ConfigValidationWarning[];
fields: ConfigSettingsField[];
}
function pathToSegments(path: string): string[] {
return path.split('.').filter(Boolean);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function warningBelongsToModifiedPath(
warning: ConfigValidationWarning,
operation: ConfigSettingsPatchOperation,
): boolean {
return (
pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path)
);
}
function warningIdentity(warning: ConfigValidationWarning): string {
return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`;
}
function parseRawConfig(content: string): RawConfig {
const errors: ParseError[] = [];
const parsed = parseJsonc(content || '{}', errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return isRecord(parsed) ? (parsed as RawConfig) : {};
}
function normalizeContent(content: string): string {
return content.trim().length > 0 ? content : '{}\n';
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
const edits = modify(
content,
pathToSegments(operation.path),
operation.op === 'reset' ? undefined : operation.value,
{
formattingOptions: JSONC_FORMATTING_OPTIONS,
getInsertionIndex: (properties) => properties.length,
},
);
return applyEdits(content, edits);
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
previousWarnings: ConfigValidationWarning[],
): ConfigValidationWarning[] {
const previous = new Set(previousWarnings.map(warningIdentity));
return warnings.filter((warning) => {
if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) {
return false;
}
return !previous.has(warningIdentity(warning));
});
}
export function applyConfigSettingsPatchToContent(
options: ApplyConfigSettingsPatchOptions,
): ConfigSettingsPatchApplyResult {
let content = normalizeContent(options.content);
try {
parseRawConfig(content);
} catch (error) {
return {
ok: false,
content,
warnings: [],
error: error instanceof Error ? error.message : 'Invalid JSONC.',
};
}
try {
for (const operation of options.operations) {
content = applySingleOperation(content, operation);
}
const rawConfig = parseRawConfig(content);
const { resolved, warnings } = resolveConfig(rawConfig);
const modifiedWarnings = collectModifiedWarnings(
warnings,
options.operations,
options.previousWarnings,
);
if (modifiedWarnings.length > 0) {
return {
ok: false,
content,
warnings: modifiedWarnings,
error: 'One or more modified settings failed validation.',
};
}
return {
ok: true,
content,
rawConfig,
resolvedConfig: resolved,
warnings,
};
} catch (error) {
return {
ok: false,
content,
warnings: [],
error: error instanceof Error ? error.message : 'Failed to update config content.',
};
}
}
export function buildConfigSettingsSnapshot(
options: BuildConfigSettingsSnapshotOptions,
): ConfigSettingsSnapshot {
const values: Record<string, unknown> = {};
for (const field of options.fields) {
const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath);
if (field.secret) {
values[field.configPath] = {
configured:
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0),
};
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
}
return {
configPath: options.configPath,
fields: options.fields,
values,
warnings: options.warnings,
};
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG } from '../definitions';
import {
buildConfigSettingsRegistry,
getConfigSettingsCoverage,
LEGACY_HIDDEN_CONFIG_PATHS,
} from './registry';
test('config settings registry places hover pause under viewing playback behavior', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const hoverPause = fields.find(
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
);
assert.ok(hoverPause);
assert.equal(hoverPause.category, 'viewing');
assert.equal(hoverPause.section, 'Playback pause behavior');
assert.equal(hoverPause.control, 'boolean');
});
test('config settings registry hides legacy and ignored paths from normal fields', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const visiblePaths = new Set(
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
);
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
assert.equal(visiblePaths.has(path), false, path);
}
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
});
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
});
+346
View File
@@ -0,0 +1,346 @@
import type { ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsCategory,
ConfigSettingsControl,
ConfigSettingsField,
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
type Leaf = {
path: string;
value: unknown;
};
export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.deck',
'ankiConnect.wordField',
'ankiConnect.audioField',
'ankiConnect.imageField',
'ankiConnect.sentenceField',
'ankiConnect.miscInfoField',
'ankiConnect.miscInfoPattern',
'ankiConnect.generateAudio',
'ankiConnect.generateImage',
'ankiConnect.imageType',
'ankiConnect.imageFormat',
'ankiConnect.imageQuality',
'ankiConnect.imageMaxWidth',
'ankiConnect.imageMaxHeight',
'ankiConnect.animatedFps',
'ankiConnect.animatedMaxWidth',
'ankiConnect.animatedMaxHeight',
'ankiConnect.animatedCrf',
'ankiConnect.syncAnimatedImageToWordAudio',
'ankiConnect.audioPadding',
'ankiConnect.fallbackDuration',
'ankiConnect.maxMediaDuration',
'ankiConnect.overwriteAudio',
'ankiConnect.overwriteImage',
'ankiConnect.mediaInsertMode',
'ankiConnect.highlightWord',
'ankiConnect.notificationType',
'ankiConnect.autoUpdateNewCards',
'ankiConnect.nPlusOne.highlightEnabled',
'ankiConnect.nPlusOne.refreshMinutes',
'ankiConnect.nPlusOne.matchMode',
'ankiConnect.nPlusOne.decks',
'ankiConnect.nPlusOne.knownWord',
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'jellyfin.accessToken',
'jellyfin.userId',
'controller.buttonIndices',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set([
'Color',
'color',
'backgroundColor',
'singleColor',
'knownWordColor',
'nPlusOne',
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function isLegacyHidden(path: string): boolean {
return (
LEGACY_HIDDEN_CONFIG_PATHS.some((hiddenPath) => pathStartsWith(path, hiddenPath)) ||
EXCLUDED_PREFIXES.some((prefix) => pathStartsWith(path, prefix))
);
}
function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
if (JSON_OBJECT_FIELDS.has(prefix)) {
return [{ path: prefix, value }];
}
if (Array.isArray(value)) {
return [{ path: prefix, value }];
}
if (isRecord(value)) {
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
if (entries.length === 0) {
return [{ path: prefix, value }];
}
return entries.flatMap(([key, child]) =>
flattenConfigLeaves(child, prefix ? `${prefix}.${key}` : key),
);
}
return prefix ? [{ path: prefix, value }] : [];
}
function humanizePath(path: string): string {
const key = path.split('.').at(-1) ?? path;
const spaced = key
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\bai\b/i, 'AI')
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'viewing', section: 'Playback pause behavior' };
}
if (
path.startsWith('ankiConnect.knownWords.') ||
path.startsWith('ankiConnect.nPlusOne.') ||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'viewing', section: 'Annotation display' };
}
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'viewing', section: 'Secondary subtitle appearance' };
}
if (path.startsWith('subtitleStyle.')) {
return { category: 'viewing', section: 'Primary subtitle appearance' };
}
if (path.startsWith('subtitleSidebar.')) {
return { category: 'viewing', section: 'Subtitle sidebar' };
}
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
return { category: 'viewing', section: 'Subtitle behavior' };
}
if (path.startsWith('ankiConnect.fields.')) {
return { category: 'mining-anki', section: 'Note fields' };
}
if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media capture' };
}
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku and Lapis' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
}
if (path.startsWith('ankiConnect.proxy.')) {
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
}
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (
path.startsWith('mpv.') ||
path.startsWith('youtube.') ||
path.startsWith('youtubeSubgen.') ||
path.startsWith('jimaku.') ||
path.startsWith('subsync.')
) {
return { category: 'playback-sources', section: topSection(path) };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay shortcuts' };
}
if (path === 'keybindings') {
return { category: 'input', section: 'MPV keybindings' };
}
if (path.startsWith('controller.')) {
return { category: 'input', section: 'Controller' };
}
if (
path.startsWith('ai.') ||
path.startsWith('anilist.') ||
path.startsWith('yomitan.') ||
path.startsWith('jellyfin.') ||
path.startsWith('discordPresence.') ||
path.startsWith('websocket.') ||
path.startsWith('annotationWebsocket.') ||
path.startsWith('texthooker.')
) {
return { category: 'integrations', section: topSection(path) };
}
if (
path.startsWith('immersionTracking.') ||
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
) {
return { category: 'tracking-app', section: topSection(path) };
}
return { category: 'advanced', section: 'Advanced' };
}
function topSection(path: string): string {
const top = path.split('.')[0] ?? path;
const labels: Record<string, string> = {
ai: 'Shared AI provider',
anilist: 'AniList',
annotationWebsocket: 'Annotation WebSocket',
discordPresence: 'Discord Rich Presence',
immersionTracking: 'Immersion tracking',
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
};
return labels[top] ?? humanizePath(top);
}
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (typeof value === 'string') {
const leaf = path.split('.').at(-1) ?? path;
if ([...COLOR_SUFFIXES].some((suffix) => leaf.endsWith(suffix))) return 'color';
if (leaf.toLowerCase().includes('prompt')) return 'textarea';
return 'text';
}
return 'json';
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
pathStartsWith(path, 'shortcuts') ||
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
) {
return 'hot-reload';
}
return 'restart';
}
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
return {
id: leaf.path,
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
configPath: leaf.path,
category,
section,
control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value,
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
restartBehavior: restartBehaviorForPath(leaf.path),
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
};
}
export function buildConfigSettingsRegistry(
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
): ConfigSettingsField[] {
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
return leaves.map(fieldForLeaf).sort((a, b) => {
const category = a.category.localeCompare(b.category);
if (category !== 0) return category;
const section = a.section.localeCompare(b.section);
if (section !== 0) return section;
return a.configPath.localeCompare(b.configPath);
});
}
export function getConfigSettingsCoverage(
defaultConfig: ResolvedConfig,
fields: ConfigSettingsField[],
): { uncoveredDefaultPaths: string[] } {
const visibleFields = fields.filter((field) => !field.legacyHidden);
const uncoveredDefaultPaths = flattenConfigLeaves(defaultConfig)
.map((leaf) => leaf.path)
.filter((path) => !isLegacyHidden(path))
.filter(
(path) =>
!visibleFields.some(
(field) =>
field.configPath === path ||
(field.control === 'json' && pathStartsWith(path, field.configPath)),
),
)
.sort();
return { uncoveredDefaultPaths };
}
export function getConfigValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}