mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(macos): configuration window + curl-backed macOS updater (#71)
This commit is contained in:
@@ -212,6 +212,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(settings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
||||
|
||||
const configSettings = parseArgs(['--config']);
|
||||
assert.equal(configSettings.configSettings, true);
|
||||
assert.equal(hasExplicitCommand(configSettings), true);
|
||||
assert.equal(shouldStartApp(configSettings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
||||
|
||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
||||
assert.equal(settingsWithOverlay.settings, true);
|
||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
settings: boolean;
|
||||
configSettings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -115,6 +116,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -234,6 +236,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--config') args.configSettings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -486,6 +489,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -558,6 +562,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.configSettings &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -625,6 +630,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -679,6 +685,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.configSettings &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -22,6 +22,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--config\s+Open configuration window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
--config Open configuration window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
|
||||
@@ -2324,7 +2324,10 @@ test('template generator includes known keys', () => {
|
||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
|
||||
assert.match(
|
||||
output,
|
||||
/"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
@@ -13,6 +14,77 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
const visit = (value: unknown, prefix: string): void => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of entries) {
|
||||
visit(child, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
};
|
||||
visit(config, '');
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// DEFAULT_CONFIG leaves that intentionally do not have a curated
|
||||
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
|
||||
// includes these paths, but their inline comments fall back to an auto-
|
||||
// humanized key name instead of a written description.
|
||||
//
|
||||
// Current intentional gaps:
|
||||
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
|
||||
// CSS reference is the canonical documentation surface.
|
||||
// - keybindings: an array of {key, command} objects, documented at the
|
||||
// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf.
|
||||
//
|
||||
// New leaves added to DEFAULT_CONFIG should prefer a registry entry over
|
||||
// an allowlist entry. Only allowlist a path when the registry is genuinely
|
||||
// the wrong surface for it.
|
||||
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'keybindings',
|
||||
'subtitleStyle.backdropFilter',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.fontFamily',
|
||||
'subtitleStyle.fontKerning',
|
||||
'subtitleStyle.fontSize',
|
||||
'subtitleStyle.fontStyle',
|
||||
'subtitleStyle.fontWeight',
|
||||
'subtitleStyle.jlptColors.N1',
|
||||
'subtitleStyle.jlptColors.N2',
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.fontFamily',
|
||||
'subtitleStyle.secondary.fontKerning',
|
||||
'subtitleStyle.secondary.fontSize',
|
||||
'subtitleStyle.secondary.fontStyle',
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
@@ -40,6 +112,35 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
const missing = leaves
|
||||
.filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`,
|
||||
);
|
||||
|
||||
const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`,
|
||||
);
|
||||
|
||||
const leafSet = new Set(leaves);
|
||||
const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
orphaned,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
|
||||
@@ -322,6 +322,46 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.texthooker.launchAtStartup,
|
||||
description: 'Launch texthooker server automatically when SubMiner starts.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.openBrowser',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.texthooker.openBrowser,
|
||||
description: 'Open the texthooker page in the default browser when the server starts.',
|
||||
},
|
||||
{
|
||||
path: 'subtitlePosition.yPercent',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.subtitlePosition.yPercent,
|
||||
description:
|
||||
'Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.',
|
||||
},
|
||||
{
|
||||
path: 'auto_start_overlay',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.auto_start_overlay,
|
||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.secondarySubLanguages',
|
||||
kind: 'array',
|
||||
defaultValue: defaultConfig.secondarySub.secondarySubLanguages,
|
||||
description:
|
||||
'Language code priority list used to auto-select a secondary subtitle track when available.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.autoLoadSecondarySub',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.secondarySub.autoLoadSecondarySub,
|
||||
description:
|
||||
'Automatically load a matching secondary subtitle when the primary subtitle loads.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.defaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['hidden', 'visible', 'hover'],
|
||||
defaultValue: defaultConfig.secondarySub.defaultMode,
|
||||
description: 'Default visibility mode for the secondary subtitle bar.',
|
||||
},
|
||||
{
|
||||
path: 'websocket.enabled',
|
||||
kind: 'enum',
|
||||
@@ -360,6 +400,27 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subsync.replace,
|
||||
description: 'Replace the active subtitle file when sync completes.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.alass_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.alass_path,
|
||||
description:
|
||||
'Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffsubsync_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffsubsync_path,
|
||||
description:
|
||||
'Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffmpeg_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffmpeg_path,
|
||||
description:
|
||||
'Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.lowPowerMode',
|
||||
kind: 'boolean',
|
||||
@@ -422,5 +483,112 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
|
||||
description: 'Timeout for multi-copy/mine modes.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleVisibleOverlayGlobal,
|
||||
description:
|
||||
'Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitle',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitle,
|
||||
description: 'Accelerator that copies the current subtitle line to the clipboard.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitleMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitleMultiple,
|
||||
description:
|
||||
'Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.updateLastCardFromClipboard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.updateLastCardFromClipboard,
|
||||
description:
|
||||
'Accelerator that updates the last mined Anki card using the current clipboard contents.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerFieldGrouping',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerFieldGrouping,
|
||||
description: 'Accelerator that triggers Kiku field grouping on duplicate cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerSubsync',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerSubsync,
|
||||
description: 'Accelerator that triggers subsync against the active subtitle file.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentence,
|
||||
description: 'Accelerator that mines the current sentence as a new Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentenceMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentenceMultiple,
|
||||
description:
|
||||
'Accelerator that mines consecutive sentences while the multi-mine window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSecondarySub',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSecondarySub,
|
||||
description: 'Accelerator that toggles the secondary subtitle bar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.markAudioCard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.markAudioCard,
|
||||
description: 'Accelerator that marks the last mined card as an audio card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openCharacterDictionary',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
|
||||
description: 'Accelerator that opens the character dictionary modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openRuntimeOptions',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openRuntimeOptions,
|
||||
description: 'Accelerator that opens the runtime options modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openJimaku',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openJimaku,
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerSelect',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerSelect,
|
||||
description: 'Accelerator that opens the controller selection and learn-mode modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerDebug',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerDebug,
|
||||
description:
|
||||
'Accelerator that opens the controller debug modal with live axis/button readouts.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSubtitleSidebar',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.enabled,
|
||||
description: 'Enable AnkiConnect integration.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.url',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.url,
|
||||
description: 'Base URL of the AnkiConnect HTTP server.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
kind: 'number',
|
||||
@@ -58,6 +64,37 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.fields.word,
|
||||
description: 'Card field for the mined word or expression text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.audio',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
||||
description: 'Card field that receives generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.image',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.image,
|
||||
description: 'Card field that receives the captured screenshot or animated image.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.sentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.sentence,
|
||||
description: 'Card field that receives the source sentence text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.miscInfo',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.miscInfo,
|
||||
description:
|
||||
'Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.translation',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.translation,
|
||||
description: 'Card field that receives the current selection or translated text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.ai.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -83,6 +120,41 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Automatically update newly added cards.',
|
||||
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteAudio,
|
||||
description:
|
||||
'When updating an existing card, overwrite the audio field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteImage,
|
||||
description:
|
||||
'When updating an existing card, overwrite the image field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.mediaInsertMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['append', 'prepend'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.mediaInsertMode,
|
||||
description:
|
||||
'Whether new media is appended after or prepended before existing field contents on update.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.highlightWord',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.highlightWord,
|
||||
description: 'Bold the mined word inside the sentence field on the saved Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
kind: 'boolean',
|
||||
@@ -90,6 +162,97 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateImage,
|
||||
description: 'Generate screenshot or animated image for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageType',
|
||||
kind: 'enum',
|
||||
enumValues: ['static', 'avif'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageType,
|
||||
description:
|
||||
'Image capture type: "static" for a single still frame, "avif" for an animated AVIF.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageFormat',
|
||||
kind: 'enum',
|
||||
enumValues: ['jpg', 'png', 'webp'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageFormat,
|
||||
description: 'Encoding format used when imageType is "static".',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageQuality',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageQuality,
|
||||
description: 'Quality (0-100) used for lossy static image encoders.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
|
||||
description:
|
||||
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedFps',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedFps,
|
||||
description: 'Target frame rate for animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxWidth,
|
||||
description: 'Maximum width applied to animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedCrf',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedCrf,
|
||||
description:
|
||||
'Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.audioPadding',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.audioPadding,
|
||||
description: 'Seconds of padding appended to both ends of generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.fallbackDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.fallbackDuration,
|
||||
description: 'Fallback clip duration in seconds when subtitle timing data is unavailable.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.maxMediaDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
|
||||
description: 'Maximum allowed media clip duration in seconds.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.matchMode',
|
||||
kind: 'enum',
|
||||
@@ -148,6 +311,44 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Kiku duplicate-card field grouping mode.',
|
||||
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.enabled,
|
||||
description: 'Enable Kiku-specific mining behaviors (duplicate handling, field grouping).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.deleteDuplicateInAuto',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.deleteDuplicateInAuto,
|
||||
description:
|
||||
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.enabled,
|
||||
description: 'Enable Lapis-specific mining behaviors and sentence card model targeting.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.sentenceCardModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||
description: 'Note type name used by Lapis sentence cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.metadata.pattern',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.metadata.pattern,
|
||||
description:
|
||||
'Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiBaseUrl,
|
||||
description: 'Base URL of the Jimaku subtitle search API.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
kind: 'enum',
|
||||
@@ -277,6 +478,26 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -387,6 +608,18 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ai.baseUrl,
|
||||
description: 'Base URL for the shared OpenAI-compatible AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.model',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.model,
|
||||
description: 'Default model identifier requested from the shared AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.systemPrompt',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.systemPrompt,
|
||||
description: 'Default system prompt sent with shared AI provider requests.',
|
||||
},
|
||||
{
|
||||
path: 'ai.requestTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -130,6 +131,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openConfigSettingsWindow: () => {
|
||||
calls.push('openConfigSettingsWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -582,6 +586,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
expected: string;
|
||||
}> = [
|
||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
expected: 'setVisibleOverlayVisible:true',
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -160,6 +161,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -257,6 +259,7 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
@@ -385,6 +388,8 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.configSettings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -142,10 +142,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
|
||||
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
|
||||
}
|
||||
|
||||
export function ensureExtensionCopy(
|
||||
sourceDir: string,
|
||||
userDataPath: string,
|
||||
): ExtensionCopyResult {
|
||||
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult {
|
||||
if (process.platform === 'win32') {
|
||||
return { targetDir: sourceDir, copied: false };
|
||||
}
|
||||
|
||||
@@ -15,21 +15,24 @@ import {
|
||||
test('yomitan settings window uses a close-only menu without app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never, (template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
});
|
||||
configureYomitanSettingsWindowChrome(
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never,
|
||||
(template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
|
||||
});
|
||||
|
||||
+57
-30
@@ -20,6 +20,8 @@ import {
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
net,
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
@@ -75,28 +77,6 @@ function getDefaultPasswordStore(): string {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
|
||||
function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
} {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
initialArgs?.update ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.update ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
@@ -152,15 +132,18 @@ import {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
commandNeedsOverlayRuntime,
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
type CliArgs,
|
||||
type CliCommandSource,
|
||||
} from './cli/args';
|
||||
import { printHelp } from './cli/help';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningDialogDetails,
|
||||
@@ -515,6 +498,8 @@ import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import { createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
@@ -523,6 +508,7 @@ import {
|
||||
parseSha256Sums,
|
||||
type GitHubRelease,
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||
@@ -541,9 +527,11 @@ import {
|
||||
} from './main/runtime/subtitle-prefetch-runtime';
|
||||
import {
|
||||
createCreateAnilistSetupWindowHandler,
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
createCreateFirstRunSetupWindowHandler,
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
@@ -577,6 +565,7 @@ import {
|
||||
generateConfigTemplate,
|
||||
} from './config';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
import { buildConfigSettingsRegistry } from './config/settings/registry';
|
||||
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
||||
import {
|
||||
createSubtitlePrefetchService,
|
||||
@@ -835,6 +824,7 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
logger.warn(`[AniList] ${message}`);
|
||||
try {
|
||||
@@ -1777,6 +1767,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
},
|
||||
},
|
||||
);
|
||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||
buildConfigHotReloadAppliedMainDepsHandler(),
|
||||
);
|
||||
const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler(
|
||||
{
|
||||
getCurrentConfig: () => getResolvedConfig(),
|
||||
@@ -1785,9 +1778,7 @@ const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRun
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout) => clearTimeout(timeout),
|
||||
debounceMs: 250,
|
||||
onHotReloadApplied: createConfigHotReloadAppliedHandler(
|
||||
buildConfigHotReloadAppliedMainDepsHandler(),
|
||||
),
|
||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||
onRestartRequired: (fields) =>
|
||||
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
|
||||
onInvalidConfig: notifyConfigHotReloadMessage,
|
||||
@@ -1808,6 +1799,32 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
|
||||
buildConfigHotReloadRuntimeMainDepsHandler(),
|
||||
);
|
||||
|
||||
const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
fields: configSettingsFields,
|
||||
getConfigPath: () => configService.getConfigPath(),
|
||||
getRawConfig: () => configService.getRawConfig(),
|
||||
getConfig: () => configService.getConfig(),
|
||||
getWarnings: () => configService.getWarnings(),
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
||||
getSettingsWindow: () => appState.configSettingsWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||
},
|
||||
createSettingsWindow: createCreateConfigSettingsWindowHandler({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
||||
openPath: (targetPath) => shell.openPath(targetPath),
|
||||
ipcMain,
|
||||
ipcChannels: IPC_CHANNELS.request,
|
||||
log: (message) => logger.error(message),
|
||||
});
|
||||
|
||||
configSettingsRuntime.registerHandlers();
|
||||
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
|
||||
|
||||
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
|
||||
platform: process.platform,
|
||||
dirname: __dirname,
|
||||
@@ -3759,7 +3776,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => configHotReloadRuntime.start(),
|
||||
shouldRefreshAnilistClientSecretState: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldRefreshAnilistOnConfigReload(appState.initialArgs),
|
||||
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
|
||||
failHandlers: {
|
||||
logError: (details) => logger.error(details),
|
||||
@@ -4636,9 +4653,12 @@ flushPendingMpvLogWrites = () => {
|
||||
|
||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
const electronNetFetch = createElectronNetFetch({
|
||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||
});
|
||||
|
||||
function getFetchForUpdater() {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
return electronNetFetch;
|
||||
}
|
||||
|
||||
async function updateLauncherFromSelectedRelease(
|
||||
@@ -4685,6 +4705,9 @@ function getUpdateService() {
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
configureHttpExecutor:
|
||||
process.platform === 'darwin' ? () => createCurlHttpExecutor() : undefined,
|
||||
disableDifferentialDownload: process.platform === 'darwin',
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
@@ -4706,6 +4729,8 @@ function getUpdateService() {
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel, release) =>
|
||||
@@ -5412,6 +5437,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
@@ -5526,7 +5552,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
|
||||
runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
if (!shouldStartAutomaticUpdateChecks(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
getUpdateService().startAutomaticChecks();
|
||||
@@ -5621,6 +5647,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
isJellyfinConfigured: () =>
|
||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -127,6 +128,7 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
@@ -373,6 +374,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -84,8 +84,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
||||
: {}),
|
||||
...(deps.getYomitanExtensionLoadInFlight
|
||||
? {
|
||||
getYomitanExtensionLoadInFlight: () =>
|
||||
deps.getYomitanExtensionLoadInFlight?.() ?? null,
|
||||
getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null,
|
||||
}
|
||||
: {}),
|
||||
openYomitanSettingsWindow: (params: {
|
||||
|
||||
@@ -70,6 +70,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -99,6 +100,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -100,6 +100,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
@@ -129,6 +130,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
deps.initializeOverlay();
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.openConfigSettingsWindow();
|
||||
deps.printHelp();
|
||||
await deps.runUpdateCommand({ update: true } as never, 'initial');
|
||||
|
||||
@@ -137,6 +139,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
'init-overlay',
|
||||
'open-setup:force',
|
||||
'set-visible:true',
|
||||
'open-config-settings',
|
||||
'help',
|
||||
'run-update',
|
||||
]);
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -127,6 +128,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
deps.runUpdateCommand(args, source),
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
printHelp: () => deps.printHelp(),
|
||||
|
||||
@@ -56,6 +56,7 @@ function createDeps() {
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -49,6 +49,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -126,6 +127,7 @@ export function createCliCommandContext(
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -47,10 +47,7 @@ export function getUserPath(options: CommonOptions & WindowsPathOptions): string
|
||||
return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? '';
|
||||
}
|
||||
|
||||
async function setWindowsUserPath(
|
||||
options: CommonOptions & WindowsPathOptions,
|
||||
nextPath: string,
|
||||
) {
|
||||
async function setWindowsUserPath(options: CommonOptions & WindowsPathOptions, nextPath: string) {
|
||||
if (options.setUserPath) {
|
||||
await options.setUserPath(nextPath);
|
||||
return;
|
||||
@@ -96,6 +93,7 @@ export async function appendWindowsUserPathDir(
|
||||
}
|
||||
|
||||
export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string {
|
||||
const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
|
||||
const userProfile =
|
||||
options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
|
||||
return path.win32.join(userProfile, '.bun', 'bin');
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isConfigSettingsPatch } from './config-settings-ipc';
|
||||
import type { ConfigSettingsField } from '../../types/settings';
|
||||
|
||||
const fields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'mpv.launchMode',
|
||||
label: 'Launch mode',
|
||||
description: 'Launch mode setting.',
|
||||
configPath: 'mpv.launchMode',
|
||||
category: 'playback-sources',
|
||||
section: 'mpv launcher',
|
||||
control: 'select',
|
||||
defaultValue: 'windowed',
|
||||
restartBehavior: 'restart',
|
||||
},
|
||||
];
|
||||
|
||||
test('isConfigSettingsPatch rejects set operations without a value property', () => {
|
||||
assert.equal(
|
||||
isConfigSettingsPatch(
|
||||
{
|
||||
operations: [{ op: 'set', path: 'mpv.launchMode' }],
|
||||
},
|
||||
fields,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConfigSettingsPatch accepts set operations with an explicit value', () => {
|
||||
assert.equal(
|
||||
isConfigSettingsPatch(
|
||||
{
|
||||
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
|
||||
},
|
||||
fields,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConfigSettingsPatch accepts reset operations without a value', () => {
|
||||
assert.equal(
|
||||
isConfigSettingsPatch(
|
||||
{
|
||||
operations: [{ op: 'reset', path: 'mpv.launchMode' }],
|
||||
},
|
||||
fields,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConfigSettingsPatch rejects unknown config paths', () => {
|
||||
assert.equal(
|
||||
isConfigSettingsPatch(
|
||||
{
|
||||
operations: [{ op: 'reset', path: 'unknown.path' }],
|
||||
},
|
||||
fields,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ConfigSettingsField, ConfigSettingsPatch } from '../../types/settings';
|
||||
|
||||
export function isConfigSettingsPatch(
|
||||
value: unknown,
|
||||
fields: readonly ConfigSettingsField[],
|
||||
): value is ConfigSettingsPatch {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const operations = (value as { operations?: unknown }).operations;
|
||||
return (
|
||||
Array.isArray(operations) &&
|
||||
operations.every((operation) => {
|
||||
if (!operation || typeof operation !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = operation as { op?: unknown; path?: unknown; value?: unknown };
|
||||
const knownPath =
|
||||
typeof candidate.path === 'string' &&
|
||||
fields.some((field) => field.configPath === candidate.path);
|
||||
if (!knownPath) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.op === 'set') {
|
||||
return 'value' in candidate;
|
||||
}
|
||||
return candidate.op === 'reset';
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsSaveResult,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
type ConfigHotReloadDiff,
|
||||
} from '../../core/services/config-hot-reload';
|
||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||
import {
|
||||
createOpenConfigSettingsWindowHandler,
|
||||
type ConfigSettingsWindowLike,
|
||||
} from './config-settings-window';
|
||||
import { isConfigSettingsPatch } from './config-settings-ipc';
|
||||
|
||||
export interface ConfigSettingsIpcMainLike {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsIpcChannels {
|
||||
getConfigSettingsSnapshot: string;
|
||||
saveConfigSettingsPatch: string;
|
||||
openConfigSettingsFile: string;
|
||||
openConfigSettingsWindow: string;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
||||
fields: ConfigSettingsField[];
|
||||
getConfigPath(): string;
|
||||
getRawConfig(): RawConfig;
|
||||
getConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
settingsHtmlPath: string;
|
||||
openPath(path: string): Promise<string>;
|
||||
ipcMain: ConfigSettingsIpcMainLike;
|
||||
ipcChannels: ConfigSettingsIpcChannels;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function writeTextFileAtomically(targetPath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
const tempPath = path.join(
|
||||
path.dirname(targetPath),
|
||||
`.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, 'utf-8');
|
||||
fs.renameSync(tempPath, targetPath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
// Best effort cleanup after a failed atomic write.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getRestartRequiredSettingsSections(
|
||||
fields: readonly ConfigSettingsField[],
|
||||
restartRequiredFields: string[],
|
||||
): string[] {
|
||||
const sections = new Set<string>();
|
||||
for (const field of fields) {
|
||||
if (
|
||||
restartRequiredFields.some(
|
||||
(restartField) =>
|
||||
field.configPath === restartField ||
|
||||
field.configPath.startsWith(`${restartField}.`) ||
|
||||
restartField.startsWith(`${field.configPath}.`),
|
||||
)
|
||||
) {
|
||||
sections.add(field.section);
|
||||
}
|
||||
}
|
||||
return [...sections].sort();
|
||||
}
|
||||
|
||||
export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindowLike>(
|
||||
deps: ConfigSettingsRuntimeDeps<TWindow>,
|
||||
) {
|
||||
function getSnapshot(): ConfigSettingsSnapshot {
|
||||
return buildConfigSettingsSnapshot({
|
||||
configPath: deps.getConfigPath(),
|
||||
rawConfig: deps.getRawConfig(),
|
||||
resolvedConfig: deps.getConfig(),
|
||||
warnings: deps.getWarnings(),
|
||||
fields: deps.fields,
|
||||
});
|
||||
}
|
||||
|
||||
const savePatch = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => deps.getConfigPath(),
|
||||
getCurrentConfig: () => deps.getConfig(),
|
||||
getWarnings: () => deps.getWarnings(),
|
||||
getSnapshot,
|
||||
fileExists: (targetPath) => fs.existsSync(targetPath),
|
||||
readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'),
|
||||
writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content),
|
||||
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
|
||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||
});
|
||||
|
||||
function ensureConfigFileExists(): string {
|
||||
const configPath = deps.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
writeTextFileAtomically(configPath, '{}\n');
|
||||
}
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const openWindow = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: deps.getSettingsWindow,
|
||||
setSettingsWindow: deps.setSettingsWindow,
|
||||
createSettingsWindow: deps.createSettingsWindow,
|
||||
settingsHtmlPath: deps.settingsHtmlPath,
|
||||
log: deps.log,
|
||||
});
|
||||
|
||||
function invalidPatchResult(): ConfigSettingsSaveResult {
|
||||
return {
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: 'Invalid config settings patch.',
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: [],
|
||||
restartRequiredSections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function registerHandlers(): void {
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||
if (!isConfigSettingsPatch(patch, deps.fields)) {
|
||||
return invalidPatchResult();
|
||||
}
|
||||
return savePatch(patch);
|
||||
});
|
||||
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsFile, async () => {
|
||||
const openError = await deps.openPath(ensureConfigFileExists());
|
||||
return openError.length === 0;
|
||||
});
|
||||
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot,
|
||||
savePatch,
|
||||
openWindow,
|
||||
registerHandlers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_CONFIG, type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { ConfigSettingsSnapshot } from '../../types/settings';
|
||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||
|
||||
function snapshot(): ConfigSettingsSnapshot {
|
||||
return {
|
||||
configPath: '/tmp/config.jsonc',
|
||||
fields: [],
|
||||
values: {},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('config settings save applies hot-reloadable diff live', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleStyle: {
|
||||
...DEFAULT_CONFIG.subtitleStyle,
|
||||
autoPauseVideoOnHover: false,
|
||||
},
|
||||
};
|
||||
let written = '';
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => previous,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{}',
|
||||
writeTextAtomically: (_path, content) => {
|
||||
written = content;
|
||||
calls.push('write');
|
||||
},
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: true,
|
||||
config: next,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => ({
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(written, /autoPauseVideoOnHover/);
|
||||
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
|
||||
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(result.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('config settings save returns restart-required sections without applying hot reload', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
mpv: {
|
||||
...DEFAULT_CONFIG.mpv,
|
||||
launchMode: 'fullscreen',
|
||||
},
|
||||
};
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => previous,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{}',
|
||||
writeTextAtomically: () => calls.push('write'),
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: true,
|
||||
config: next,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => ({
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: ['mpv'],
|
||||
}),
|
||||
applyHotReload: () => calls.push('hot'),
|
||||
getRestartRequiredSections: () => ['mpv launcher'],
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ['write']);
|
||||
assert.deepEqual(result.hotReloadFields, []);
|
||||
assert.deepEqual(result.restartRequiredFields, ['mpv']);
|
||||
assert.deepEqual(result.restartRequiredSections, ['mpv launcher']);
|
||||
});
|
||||
|
||||
test('config settings save restores previous file content when strict reload fails', () => {
|
||||
const writes: string[] = [];
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => DEFAULT_CONFIG,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{"mpv":{"launchMode":"normal"}}\n',
|
||||
writeTextAtomically: (_path, content) => {
|
||||
writes.push(content);
|
||||
},
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: false,
|
||||
error: 'invalid config',
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => {
|
||||
throw new Error('Should not classify invalid config.');
|
||||
},
|
||||
applyHotReload: () => {
|
||||
throw new Error('Should not hot reload invalid config.');
|
||||
},
|
||||
getRestartRequiredSections: () => [],
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'invalid config');
|
||||
assert.equal(writes.length, 2);
|
||||
assert.match(writes[0] ?? '', /fullscreen/);
|
||||
assert.equal(writes[1], '{"mpv":{"launchMode":"normal"}}\n');
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import { applyConfigSettingsPatchToContent } from '../../config/settings/jsonc-edit';
|
||||
import type { ConfigValidationWarning, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsPatch,
|
||||
ConfigSettingsSaveResult,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
|
||||
export interface ConfigSettingsHotReloadDiff {
|
||||
hotReloadFields: string[];
|
||||
restartRequiredFields: string[];
|
||||
}
|
||||
|
||||
export interface ConfigSettingsSaveDeps {
|
||||
getConfigPath(): string;
|
||||
getCurrentConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
getSnapshot(): ConfigSettingsSnapshot;
|
||||
fileExists(path: string): boolean;
|
||||
readText(path: string): string;
|
||||
writeTextAtomically(path: string, content: string): void;
|
||||
deleteFile?(path: string): void;
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
|
||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||
}
|
||||
|
||||
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
||||
return (patch: ConfigSettingsPatch): ConfigSettingsSaveResult => {
|
||||
if (patch.operations.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: deps.getSnapshot(),
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: [],
|
||||
restartRequiredSections: [],
|
||||
};
|
||||
}
|
||||
|
||||
const configPath = deps.getConfigPath();
|
||||
const previousConfig = deps.getCurrentConfig();
|
||||
const previousWarnings = deps.getWarnings();
|
||||
const hadExistingConfig = deps.fileExists(configPath);
|
||||
const content = hadExistingConfig ? deps.readText(configPath) : '{}\n';
|
||||
const candidate = applyConfigSettingsPatchToContent({
|
||||
content,
|
||||
operations: patch.operations,
|
||||
previousWarnings,
|
||||
});
|
||||
|
||||
if (!candidate.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
warnings: candidate.warnings,
|
||||
error: candidate.error,
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: [],
|
||||
restartRequiredSections: [],
|
||||
};
|
||||
}
|
||||
|
||||
deps.writeTextAtomically(configPath, candidate.content);
|
||||
const reloadResult = deps.reloadConfigStrict();
|
||||
if (!reloadResult.ok) {
|
||||
if (hadExistingConfig) {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
} else if (deps.deleteFile) {
|
||||
deps.deleteFile(configPath);
|
||||
} else {
|
||||
deps.writeTextAtomically(configPath, content);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: reloadResult.error,
|
||||
hotReloadFields: [],
|
||||
restartRequiredFields: [],
|
||||
restartRequiredSections: [],
|
||||
};
|
||||
}
|
||||
|
||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.applyHotReload(diff, reloadResult.config);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: deps.getSnapshot(),
|
||||
warnings: reloadResult.warnings,
|
||||
hotReloadFields: diff.hotReloadFields,
|
||||
restartRequiredFields: diff.restartRequiredFields,
|
||||
restartRequiredSections: deps.getRestartRequiredSections(diff.restartRequiredFields),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOpenConfigSettingsWindowHandler } from './config-settings-window';
|
||||
|
||||
test('createOpenConfigSettingsWindowHandler focuses existing settings window', () => {
|
||||
const calls: string[] = [];
|
||||
const existing = {
|
||||
isDestroyed: () => false,
|
||||
focus: () => calls.push('focus'),
|
||||
loadFile: () => calls.push('load'),
|
||||
on: () => {},
|
||||
};
|
||||
|
||||
const open = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: () => existing,
|
||||
setSettingsWindow: () => calls.push('set'),
|
||||
createSettingsWindow: () => {
|
||||
throw new Error('Should not create a second window.');
|
||||
},
|
||||
settingsHtmlPath: '/tmp/settings.html',
|
||||
});
|
||||
|
||||
assert.equal(open(), true);
|
||||
assert.deepEqual(calls, ['focus']);
|
||||
});
|
||||
|
||||
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
|
||||
const calls: string[] = [];
|
||||
const handlers: { closed?: () => void } = {};
|
||||
const created = {
|
||||
isDestroyed: () => false,
|
||||
focus: () => calls.push('focus'),
|
||||
loadFile: (path: string) => calls.push(`load:${path}`),
|
||||
on: (event: string, handler: () => void) => {
|
||||
if (event === 'closed') handlers.closed = handler;
|
||||
},
|
||||
};
|
||||
|
||||
const open = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: () => null,
|
||||
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
|
||||
createSettingsWindow: () => created,
|
||||
settingsHtmlPath: '/tmp/settings.html',
|
||||
});
|
||||
|
||||
assert.equal(open(), true);
|
||||
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']);
|
||||
assert.ok(handlers.closed);
|
||||
handlers.closed();
|
||||
assert.equal(calls.at(-1), 'set:null');
|
||||
});
|
||||
|
||||
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
|
||||
const calls: string[] = [];
|
||||
const created = {
|
||||
isDestroyed: () => false,
|
||||
focus: () => calls.push('focus'),
|
||||
loadFile: (path: string) => {
|
||||
calls.push(`load:${path}`);
|
||||
return Promise.reject(new Error('missing settings html'));
|
||||
},
|
||||
on: () => {},
|
||||
destroy: () => calls.push('destroy'),
|
||||
};
|
||||
|
||||
const open = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: () => null,
|
||||
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
|
||||
createSettingsWindow: () => created,
|
||||
settingsHtmlPath: '/tmp/missing-settings.html',
|
||||
});
|
||||
|
||||
assert.equal(open(), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'load:/tmp/missing-settings.html',
|
||||
'set:window',
|
||||
'focus',
|
||||
'set:null',
|
||||
'destroy',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface ConfigSettingsWindowLike {
|
||||
isDestroyed(): boolean;
|
||||
focus(): void;
|
||||
loadFile(path: string): unknown;
|
||||
on(event: 'closed', handler: () => void): unknown;
|
||||
destroy?(): unknown;
|
||||
}
|
||||
|
||||
export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWindowLike> {
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
settingsHtmlPath: string;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSettingsWindowLike>(
|
||||
deps: OpenConfigSettingsWindowDeps<TWindow>,
|
||||
): () => boolean {
|
||||
return () => {
|
||||
const existing = deps.getSettingsWindow();
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
const window = deps.createSettingsWindow();
|
||||
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.log?.(`Failed to load configuration settings window: ${message}`);
|
||||
deps.setSettingsWindow(null);
|
||||
window.destroy?.();
|
||||
});
|
||||
deps.setSettingsWindow(window);
|
||||
window.on('closed', () => {
|
||||
deps.setSettingsWindow(null);
|
||||
});
|
||||
window.focus();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -120,6 +121,7 @@ function createCommandLineLauncherSnapshot(
|
||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
||||
false,
|
||||
|
||||
@@ -72,6 +72,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createCreateAnilistSetupWindowHandler,
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
createCreateFirstRunSetupWindowHandler,
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
} from './setup-window-factory';
|
||||
@@ -77,3 +78,31 @@ test('createCreateAnilistSetupWindowHandler builds anilist setup window', () =>
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('createCreateConfigSettingsWindowHandler builds configuration settings window', () => {
|
||||
let options: Electron.BrowserWindowConstructorOptions | null = null;
|
||||
const createSettingsWindow = createCreateConfigSettingsWindowHandler({
|
||||
preloadPath: '/tmp/preload-settings.js',
|
||||
createBrowserWindow: (nextOptions) => {
|
||||
options = nextOptions;
|
||||
return { id: 'config-settings' } as never;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(createSettingsWindow(), { id: 'config-settings' });
|
||||
assert.deepEqual(options, {
|
||||
width: 1040,
|
||||
height: 760,
|
||||
title: 'SubMiner Configuration',
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
backgroundColor: '#24273a',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
preload: '/tmp/preload-settings.js',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ interface SetupWindowConfig {
|
||||
resizable?: boolean;
|
||||
minimizable?: boolean;
|
||||
maximizable?: boolean;
|
||||
preloadPath?: string;
|
||||
sandbox?: boolean;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function createSetupWindowHandler<TWindow>(
|
||||
@@ -21,9 +24,12 @@ function createSetupWindowHandler<TWindow>(
|
||||
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
|
||||
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
|
||||
...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }),
|
||||
...(config.backgroundColor === undefined ? {} : { backgroundColor: config.backgroundColor }),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
...(config.sandbox === undefined ? {} : { sandbox: config.sandbox }),
|
||||
...(config.preloadPath ? { preload: config.preloadPath } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,3 +66,18 @@ export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
|
||||
title: 'Anilist Setup',
|
||||
});
|
||||
}
|
||||
|
||||
export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
preloadPath: string;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 1040,
|
||||
height: 760,
|
||||
title: 'SubMiner Configuration',
|
||||
resizable: true,
|
||||
preloadPath: deps.preloadPath,
|
||||
sandbox: false,
|
||||
backgroundColor: '#24273a',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseArgs } from '../../cli/args';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './startup-mode-flags';
|
||||
|
||||
test('config settings startup uses minimal startup and skips background integrations', () => {
|
||||
const args = parseArgs(['--config']);
|
||||
const flags = getStartupModeFlags(args);
|
||||
|
||||
assert.equal(flags.shouldUseMinimalStartup, true);
|
||||
assert.equal(flags.shouldSkipHeavyStartup, true);
|
||||
assert.equal(shouldRefreshAnilistOnConfigReload(args), false);
|
||||
assert.equal(shouldStartAutomaticUpdateChecks(args), false);
|
||||
});
|
||||
|
||||
test('normal startup still allows background integrations', () => {
|
||||
const flags = getStartupModeFlags(null);
|
||||
|
||||
assert.equal(flags.shouldUseMinimalStartup, false);
|
||||
assert.equal(flags.shouldSkipHeavyStartup, false);
|
||||
assert.equal(shouldRefreshAnilistOnConfigReload(null), true);
|
||||
assert.equal(shouldStartAutomaticUpdateChecks(null), true);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
} from '../../cli/args';
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
} {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
initialArgs?.configSettings ||
|
||||
initialArgs?.update ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.configSettings ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.update ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRefreshAnilistOnConfigReload(
|
||||
initialArgs: CliArgs | null | undefined,
|
||||
): boolean {
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
|
||||
}
|
||||
|
||||
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
|
||||
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
|
||||
}
|
||||
@@ -48,6 +48,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openConfigSettings();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
handlers.openAnilistSetup();
|
||||
@@ -68,6 +69,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
@@ -90,6 +92,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'configuration',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
'anilist',
|
||||
|
||||
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
@@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
@@ -92,6 +94,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}
|
||||
deps.openRuntimeOptionsPalette();
|
||||
},
|
||||
openConfigSettings: () => {
|
||||
deps.openConfigSettingsWindow();
|
||||
},
|
||||
openJellyfinSetup: () => {
|
||||
deps.openJellyfinSetupWindow();
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
@@ -53,6 +54,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
|
||||
@@ -36,6 +36,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
@@ -54,6 +55,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
|
||||
|
||||
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
|
||||
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
@@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 12);
|
||||
assert.equal(template.length, 13);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
@@ -60,10 +61,10 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
assert.equal(template[9]!.label, 'Check for Updates');
|
||||
template[9]!.click?.();
|
||||
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[11]!.click?.();
|
||||
assert.equal(template[10]!.label, 'Check for Updates');
|
||||
template[10]!.click?.();
|
||||
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[12]!.click?.();
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery',
|
||||
'help',
|
||||
@@ -85,6 +86,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
jellyfinDiscoveryActive: false,
|
||||
@@ -112,6 +114,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
jellyfinDiscoveryActive: false,
|
||||
@@ -137,6 +140,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type TrayMenuActionHandlers = {
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
@@ -92,6 +93,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Open Runtime Options',
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Open Configuration',
|
||||
click: handlers.openConfigSettings,
|
||||
},
|
||||
{
|
||||
label: 'Configure Jellyfin',
|
||||
click: handlers.openJellyfinSetup,
|
||||
|
||||
@@ -162,6 +162,46 @@ test('app updater skips native downloads when native updater is unsupported', as
|
||||
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
|
||||
});
|
||||
|
||||
test('app updater installs a custom HTTP executor before native checks', async () => {
|
||||
const httpExecutor = { request: async () => null };
|
||||
let executorDuringCheck: unknown;
|
||||
let differentialDownloadDuringCheck: unknown;
|
||||
const updater: ElectronAutoUpdaterLike & {
|
||||
httpExecutor?: unknown;
|
||||
disableDifferentialDownload?: boolean;
|
||||
} = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => {
|
||||
executorDuringCheck = updater.httpExecutor;
|
||||
differentialDownloadDuringCheck = updater.disableDifferentialDownload;
|
||||
return {
|
||||
updateInfo: {
|
||||
version: '0.15.0',
|
||||
},
|
||||
};
|
||||
},
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: () => {},
|
||||
configureHttpExecutor: () => httpExecutor,
|
||||
disableDifferentialDownload: true,
|
||||
});
|
||||
|
||||
const result = await appUpdater.checkForUpdates('stable');
|
||||
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(executorDuringCheck, httpExecutor);
|
||||
assert.equal(differentialDownloadDuringCheck, true);
|
||||
});
|
||||
|
||||
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
|
||||
assert.equal(
|
||||
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
@@ -185,6 +225,25 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
|
||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||
});
|
||||
|
||||
test('mac native updater is unsupported outside Applications folders before signature probing', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Users/tester/build/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
homeDir: '/Users/tester',
|
||||
readCodeSignature: () => {
|
||||
throw new Error('signature should not be read');
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native macOS updater because the app is not installed in an Applications folder.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
@@ -34,11 +36,16 @@ export interface ElectronAutoUpdaterLike {
|
||||
} | null>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
disableDifferentialDownload?: boolean;
|
||||
}
|
||||
|
||||
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ElectronAutoUpdaterWithHttpExecutor = ElectronAutoUpdaterLike & {
|
||||
httpExecutor?: unknown;
|
||||
};
|
||||
|
||||
export function resolveMacAppBundlePath(execPath: string): string | null {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
@@ -65,6 +72,25 @@ function realpathOrOriginal(filePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isSameOrInsideDirectory(parentPath: string, candidatePath: string): boolean {
|
||||
const relative = path.relative(parentPath, candidatePath);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMacApplicationsFolderBundle(
|
||||
appBundlePath: string,
|
||||
homeDir: string = os.homedir(),
|
||||
): boolean {
|
||||
const resolvedBundlePath = path.resolve(appBundlePath);
|
||||
return (
|
||||
isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
|
||||
isSameOrInsideDirectory(path.join(homeDir, 'Applications'), resolvedBundlePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
@@ -74,6 +100,7 @@ export async function isNativeUpdaterSupported(options: {
|
||||
isPackaged: boolean;
|
||||
execPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
@@ -100,6 +127,13 @@ export async function isNativeUpdaterSupported(options: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app is not installed in an Applications folder.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.(
|
||||
@@ -157,6 +191,8 @@ export function createElectronAppUpdater(options: {
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
|
||||
configureHttpExecutor?: () => unknown;
|
||||
disableDifferentialDownload?: boolean;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
@@ -164,6 +200,13 @@ export function createElectronAppUpdater(options: {
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
if (options.configureHttpExecutor) {
|
||||
// electron-updater has no public executor hook; keep the macOS cURL override localized.
|
||||
(updater as ElectronAutoUpdaterWithHttpExecutor).httpExecutor = options.configureHttpExecutor();
|
||||
}
|
||||
if (options.disableDifferentialDownload !== undefined) {
|
||||
updater.disableDifferentialDownload = options.disableDifferentialDownload;
|
||||
}
|
||||
let nativeUpdaterSupported: Promise<boolean> | null = null;
|
||||
|
||||
async function getNativeUpdaterSupported(): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createCurlHttpExecutor, type CurlExecFile } from './curl-http-executor';
|
||||
|
||||
test('curl HTTP executor requests updater metadata without Electron networking', async () => {
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const execFile: CurlExecFile = (file, args, _options, callback) => {
|
||||
calls.push({ file, args });
|
||||
queueMicrotask(() => callback(null, 'metadata', ''));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
|
||||
|
||||
const result = await executor.request({
|
||||
protocol: 'https:',
|
||||
hostname: 'api.github.com',
|
||||
path: '/repos/ksyasuda/SubMiner/releases',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'x-user-staging-id': 'abc',
|
||||
},
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
assert.equal(result, 'metadata');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.file, '/usr/bin/curl');
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'120',
|
||||
'--header',
|
||||
'Accept: application/vnd.github+json',
|
||||
'--header',
|
||||
'x-user-staging-id: abc',
|
||||
'https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
]);
|
||||
});
|
||||
|
||||
test('curl HTTP executor downloads updater assets to the requested destination', async () => {
|
||||
const calls: Array<{ args: readonly string[] }> = [];
|
||||
const execFile: CurlExecFile = (_file, args, _options, callback) => {
|
||||
calls.push({ args });
|
||||
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({
|
||||
execFile,
|
||||
curlPath: '/usr/bin/curl',
|
||||
mkdir: async () => undefined,
|
||||
});
|
||||
|
||||
await executor.download(
|
||||
new URL('https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip'),
|
||||
'/tmp/subminer/update.zip',
|
||||
{
|
||||
headers: { 'User-Agent': 'SubMiner updater' },
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--header',
|
||||
'User-Agent: SubMiner updater',
|
||||
'--output',
|
||||
'/tmp/subminer/update.zip',
|
||||
'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip',
|
||||
]);
|
||||
});
|
||||
|
||||
test('curl HTTP executor verifies downloaded updater asset hashes', async () => {
|
||||
const data = Buffer.from('zip payload');
|
||||
const expectedSha512 = createHash('sha512').update(data).digest('base64');
|
||||
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
|
||||
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({
|
||||
execFile,
|
||||
curlPath: '/usr/bin/curl',
|
||||
mkdir: async () => undefined,
|
||||
readFile: async () => data,
|
||||
});
|
||||
|
||||
await executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
|
||||
sha512: expectedSha512,
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
|
||||
sha512: 'bad',
|
||||
cancellationToken: {
|
||||
createPromise: (callback) =>
|
||||
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
|
||||
},
|
||||
}),
|
||||
/sha512 mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test('curl HTTP executor does not expose command arguments when stderr is empty', async () => {
|
||||
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
|
||||
const error = new Error('--header Authorization: Bearer secret-token');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
queueMicrotask(() => callback(error, '', ''));
|
||||
return { kill: () => true };
|
||||
};
|
||||
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.request({
|
||||
protocol: 'https:',
|
||||
hostname: 'api.github.com',
|
||||
path: '/repos/ksyasuda/SubMiner/releases',
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, 'curl failed (ENOENT)');
|
||||
assert.doesNotMatch(error.message, /secret-token|Authorization/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { execFile as defaultExecFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { RequestOptions, OutgoingHttpHeaders } from 'node:http';
|
||||
|
||||
export type CurlExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
encoding: 'utf8' | 'buffer';
|
||||
maxBuffer?: number;
|
||||
timeout?: number;
|
||||
},
|
||||
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void,
|
||||
) => { kill: (signal?: NodeJS.Signals) => unknown };
|
||||
|
||||
type CancellationTokenLike = {
|
||||
createPromise: <T>(
|
||||
callback: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => void,
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
type CurlDownloadOptions = {
|
||||
headers?: OutgoingHttpHeaders | null;
|
||||
sha2?: string | null;
|
||||
sha512?: string | null;
|
||||
cancellationToken: CancellationTokenLike;
|
||||
};
|
||||
|
||||
export type CurlHttpExecutor = {
|
||||
request: (
|
||||
options: RequestOptions,
|
||||
cancellationToken?: CancellationTokenLike,
|
||||
data?: Record<string, unknown> | null,
|
||||
) => Promise<string | null>;
|
||||
download: (url: URL, destination: string, options: CurlDownloadOptions) => Promise<string>;
|
||||
downloadToBuffer: (url: URL, options: CurlDownloadOptions) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
function requestOptionsToUrl(options: RequestOptions): string {
|
||||
const protocol = options.protocol ?? 'https:';
|
||||
const hostname = options.hostname ?? options.host;
|
||||
if (!hostname) throw new Error('Updater request is missing a hostname.');
|
||||
const port = options.port ? `:${options.port}` : '';
|
||||
const requestPath = options.path ?? '/';
|
||||
return `${protocol}//${hostname}${port}${requestPath}`;
|
||||
}
|
||||
|
||||
function addHeaderArgs(
|
||||
args: string[],
|
||||
headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined,
|
||||
): void {
|
||||
if (Array.isArray(headers)) {
|
||||
for (let index = 0; index < headers.length; index += 2) {
|
||||
const name = headers[index];
|
||||
const value = headers[index + 1];
|
||||
if (name !== undefined && value !== undefined) {
|
||||
args.push('--header', `${name}: ${value}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
if (value === undefined) continue;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
args.push('--header', `${name}: ${String(item)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseArgs(timeoutMs?: number): string[] {
|
||||
const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30'];
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
args.push('--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function runCurl<T>(options: {
|
||||
execFile: CurlExecFile;
|
||||
curlPath: string;
|
||||
args: readonly string[];
|
||||
encoding: 'utf8' | 'buffer';
|
||||
maxBuffer?: number;
|
||||
timeout?: number;
|
||||
cancellationToken?: CancellationTokenLike;
|
||||
}): Promise<T> {
|
||||
const run = (
|
||||
resolve: (value: T) => void,
|
||||
reject: (error: Error) => void,
|
||||
onCancel: (callback: () => void) => void,
|
||||
) => {
|
||||
const child = options.execFile(
|
||||
options.curlPath,
|
||||
options.args,
|
||||
{
|
||||
encoding: options.encoding,
|
||||
maxBuffer: options.maxBuffer,
|
||||
timeout: options.timeout,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
const safeFallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||
reject(new Error(stderrMessage.trim() || safeFallback));
|
||||
return;
|
||||
}
|
||||
resolve(stdout as T);
|
||||
},
|
||||
);
|
||||
onCancel(() => {
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
};
|
||||
|
||||
if (options.cancellationToken) {
|
||||
return options.cancellationToken.createPromise<T>(run);
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => run(resolve, reject, () => {}));
|
||||
}
|
||||
|
||||
export function createCurlHttpExecutor(
|
||||
options: {
|
||||
execFile?: CurlExecFile;
|
||||
curlPath?: string;
|
||||
mkdir?: (targetPath: string) => Promise<unknown>;
|
||||
readFile?: (targetPath: string) => Promise<Buffer>;
|
||||
} = {},
|
||||
): CurlHttpExecutor {
|
||||
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
|
||||
const curlPath = options.curlPath ?? '/usr/bin/curl';
|
||||
const mkdir =
|
||||
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
|
||||
const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath));
|
||||
|
||||
async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) {
|
||||
if (!downloadOptions.sha512 && !downloadOptions.sha2) return;
|
||||
const data = await readFile(destination);
|
||||
if (downloadOptions.sha512) {
|
||||
const actual = createHash('sha512').update(data).digest('base64');
|
||||
if (actual !== downloadOptions.sha512) {
|
||||
throw new Error(`sha512 mismatch: expected ${downloadOptions.sha512}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
if (downloadOptions.sha2) {
|
||||
const actual = createHash('sha256').update(data).digest('hex');
|
||||
if (actual !== downloadOptions.sha2.toLowerCase()) {
|
||||
throw new Error(`sha2 mismatch: expected ${downloadOptions.sha2}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request(requestOptions, cancellationToken, data): Promise<string | null> {
|
||||
const args = buildBaseArgs(requestOptions.timeout);
|
||||
addHeaderArgs(args, requestOptions.headers);
|
||||
if (requestOptions.method && requestOptions.method !== 'GET') {
|
||||
args.push('--request', requestOptions.method);
|
||||
}
|
||||
if (data) {
|
||||
args.push('--data-binary', JSON.stringify(data));
|
||||
}
|
||||
args.push(requestOptionsToUrl(requestOptions));
|
||||
const result = await runCurl<string>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: requestOptions.timeout,
|
||||
cancellationToken,
|
||||
});
|
||||
return result.length === 0 ? null : result;
|
||||
},
|
||||
async download(url, destination, downloadOptions): Promise<string> {
|
||||
await mkdir(path.dirname(destination));
|
||||
const args = buildBaseArgs();
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push('--output', destination, url.href);
|
||||
await runCurl<Buffer>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 1024 * 1024,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
await verifyDownloadedFile(destination, downloadOptions);
|
||||
return destination;
|
||||
},
|
||||
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
|
||||
const args = buildBaseArgs();
|
||||
addHeaderArgs(args, downloadOptions.headers);
|
||||
args.push(url.href);
|
||||
return await runCurl<Buffer>({
|
||||
execFile,
|
||||
curlPath,
|
||||
args,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 600 * 1024 * 1024,
|
||||
cancellationToken: downloadOptions.cancellationToken,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElectronNetFetch } from './fetch-adapter';
|
||||
import type { FetchResponseLike } from './release-assets';
|
||||
|
||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
||||
const calls: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
||||
const response: FetchResponseLike = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => 'ok',
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
|
||||
const fetch = createElectronNetFetch({
|
||||
fetch: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', {
|
||||
headers: { 'User-Agent': 'SubMiner updater' },
|
||||
});
|
||||
|
||||
assert.equal(result, response);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
url: 'https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
init: { headers: { 'User-Agent': 'SubMiner updater' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FetchLike, FetchResponseLike } from './release-assets';
|
||||
|
||||
export interface ElectronNetFetchLike {
|
||||
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
}
|
||||
|
||||
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
|
||||
return (url, init) => net.fetch(url, init);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
|
||||
test('macOS release metadata fetch is skipped only when native updater is unsupported', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('darwin', {
|
||||
available: true,
|
||||
version: '0.15.0',
|
||||
canUpdate: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-macOS release metadata fetch is not gated by native updater support', () => {
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('linux', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFetchReleaseMetadataForPlatform('win32', {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
type AppUpdateMetadata = {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
export function shouldFetchReleaseMetadataForPlatform(
|
||||
platform: NodeJS.Platform,
|
||||
appUpdate: AppUpdateMetadata,
|
||||
): boolean {
|
||||
if (platform !== 'darwin') {
|
||||
return true;
|
||||
}
|
||||
return appUpdate.canUpdate !== false;
|
||||
}
|
||||
@@ -151,11 +151,72 @@ test('manual update check does not prompt restart when only launcher updates', a
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, [
|
||||
'available-dialog:0.15.0',
|
||||
'launcher:stable',
|
||||
'manual-install:0.15.0',
|
||||
]);
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check can skip release metadata after unsupported app updater', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
|
||||
fetchLatestStableRelease: async () => {
|
||||
calls.push('fetch-release');
|
||||
return {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
} as Partial<UpdateServiceDeps>);
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['no-update:0.14.0']);
|
||||
});
|
||||
|
||||
test('manual update check fetches release metadata after app metadata errors', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => {
|
||||
throw new Error('latest-mac.yml missing');
|
||||
},
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
|
||||
fetchLatestStableRelease: async () => {
|
||||
calls.push('fetch-release');
|
||||
return {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
} as Partial<UpdateServiceDeps>);
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, ['fetch-release', 'available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check reports non-Error failures safely', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
downloadAppUpdate: async () => {
|
||||
throw 'download rejected';
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.deepEqual(result, { status: 'failed', error: 'download rejected' });
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'failed:download rejected']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
|
||||
@@ -30,15 +30,24 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type AppUpdateMetadata = {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate?: boolean;
|
||||
};
|
||||
|
||||
export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
checkAppUpdate: (
|
||||
channel: UpdateChannel,
|
||||
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
|
||||
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
|
||||
shouldFetchReleaseMetadata?: (input: {
|
||||
request: UpdateCheckRequest;
|
||||
channel: UpdateChannel;
|
||||
appUpdate: AppUpdateMetadata;
|
||||
}) => boolean;
|
||||
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
@@ -112,22 +121,23 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [appUpdate, release] = await Promise.all([
|
||||
deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
canUpdate: false,
|
||||
};
|
||||
}),
|
||||
deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const appUpdate: AppUpdateMetadata = await deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
};
|
||||
});
|
||||
const shouldFetchReleaseMetadata =
|
||||
deps.shouldFetchReleaseMetadata?.({ request, channel, appUpdate }) ?? true;
|
||||
const release = shouldFetchReleaseMetadata
|
||||
? await deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${summarizeError(error)}`);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const currentVersion = deps.getCurrentVersion();
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
@@ -181,7 +191,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
const message = summarizeError(error);
|
||||
if (isAutomatic) {
|
||||
deps.log(`Automatic update check failed: ${message}`);
|
||||
} else {
|
||||
|
||||
@@ -151,6 +151,7 @@ export interface AppState {
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
jellyfinSetupWindow: BrowserWindow | null;
|
||||
firstRunSetupWindow: BrowserWindow | null;
|
||||
configSettingsWindow: BrowserWindow | null;
|
||||
yomitanParserReadyPromise: Promise<void> | null;
|
||||
yomitanParserInitPromise: Promise<boolean> | null;
|
||||
mpvClient: MpvIpcClient | null;
|
||||
@@ -235,6 +236,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
anilistSetupWindow: null,
|
||||
jellyfinSetupWindow: null,
|
||||
firstRunSetupWindow: null,
|
||||
configSettingsWindow: null,
|
||||
yomitanParserReadyPromise: null,
|
||||
yomitanParserInitPromise: null,
|
||||
mpvClient: null,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('settings preload stays sandbox-compatible by avoiding local runtime imports', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload-settings.ts'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /from\s+['"]\.\/shared\/ipc\/contracts(?:\.(?:js|ts))?['"]/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type {
|
||||
ConfigSettingsAPI,
|
||||
ConfigSettingsPatch,
|
||||
ConfigSettingsSaveResult,
|
||||
ConfigSettingsSnapshot,
|
||||
} from './types/settings';
|
||||
|
||||
const SETTINGS_IPC_CHANNELS = {
|
||||
getSnapshot: 'config:get-settings-snapshot',
|
||||
savePatch: 'config:save-settings-patch',
|
||||
openFile: 'config:open-settings-file',
|
||||
openWindow: 'config:open-settings-window',
|
||||
} as const;
|
||||
|
||||
const configSettingsAPI: ConfigSettingsAPI = {
|
||||
getSnapshot: (): Promise<ConfigSettingsSnapshot> =>
|
||||
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getSnapshot),
|
||||
savePatch: (patch: ConfigSettingsPatch): Promise<ConfigSettingsSaveResult> =>
|
||||
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.savePatch, patch),
|
||||
openSettingsFile: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openFile),
|
||||
openSettingsWindow: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openWindow),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self';"
|
||||
/>
|
||||
<title>SubMiner Configuration</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="settings-shell">
|
||||
<aside class="settings-nav" aria-label="Configuration categories">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Configuration</div>
|
||||
</div>
|
||||
<nav id="categoryNav" class="category-nav"></nav>
|
||||
</aside>
|
||||
<section class="settings-main">
|
||||
<header class="settings-toolbar">
|
||||
<div class="toolbar-title-block">
|
||||
<h1 id="categoryTitle">Configuration</h1>
|
||||
<div id="categoryMeta" class="toolbar-meta"></div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<input
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search settings"
|
||||
/>
|
||||
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
|
||||
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="statusBanner" class="status-banner hidden" role="status"></div>
|
||||
<div id="warningsPanel" class="warnings-panel hidden"></div>
|
||||
<div id="settingsContent" class="settings-content"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
|
||||
test('parseOptionalNumberInputValue treats empty input as unset', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue(''), { ok: true, value: undefined });
|
||||
assert.deepEqual(parseOptionalNumberInputValue(' '), { ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test('parseOptionalNumberInputValue parses finite numeric input', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue('42'), { ok: true, value: 42 });
|
||||
assert.deepEqual(parseOptionalNumberInputValue(' 3.5 '), { ok: true, value: 3.5 });
|
||||
});
|
||||
|
||||
test('parseOptionalNumberInputValue rejects invalid numeric input', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue('abc'), { ok: false });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type OptionalNumberInputParseResult =
|
||||
| {
|
||||
ok: true;
|
||||
value: number | undefined;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
};
|
||||
|
||||
export function parseOptionalNumberInputValue(value: string): OptionalNumberInputParseResult {
|
||||
const raw = value.trim();
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
const next = Number(raw);
|
||||
if (!Number.isFinite(next)) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
setDraftValue,
|
||||
getDirtyOperations,
|
||||
} from './settings-model';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
|
||||
const fields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
label: 'Pause on subtitle hover',
|
||||
description: 'Pause while hovering subtitles.',
|
||||
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
category: 'viewing',
|
||||
section: 'Playback pause behavior',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'hot-reload',
|
||||
},
|
||||
{
|
||||
id: 'ankiConnect.enabled',
|
||||
label: 'Enable AnkiConnect',
|
||||
description: 'Enable Anki integration.',
|
||||
configPath: 'ankiConnect.enabled',
|
||||
category: 'mining-anki',
|
||||
section: 'Connection',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'restart',
|
||||
},
|
||||
];
|
||||
|
||||
test('filterSettingsFields searches label, section, and config path', () => {
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map(
|
||||
(field) => field.configPath,
|
||||
),
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'viewing', query: 'anki' }), []);
|
||||
});
|
||||
|
||||
test('settings draft tracks dirty set and emits save operations', () => {
|
||||
const draft = createSettingsDraft({
|
||||
'subtitleStyle.autoPauseVideoOnHover': true,
|
||||
});
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', false);
|
||||
assert.deepEqual(getDirtyOperations(draft), [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
||||
assert.deepEqual(getDirtyOperations(draft), []);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
|
||||
export interface SettingsFilter {
|
||||
category: ConfigSettingsCategory;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface SettingsDraft {
|
||||
readonly initialValues: Record<string, ConfigSettingsSnapshotValue>;
|
||||
readonly values: Record<string, ConfigSettingsSnapshotValue>;
|
||||
readonly resetPaths: Set<string>;
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string | undefined): string {
|
||||
return (query ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function filterSettingsFields(
|
||||
fields: ConfigSettingsField[],
|
||||
filter: SettingsFilter,
|
||||
): ConfigSettingsField[] {
|
||||
const query = normalizeQuery(filter.query);
|
||||
return fields.filter((field) => {
|
||||
if (field.category !== filter.category || field.legacyHidden) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
field.label,
|
||||
field.description,
|
||||
field.configPath,
|
||||
field.section,
|
||||
field.enumValues?.join(' ') ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSettingsDraft(
|
||||
values: Record<string, ConfigSettingsSnapshotValue>,
|
||||
): SettingsDraft {
|
||||
return {
|
||||
initialValues: structuredClone(values),
|
||||
values: structuredClone(values),
|
||||
resetPaths: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function setDraftValue(
|
||||
draft: SettingsDraft,
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): void {
|
||||
draft.values[path] = value;
|
||||
draft.resetPaths.delete(path);
|
||||
}
|
||||
|
||||
export function resetDraftPath(draft: SettingsDraft, path: string, defaultValue: unknown): void {
|
||||
draft.values[path] = structuredClone(defaultValue);
|
||||
draft.resetPaths.add(path);
|
||||
}
|
||||
|
||||
export function getDirtyOperations(draft: SettingsDraft): ConfigSettingsPatchOperation[] {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const paths = new Set([...Object.keys(draft.initialValues), ...Object.keys(draft.values)]);
|
||||
|
||||
for (const path of [...paths].sort()) {
|
||||
if (draft.resetPaths.has(path)) {
|
||||
operations.push({ op: 'reset', path });
|
||||
continue;
|
||||
}
|
||||
if (!valuesEqual(draft.values[path], draft.initialValues[path])) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value: draft.values[path],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import type {
|
||||
ConfigSettingsAPI,
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshot,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
getDirtyOperations,
|
||||
resetDraftPath,
|
||||
setDraftValue,
|
||||
type SettingsDraft,
|
||||
} from './settings-model';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
configSettingsAPI: ConfigSettingsAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
viewing: 'Viewing',
|
||||
'mining-anki': 'Mining & Anki',
|
||||
'playback-sources': 'Playback & Sources',
|
||||
input: 'Input',
|
||||
integrations: 'Integrations',
|
||||
'tracking-app': 'Tracking & App',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'viewing',
|
||||
'mining-anki',
|
||||
'playback-sources',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
'advanced',
|
||||
];
|
||||
|
||||
const state: {
|
||||
snapshot: ConfigSettingsSnapshot | null;
|
||||
draft: SettingsDraft | null;
|
||||
category: ConfigSettingsCategory;
|
||||
query: string;
|
||||
inputErrors: Map<string, string>;
|
||||
} = {
|
||||
snapshot: null,
|
||||
draft: null,
|
||||
category: 'viewing',
|
||||
query: '',
|
||||
inputErrors: new Map(),
|
||||
};
|
||||
|
||||
function getElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing settings element: ${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const dom = {
|
||||
categoryNav: getElement<HTMLElement>('categoryNav'),
|
||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||
settingsContent: getElement<HTMLElement>('settingsContent'),
|
||||
};
|
||||
|
||||
function isSecretSnapshotValue(
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): value is { configured: boolean } {
|
||||
return Boolean(value && typeof value === 'object' && 'configured' in value);
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
||||
dom.statusBanner.textContent = message;
|
||||
dom.statusBanner.className = `status-banner ${tone}`;
|
||||
}
|
||||
|
||||
function clearStatus(): void {
|
||||
dom.statusBanner.textContent = '';
|
||||
dom.statusBanner.className = 'status-banner hidden';
|
||||
}
|
||||
|
||||
function getDirtyCount(): number {
|
||||
return state.draft ? getDirtyOperations(state.draft).length : 0;
|
||||
}
|
||||
|
||||
function syncSaveButton(): void {
|
||||
const dirtyCount = getDirtyCount();
|
||||
dom.saveButton.disabled = dirtyCount === 0 || state.inputErrors.size > 0;
|
||||
dom.saveButton.textContent = dirtyCount > 0 ? `Save ${dirtyCount}` : 'Save';
|
||||
}
|
||||
|
||||
function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
tagName: K,
|
||||
className?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function createFieldMeta(field: ConfigSettingsField): HTMLElement {
|
||||
const meta = createElement('div', 'field-meta');
|
||||
const path = createElement('code');
|
||||
path.textContent = field.configPath;
|
||||
meta.append(path);
|
||||
|
||||
const restart = createElement('span', `restart-chip ${field.restartBehavior}`);
|
||||
restart.textContent = field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart';
|
||||
meta.append(restart);
|
||||
|
||||
if (field.advanced) {
|
||||
const advanced = createElement('span', 'advanced-chip');
|
||||
advanced.textContent = 'Advanced';
|
||||
meta.append(advanced);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
return state.draft?.values[field.configPath] ?? field.defaultValue;
|
||||
}
|
||||
|
||||
function setFieldError(path: string, message: string | null): void {
|
||||
if (message) {
|
||||
state.inputErrors.set(path, message);
|
||||
} else {
|
||||
state.inputErrors.delete(path);
|
||||
}
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
||||
if (!state.draft) return;
|
||||
setDraftValue(state.draft, path, value);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderJsonInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||
textarea.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
} catch {
|
||||
textarea.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid JSON');
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderStringListInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||
textarea.addEventListener('input', () => {
|
||||
updateDraft(
|
||||
field.configPath,
|
||||
textarea.value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderControl(field: ConfigSettingsField): HTMLElement {
|
||||
const value = valueForField(field);
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(value);
|
||||
input.addEventListener('change', () => updateDraft(field.configPath, input.checked));
|
||||
const track = createElement('span', 'switch-track');
|
||||
label.append(input, track);
|
||||
return label;
|
||||
}
|
||||
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
input.value = typeof value === 'number' ? String(value) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
input.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
updateDraft(field.configPath, next.value);
|
||||
} else {
|
||||
input.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid number');
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
if (field.control === 'select') {
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const enumValue of field.enumValues ?? []) {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = enumValue;
|
||||
option.textContent = enumValue;
|
||||
option.selected = enumValue === value;
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => updateDraft(field.configPath, select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field.control === 'string-list') {
|
||||
return renderStringListInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'json') {
|
||||
return renderJsonInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value));
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = field.control === 'secret' ? 'password' : field.control;
|
||||
if (field.control === 'secret') {
|
||||
input.placeholder =
|
||||
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value.trim().length === 0) {
|
||||
if (state.draft) {
|
||||
setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]);
|
||||
}
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
updateDraft(field.configPath, input.value);
|
||||
});
|
||||
} else {
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
input.addEventListener('input', () => updateDraft(field.configPath, input.value));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.warningsPanel.replaceChildren();
|
||||
if (snapshot.warnings.length === 0) {
|
||||
dom.warningsPanel.className = 'warnings-panel hidden';
|
||||
return;
|
||||
}
|
||||
|
||||
const title = createElement('div', 'warnings-title');
|
||||
title.textContent = `${snapshot.warnings.length} validation warning${
|
||||
snapshot.warnings.length === 1 ? '' : 's'
|
||||
}`;
|
||||
dom.warningsPanel.append(title);
|
||||
|
||||
for (const warning of snapshot.warnings.slice(0, 6)) {
|
||||
const row = createElement('div', 'warning-row');
|
||||
const path = createElement('code');
|
||||
path.textContent = warning.path;
|
||||
const message = createElement('span');
|
||||
message.textContent = warning.message;
|
||||
row.append(path, message);
|
||||
dom.warningsPanel.append(row);
|
||||
}
|
||||
dom.warningsPanel.className = 'warnings-panel';
|
||||
}
|
||||
|
||||
function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.categoryNav.replaceChildren();
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const count = snapshot.fields.filter(
|
||||
(field) => field.category === category && !field.legacyHidden,
|
||||
).length;
|
||||
if (count === 0) continue;
|
||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||
button.type = 'button';
|
||||
button.classList.toggle('active', state.category === category);
|
||||
const label = createElement('span');
|
||||
label.textContent = CATEGORY_LABELS[category];
|
||||
const badge = createElement('strong');
|
||||
badge.textContent = String(count);
|
||||
button.append(label, badge);
|
||||
button.addEventListener('click', () => {
|
||||
state.category = category;
|
||||
render();
|
||||
});
|
||||
dom.categoryNav.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
const row = createElement('article', 'field-row');
|
||||
const header = createElement('div', 'field-copy');
|
||||
const label = createElement('h3');
|
||||
label.textContent = field.label;
|
||||
const description = createElement('p');
|
||||
description.textContent = field.description;
|
||||
header.append(label, description, createFieldMeta(field));
|
||||
|
||||
const controlWrap = createElement('div', 'field-control');
|
||||
controlWrap.append(renderControl(field));
|
||||
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Reset';
|
||||
resetButton.addEventListener('click', () => {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||
state.inputErrors.delete(field.configPath);
|
||||
render();
|
||||
});
|
||||
controlWrap.append(resetButton);
|
||||
row.append(header, controlWrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.settingsContent.replaceChildren();
|
||||
const fields = filterSettingsFields(snapshot.fields, {
|
||||
category: state.category,
|
||||
query: state.query,
|
||||
});
|
||||
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
|
||||
if (fields.length === 0) {
|
||||
const empty = createElement('div', 'empty-state');
|
||||
empty.textContent = 'No matching settings';
|
||||
dom.settingsContent.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = new Map<string, ConfigSettingsField[]>();
|
||||
for (const field of fields) {
|
||||
const sectionFields = sections.get(field.section) ?? [];
|
||||
sectionFields.push(field);
|
||||
sections.set(field.section, sectionFields);
|
||||
}
|
||||
|
||||
for (const [section, sectionFields] of sections) {
|
||||
const sectionEl = createElement('section', 'settings-section');
|
||||
const title = createElement('h2');
|
||||
title.textContent = section;
|
||||
sectionEl.append(title);
|
||||
for (const field of sectionFields) {
|
||||
sectionEl.append(renderField(field));
|
||||
}
|
||||
dom.settingsContent.append(sectionEl);
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = state.snapshot;
|
||||
if (!snapshot) return;
|
||||
renderCategoryNav(snapshot);
|
||||
renderWarnings(snapshot);
|
||||
renderSettingsContent(snapshot);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
clearStatus();
|
||||
const snapshot = await window.configSettingsAPI.getSnapshot();
|
||||
state.snapshot = snapshot;
|
||||
state.draft = createSettingsDraft(snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
render();
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!state.draft) return;
|
||||
const operations: ConfigSettingsPatchOperation[] = getDirtyOperations(state.draft);
|
||||
if (operations.length === 0) return;
|
||||
|
||||
dom.saveButton.disabled = true;
|
||||
setStatus('Saving...', 'info');
|
||||
try {
|
||||
const result = await window.configSettingsAPI.savePatch({ operations });
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
||||
} finally {
|
||||
syncSaveButton();
|
||||
}
|
||||
}
|
||||
|
||||
dom.searchInput.addEventListener('input', () => {
|
||||
state.query = dom.searchInput.value;
|
||||
render();
|
||||
});
|
||||
dom.saveButton.addEventListener('click', () => {
|
||||
void save();
|
||||
});
|
||||
dom.openFileButton.addEventListener('click', () => {
|
||||
void window.configSettingsAPI.openSettingsFile();
|
||||
});
|
||||
|
||||
void loadSnapshot().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Catppuccin Macchiato */
|
||||
--ctp-rosewater: #f4dbd6;
|
||||
--ctp-flamingo: #f0c6c6;
|
||||
--ctp-pink: #f5bde6;
|
||||
--ctp-mauve: #c6a0f6;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-maroon: #ee99a0;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-teal: #8bd5ca;
|
||||
--ctp-sky: #91d7e3;
|
||||
--ctp-sapphire: #7dc4e4;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface2: #5b6078;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
|
||||
/* Semantic */
|
||||
--bg: var(--ctp-base);
|
||||
--panel: rgba(36, 39, 58, 0.85);
|
||||
--panel-elevated: rgba(54, 58, 79, 0.55);
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--line-soft: rgba(110, 115, 141, 0.14);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
--faint: var(--ctp-overlay1);
|
||||
--accent: var(--ctp-blue);
|
||||
--accent-strong: var(--ctp-lavender);
|
||||
--highlight: var(--ctp-mauve);
|
||||
--danger: var(--ctp-red);
|
||||
--ok: var(--ctp-green);
|
||||
--warn: var(--ctp-peach);
|
||||
--shadow: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--ctp-base);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
background-color: rgba(110, 115, 141, 0.35);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(138, 173, 244, 0.45);
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 244px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 22px 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
padding: 6px 8px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 21px;
|
||||
font-weight: 820;
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.category-button:hover {
|
||||
border-color: var(--line-soft);
|
||||
background: rgba(138, 173, 244, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.category-button.active {
|
||||
border-color: rgba(138, 173, 244, 0.42);
|
||||
background: rgba(138, 173, 244, 0.14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.category-button strong {
|
||||
min-width: 24px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(110, 115, 141, 0.2);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-button.active strong {
|
||||
background: rgba(138, 173, 244, 0.22);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 78px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.toolbar-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.toolbar-meta {
|
||||
margin-top: 5px;
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.config-input,
|
||||
.config-textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.search-input::placeholder,
|
||||
.config-input::placeholder,
|
||||
.config-textarea::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 210px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
width: min(320px, 100%);
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
width: min(420px, 100%);
|
||||
min-height: 138px;
|
||||
padding: 9px 11px;
|
||||
resize: vertical;
|
||||
font-family:
|
||||
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
|
||||
monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.config-textarea.compact {
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
.search-input:hover,
|
||||
.config-input:hover,
|
||||
.config-textarea:hover {
|
||||
border-color: rgba(138, 173, 244, 0.32);
|
||||
}
|
||||
|
||||
.search-input:focus,
|
||||
.config-input:focus,
|
||||
.config-textarea:focus {
|
||||
border-color: rgba(138, 173, 244, 0.65);
|
||||
background: rgba(24, 25, 38, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
|
||||
select.config-input {
|
||||
appearance: none;
|
||||
padding-right: 32px;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'><path d='M1 1l4 4 4-4' stroke='%23a5adcb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select.config-input option {
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.invalid,
|
||||
.invalid:focus {
|
||||
border-color: rgba(237, 135, 150, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(237, 135, 150, 0.12);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 60ms ease;
|
||||
}
|
||||
|
||||
.primary-button:active,
|
||||
.secondary-button:active,
|
||||
.reset-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
min-width: 92px;
|
||||
padding: 0 16px;
|
||||
border-color: transparent;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
border-color: var(--line);
|
||||
background: rgba(54, 58, 79, 0.55);
|
||||
color: var(--ctp-overlay0);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
padding: 0 13px;
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secondary-button:hover,
|
||||
.reset-button:hover {
|
||||
border-color: rgba(138, 173, 244, 0.45);
|
||||
background: rgba(73, 77, 100, 0.6);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.status-banner,
|
||||
.warnings-panel {
|
||||
margin: 14px 24px 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 11px 14px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.status-banner.success {
|
||||
border-color: rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
border-color: rgba(237, 135, 150, 0.55);
|
||||
background: rgba(237, 135, 150, 0.1);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.warnings-panel {
|
||||
padding: 12px 14px;
|
||||
border-color: rgba(238, 212, 159, 0.32);
|
||||
background: rgba(238, 212, 159, 0.07);
|
||||
}
|
||||
|
||||
.warnings-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warning-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgba(24, 25, 38, 0.7);
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--ctp-teal);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 32px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--ctp-mantle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--ctp-crust);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
color: var(--ctp-lavender);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
padding: 16px 16px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.settings-section h2 + .field-row {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.field-copy h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ctp-text);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.field-copy p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.restart-chip,
|
||||
.advanced-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.restart-chip.hot-reload {
|
||||
border-color: rgba(166, 218, 149, 0.42);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.restart-chip.restart {
|
||||
border-color: rgba(245, 169, 127, 0.42);
|
||||
background: rgba(245, 169, 127, 0.1);
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.advanced-chip {
|
||||
border-color: rgba(198, 160, 246, 0.4);
|
||||
background: rgba(198, 160, 246, 0.1);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch-control input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.switch-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track {
|
||||
border-color: rgba(138, 173, 244, 0.6);
|
||||
background: rgba(138, 173, 244, 0.3);
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track::after {
|
||||
transform: translateX(20px);
|
||||
background: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.switch-control input:focus-visible + .switch-track {
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--ctp-overlay1);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.settings-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
max-height: 200px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-toolbar,
|
||||
.field-row,
|
||||
.field-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,10 @@ export const IPC_CHANNELS = {
|
||||
statsGetMediaSessions: 'stats:get-media-sessions',
|
||||
statsGetMediaDailyRollups: 'stats:get-media-daily-rollups',
|
||||
statsGetMediaCover: 'stats:get-media-cover',
|
||||
getConfigSettingsSnapshot: 'config:get-settings-snapshot',
|
||||
saveConfigSettingsPatch: 'config:save-settings-patch',
|
||||
openConfigSettingsFile: 'config:open-settings-file',
|
||||
openConfigSettingsWindow: 'config:open-settings-window',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from './types/integrations';
|
||||
export * from './types/runtime';
|
||||
export * from './types/runtime-options';
|
||||
export * from './types/session-bindings';
|
||||
export * from './types/settings';
|
||||
export * from './types/subtitle';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ConfigValidationWarning } from './config';
|
||||
|
||||
export type ConfigSettingsCategory =
|
||||
| 'viewing'
|
||||
| 'mining-anki'
|
||||
| 'playback-sources'
|
||||
| 'input'
|
||||
| 'integrations'
|
||||
| 'tracking-app'
|
||||
| 'advanced';
|
||||
|
||||
export type ConfigSettingsControl =
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'color'
|
||||
| 'string-list'
|
||||
| 'json'
|
||||
| 'secret';
|
||||
|
||||
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
|
||||
|
||||
export interface ConfigSettingsField {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
configPath: string;
|
||||
category: ConfigSettingsCategory;
|
||||
section: string;
|
||||
control: ConfigSettingsControl;
|
||||
defaultValue: unknown;
|
||||
enumValues?: readonly string[];
|
||||
restartBehavior: ConfigSettingsRestartBehavior;
|
||||
advanced?: boolean;
|
||||
secret?: boolean;
|
||||
legacyHidden?: boolean;
|
||||
}
|
||||
|
||||
export type ConfigSettingsSnapshotValue = unknown;
|
||||
|
||||
export interface ConfigSettingsSnapshot {
|
||||
configPath: string;
|
||||
fields: ConfigSettingsField[];
|
||||
values: Record<string, ConfigSettingsSnapshotValue>;
|
||||
warnings: ConfigValidationWarning[];
|
||||
}
|
||||
|
||||
export type ConfigSettingsPatchOperation =
|
||||
| {
|
||||
op: 'set';
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
| {
|
||||
op: 'reset';
|
||||
path: string;
|
||||
};
|
||||
|
||||
export interface ConfigSettingsPatch {
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
}
|
||||
|
||||
export interface ConfigSettingsSaveResult {
|
||||
ok: boolean;
|
||||
snapshot?: ConfigSettingsSnapshot;
|
||||
warnings?: ConfigValidationWarning[];
|
||||
error?: string;
|
||||
hotReloadFields: string[];
|
||||
restartRequiredFields: string[];
|
||||
restartRequiredSections?: string[];
|
||||
}
|
||||
|
||||
export interface ConfigSettingsAPI {
|
||||
getSnapshot(): Promise<ConfigSettingsSnapshot>;
|
||||
savePatch(patch: ConfigSettingsPatch): Promise<ConfigSettingsSaveResult>;
|
||||
openSettingsFile(): Promise<boolean>;
|
||||
openSettingsWindow(): Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user