mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(config): hot-reload safe config updates and document behavior
This commit is contained in:
@@ -258,6 +258,55 @@ test('parses jsonc and warns/falls back on invalid value', () => {
|
||||
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
|
||||
});
|
||||
|
||||
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"logging": {
|
||||
"level": "warn"
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().logging.level, 'warn');
|
||||
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"logging":`,
|
||||
);
|
||||
|
||||
const result = service.reloadConfigStrict();
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) {
|
||||
throw new Error('Expected strict reload to fail on invalid JSONC.');
|
||||
}
|
||||
assert.equal(result.path, configPath);
|
||||
assert.equal(service.getConfig().logging.level, 'warn');
|
||||
});
|
||||
|
||||
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().logging.level, 'error');
|
||||
|
||||
fs.writeFileSync(configPath, '{"logging":');
|
||||
|
||||
const result = service.reloadConfigStrict();
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) {
|
||||
throw new Error('Expected strict reload to fail on invalid JSON.');
|
||||
}
|
||||
assert.equal(result.path, configPath);
|
||||
assert.equal(service.getConfig().logging.level, 'error');
|
||||
});
|
||||
|
||||
test('accepts valid logging.level', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -715,11 +715,16 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: AI translation settings update live while SubMiner is running.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
key: 'ankiConnect',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
|
||||
key: 'shortcuts',
|
||||
},
|
||||
{
|
||||
@@ -737,11 +742,15 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Extra keybindings that are merged with built-in defaults.',
|
||||
'Set command to null to disable a default keybinding.',
|
||||
],
|
||||
notes: [
|
||||
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
|
||||
],
|
||||
key: 'keybindings',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Appearance',
|
||||
description: ['Primary and secondary subtitle styling.'],
|
||||
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
|
||||
key: 'subtitleStyle',
|
||||
},
|
||||
{
|
||||
@@ -750,6 +759,7 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Dual subtitle track options.',
|
||||
'Used by subminer YouTube subtitle generation as secondary language preferences.',
|
||||
],
|
||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||
key: 'secondarySub',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
|
||||
import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||
|
||||
@@ -9,6 +9,19 @@ interface LoadResult {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type ReloadConfigStrictResult =
|
||||
| {
|
||||
ok: true;
|
||||
config: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -91,6 +104,26 @@ export class ConfigService {
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||
const loadResult = this.loadRawConfigStrict();
|
||||
if (!loadResult.ok) {
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
const { config, path: configPath } = loadResult;
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = configPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
return {
|
||||
ok: true,
|
||||
config: this.getConfig(),
|
||||
warnings: [...warnings],
|
||||
path: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
saveRawConfig(config: RawConfig): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
@@ -112,6 +145,20 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
private loadRawConfig(): LoadResult {
|
||||
const strictResult = this.loadRawConfigStrict();
|
||||
if (strictResult.ok) {
|
||||
return strictResult;
|
||||
}
|
||||
return { config: {}, path: strictResult.path };
|
||||
}
|
||||
|
||||
private loadRawConfigStrict():
|
||||
| (LoadResult & { ok: true })
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
path: string;
|
||||
} {
|
||||
const configPath = fs.existsSync(this.configFileJsonc)
|
||||
? this.configFileJsonc
|
||||
: fs.existsSync(this.configFileJson)
|
||||
@@ -119,18 +166,29 @@ export class ConfigService {
|
||||
: this.configFileJsonc;
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { config: {}, path: configPath };
|
||||
return { ok: true, config: {}, path: configPath };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
const parsed = configPath.endsWith('.jsonc')
|
||||
? (() => {
|
||||
const errors: ParseError[] = [];
|
||||
const result = parseJsonc(data, errors);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
|
||||
}
|
||||
return result;
|
||||
})()
|
||||
: JSON.parse(data);
|
||||
return {
|
||||
ok: true,
|
||||
config: isObject(parsed) ? (parsed as Config) : {},
|
||||
path: configPath,
|
||||
};
|
||||
} catch {
|
||||
return { config: {}, path: configPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown parse error';
|
||||
return { ok: false, error: message, path: configPath };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
src/core/services/config-hot-reload.test.ts
Normal file
111
src/core/services/config-hot-reload.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
createConfigHotReloadRuntime,
|
||||
type ConfigHotReloadRuntimeDeps,
|
||||
} from './config-hot-reload';
|
||||
|
||||
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
|
||||
next.websocket.port = prev.websocket.port + 1;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
let reloadCalls = 0;
|
||||
|
||||
const deps: ConfigHotReloadRuntimeDeps = {
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => {
|
||||
reloadCalls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
};
|
||||
},
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
const id = nextTimerId;
|
||||
nextTimerId += 1;
|
||||
pendingTimers.set(id, callback);
|
||||
return id as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: (timeout) => {
|
||||
pendingTimers.delete(timeout as unknown as number);
|
||||
},
|
||||
debounceMs: 25,
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
};
|
||||
|
||||
const runtime = createConfigHotReloadRuntime(deps);
|
||||
runtime.start();
|
||||
assert.equal(reloadCalls, 1);
|
||||
if (!watchedChangeCallback) {
|
||||
throw new Error('Expected watch callback to be registered.');
|
||||
}
|
||||
const trigger = watchedChangeCallback as () => void;
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
assert.equal(pendingTimers.size, 1);
|
||||
|
||||
for (const callback of pendingTimers.values()) {
|
||||
callback();
|
||||
}
|
||||
assert.equal(reloadCalls, 2);
|
||||
});
|
||||
|
||||
test('config hot reload runtime reports invalid config and skips apply', () => {
|
||||
const invalidMessages: string[] = [];
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
|
||||
const runtime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => ({
|
||||
ok: false,
|
||||
error: 'Invalid JSON',
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
debounceMs: 0,
|
||||
onHotReloadApplied: () => {
|
||||
throw new Error('Hot reload should not apply for invalid config.');
|
||||
},
|
||||
onRestartRequired: () => {
|
||||
throw new Error('Restart warning should not trigger for invalid config.');
|
||||
},
|
||||
onInvalidConfig: (message) => {
|
||||
invalidMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(watchedChangeCallback, null);
|
||||
assert.equal(invalidMessages.length, 1);
|
||||
});
|
||||
159
src/core/services/config-hot-reload.ts
Normal file
159
src/core/services/config-hot-reload.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
|
||||
export interface ConfigHotReloadDiff {
|
||||
hotReloadFields: string[];
|
||||
restartRequiredFields: string[];
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntimeDeps {
|
||||
getCurrentConfig: () => ResolvedConfig;
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void };
|
||||
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
||||
clearTimeout: (timeout: NodeJS.Timeout) => void;
|
||||
debounceMs?: number;
|
||||
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
|
||||
onRestartRequired: (fields: string[]) => void;
|
||||
onInvalidConfig: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadRuntime {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
function isEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
|
||||
const hotReloadFields: string[] = [];
|
||||
const restartRequiredFields: string[] = [];
|
||||
|
||||
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
|
||||
hotReloadFields.push('subtitleStyle');
|
||||
}
|
||||
if (!isEqual(prev.keybindings, next.keybindings)) {
|
||||
hotReloadFields.push('keybindings');
|
||||
}
|
||||
if (!isEqual(prev.shortcuts, next.shortcuts)) {
|
||||
hotReloadFields.push('shortcuts');
|
||||
}
|
||||
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
|
||||
hotReloadFields.push('secondarySub.defaultMode');
|
||||
}
|
||||
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
|
||||
hotReloadFields.push('ankiConnect.ai');
|
||||
}
|
||||
|
||||
const keys = new Set([
|
||||
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
|
||||
...(Object.keys(next) as Array<keyof ResolvedConfig>),
|
||||
]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'secondarySub') {
|
||||
const normalizedPrev = {
|
||||
...prev.secondarySub,
|
||||
defaultMode: next.secondarySub.defaultMode,
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.secondarySub)) {
|
||||
restartRequiredFields.push('secondarySub');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'ankiConnect') {
|
||||
const normalizedPrev = {
|
||||
...prev.ankiConnect,
|
||||
ai: next.ankiConnect.ai,
|
||||
};
|
||||
if (!isEqual(normalizedPrev, next.ankiConnect)) {
|
||||
restartRequiredFields.push('ankiConnect');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEqual(prev[key], next[key])) {
|
||||
restartRequiredFields.push(String(key));
|
||||
}
|
||||
}
|
||||
|
||||
return { hotReloadFields, restartRequiredFields };
|
||||
}
|
||||
|
||||
export function createConfigHotReloadRuntime(
|
||||
deps: ConfigHotReloadRuntimeDeps,
|
||||
): ConfigHotReloadRuntime {
|
||||
let watcher: { close: () => void } | null = null;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let watchedPath: string | null = null;
|
||||
const debounceMs = deps.debounceMs ?? 250;
|
||||
|
||||
const reloadWithDiff = () => {
|
||||
const prev = deps.getCurrentConfig();
|
||||
const result = deps.reloadConfigStrict();
|
||||
if (!result.ok) {
|
||||
deps.onInvalidConfig(`Config reload failed: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (watchedPath !== result.path) {
|
||||
watchPath(result.path);
|
||||
}
|
||||
|
||||
const diff = classifyDiff(prev, result.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.onHotReloadApplied(diff, result.config);
|
||||
}
|
||||
if (diff.restartRequiredFields.length > 0) {
|
||||
deps.onRestartRequired(diff.restartRequiredFields);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (timer) {
|
||||
deps.clearTimeout(timer);
|
||||
}
|
||||
timer = deps.setTimeout(() => {
|
||||
timer = null;
|
||||
reloadWithDiff();
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const watchPath = (configPath: string) => {
|
||||
watcher?.close();
|
||||
watcher = deps.watchConfigPath(configPath, scheduleReload);
|
||||
watchedPath = configPath;
|
||||
};
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
if (watcher) {
|
||||
return;
|
||||
}
|
||||
const result = deps.reloadConfigStrict();
|
||||
if (!result.ok) {
|
||||
deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
watchPath(result.path);
|
||||
},
|
||||
stop: () => {
|
||||
if (timer) {
|
||||
deps.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
watcher?.close();
|
||||
watcher = null;
|
||||
watchedPath = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { classifyDiff as classifyConfigHotReloadDiff };
|
||||
@@ -108,3 +108,4 @@ export {
|
||||
createOverlayManager,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
} from './overlay-manager';
|
||||
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
|
||||
|
||||
211
src/main.ts
211
src/main.ts
@@ -23,6 +23,9 @@ import {
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
Menu,
|
||||
Tray,
|
||||
nativeImage,
|
||||
} from 'electron';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
@@ -57,6 +60,7 @@ import type {
|
||||
RuntimeOptionState,
|
||||
MpvSubtitleRenderMetrics,
|
||||
ResolvedConfig,
|
||||
ConfigHotReloadPayload,
|
||||
} from './types';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
@@ -119,6 +123,7 @@ import {
|
||||
runStartupBootstrapRuntime,
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
authenticateWithPasswordRuntime,
|
||||
createConfigHotReloadRuntime,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
@@ -194,6 +199,7 @@ const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||
const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json';
|
||||
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
|
||||
const TRAY_TOOLTIP = 'SubMiner';
|
||||
|
||||
let anilistCurrentMediaKey: string | null = null;
|
||||
let anilistCurrentMediaDurationSec: number | null = null;
|
||||
@@ -357,6 +363,7 @@ const appState = createAppState({
|
||||
mpvSocketPath: getDefaultSocketPath(),
|
||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||
});
|
||||
let appTray: Tray | null = null;
|
||||
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getShortcutsRegistered: () => appState.shortcutsRegistered,
|
||||
@@ -396,6 +403,64 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
|
||||
},
|
||||
});
|
||||
|
||||
const configHotReloadRuntime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => getResolvedConfig(),
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
watchConfigPath: (configPath, onChange) => {
|
||||
const watchTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath);
|
||||
const watcher = fs.watch(watchTarget, (_eventType, filename) => {
|
||||
if (watchTarget === configPath) {
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized =
|
||||
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
|
||||
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
return {
|
||||
close: () => {
|
||||
watcher.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout) => clearTimeout(timeout),
|
||||
debounceMs: 250,
|
||||
onHotReloadApplied: (diff, config) => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
appState.keybindings = payload.keybindings;
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
|
||||
appState.secondarySubMode = payload.secondarySubMode;
|
||||
broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.includes('ankiConnect.ai') && appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch({ ai: config.ankiConnect.ai });
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
broadcastToOverlayWindows('config:hot-reload', payload);
|
||||
}
|
||||
},
|
||||
onRestartRequired: (fields) => {
|
||||
const message = `Config updated; restart required for: ${fields.join(', ')}`;
|
||||
showMpvOsd(message);
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
},
|
||||
onInvalidConfig: (message) => {
|
||||
showMpvOsd(message);
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
},
|
||||
});
|
||||
|
||||
const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({
|
||||
isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
getSearchPaths: () =>
|
||||
@@ -590,6 +655,28 @@ function openRuntimeOptionsPalette(): void {
|
||||
function getResolvedConfig() {
|
||||
return configService.getConfig();
|
||||
}
|
||||
|
||||
function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
if (!config.subtitleStyle) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...config.subtitleStyle,
|
||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
|
||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||
return {
|
||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedJellyfinConfig() {
|
||||
return getResolvedConfig().jellyfin;
|
||||
}
|
||||
@@ -2084,6 +2171,7 @@ const startupState = runStartupBootstrapRuntime(
|
||||
reloadConfig: () => {
|
||||
configService.reloadConfig();
|
||||
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
|
||||
configHotReloadRuntime.start();
|
||||
void refreshAnilistClientSecretState({ force: true });
|
||||
},
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
@@ -2172,11 +2260,13 @@ const startupState = runStartupBootstrapRuntime(
|
||||
},
|
||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
}),
|
||||
onWillQuitCleanup: () => {
|
||||
destroyTray();
|
||||
configHotReloadRuntime.stop();
|
||||
restorePreviousSecondarySubVisibility();
|
||||
globalShortcut.unregisterAll();
|
||||
subtitleWsService.stop();
|
||||
@@ -2224,6 +2314,7 @@ const startupState = runStartupBootstrapRuntime(
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
overlayVisibilityRuntime.updateInvisibleOverlayVisibility();
|
||||
},
|
||||
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -2296,6 +2387,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
|
||||
|
||||
function handleInitialArgs(): void {
|
||||
if (!appState.initialArgs) return;
|
||||
if (appState.backgroundMode) {
|
||||
ensureTray();
|
||||
}
|
||||
if (
|
||||
!appState.texthookerOnlyMode &&
|
||||
appState.immersionTracker &&
|
||||
@@ -2529,6 +2623,103 @@ function createInvisibleWindow(): BrowserWindow {
|
||||
return window;
|
||||
}
|
||||
|
||||
function resolveTrayIconPath(): string | null {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'assets', 'SubMiner.png'),
|
||||
path.join(app.getAppPath(), 'assets', 'SubMiner.png'),
|
||||
path.join(__dirname, '..', 'assets', 'SubMiner.png'),
|
||||
path.join(__dirname, '..', '..', 'assets', 'SubMiner.png'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTrayMenu(): Menu {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Overlay',
|
||||
click: () => {
|
||||
if (!appState.overlayRuntimeInitialized) {
|
||||
initializeOverlayRuntime();
|
||||
}
|
||||
setVisibleOverlayVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: () => {
|
||||
openYomitanSettings();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Runtime Options',
|
||||
click: () => {
|
||||
if (!appState.overlayRuntimeInitialized) {
|
||||
initializeOverlayRuntime();
|
||||
}
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Configure Jellyfin',
|
||||
click: () => {
|
||||
openJellyfinSetupWindow();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Configure AniList',
|
||||
click: () => {
|
||||
openAnilistSetupWindow();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function ensureTray(): void {
|
||||
if (appTray) {
|
||||
appTray.setContextMenu(buildTrayMenu());
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = resolveTrayIconPath();
|
||||
let trayIcon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
|
||||
if (trayIcon.isEmpty()) {
|
||||
logger.warn('Tray icon asset not found; using empty icon placeholder.');
|
||||
}
|
||||
if (process.platform === 'linux' && !trayIcon.isEmpty()) {
|
||||
trayIcon = trayIcon.resize({ width: 20, height: 20 });
|
||||
}
|
||||
|
||||
appTray = new Tray(trayIcon);
|
||||
appTray.setToolTip(TRAY_TOOLTIP);
|
||||
appTray.setContextMenu(buildTrayMenu());
|
||||
appTray.on('click', () => {
|
||||
if (!appState.overlayRuntimeInitialized) {
|
||||
initializeOverlayRuntime();
|
||||
}
|
||||
setVisibleOverlayVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
function destroyTray(): void {
|
||||
if (!appTray) {
|
||||
return;
|
||||
}
|
||||
appTray.destroy();
|
||||
appTray = null;
|
||||
}
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
if (appState.overlayRuntimeInitialized) {
|
||||
return;
|
||||
@@ -2600,6 +2791,12 @@ function registerGlobalShortcuts(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGlobalAndOverlayShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
registerGlobalShortcuts();
|
||||
syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
function getConfiguredShortcuts() {
|
||||
return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG);
|
||||
}
|
||||
@@ -2916,17 +3113,7 @@ registerIpcRuntimeServices({
|
||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||
getSubtitleStyle: () => {
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
if (!resolvedConfig.subtitleStyle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...resolvedConfig.subtitleStyle,
|
||||
nPlusOneColor: resolvedConfig.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: resolvedConfig.ankiConnect.nPlusOne.knownWord,
|
||||
enableJlpt: resolvedConfig.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary: resolvedConfig.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
return resolveSubtitleStyleForRenderer(resolvedConfig);
|
||||
},
|
||||
saveSubtitlePosition: (position: unknown) => saveSubtitlePosition(position as SubtitlePosition),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
MpvSubtitleRenderMetrics,
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
} from './types';
|
||||
|
||||
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
|
||||
@@ -236,6 +237,14 @@ const electronAPI: ElectronAPI = {
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
ipcRenderer.send('overlay-content-bounds:report', measurement);
|
||||
},
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||
ipcRenderer.on(
|
||||
'config:hot-reload',
|
||||
(_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => {
|
||||
callback(payload);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
@@ -185,13 +185,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
const keybindings: Keybinding[] = await window.electronAPI.getKeybindings();
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
updateKeybindings(await window.electronAPI.getKeybindings());
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
|
||||
@@ -293,7 +287,17 @@ export function createKeyboardHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
function updateKeybindings(keybindings: Keybinding[]): void {
|
||||
ctx.state.keybindingsMap = new Map();
|
||||
for (const binding of keybindings) {
|
||||
if (binding.command) {
|
||||
ctx.state.keybindingsMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setupMpvInputForwarding,
|
||||
updateKeybindings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
SubsyncManualPayload,
|
||||
ConfigHotReloadPayload,
|
||||
} from '../types';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
import { createMouseHandlers } from './handlers/mouse.js';
|
||||
@@ -196,6 +197,12 @@ async function init(): Promise<void> {
|
||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
});
|
||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
window.electronAPI.onOpenRuntimeOptions(() => {
|
||||
runtimeOptionsModal.openRuntimeOptionsModal().catch(() => {
|
||||
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);
|
||||
|
||||
@@ -709,6 +709,12 @@ export type JimakuDownloadResult =
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; error: JimakuApiError };
|
||||
|
||||
export interface ConfigHotReloadPayload {
|
||||
keybindings: Keybinding[];
|
||||
subtitleStyle: SubtitleStyleConfig | null;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getOverlayLayer: () => 'visible' | 'invisible' | null;
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
@@ -763,6 +769,7 @@ export interface ElectronAPI {
|
||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user