mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(plugins): add JavaScript plugin system with overlay panel (#303)
This commit is contained in:
1 parent
90fc7cf940
commit
a9a4c4f083
110 files changed
+5897
-344
No files matched your search
@@ -37,6 +37,7 @@ const {
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
plugins,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const {
|
||||
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
plugins,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notifications: {
|
||||
overlayPosition: 'top-right',
|
||||
},
|
||||
plugins: {},
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||
description: 'Position for in-overlay notification cards.',
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.plugins,
|
||||
description:
|
||||
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
|
||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
PLUGIN_ACTION_PREFIX: '__plugin-action:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
|
||||
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||
key: 'notifications',
|
||||
},
|
||||
{
|
||||
title: 'Plugins',
|
||||
description: [
|
||||
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
|
||||
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
|
||||
],
|
||||
notes: ['Restart SubMiner after changing plugins.'],
|
||||
key: 'plugins',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
|
||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||
import { applyIntegrationConfig } from './resolve/integrations';
|
||||
import { applyPluginsConfig } from './resolve/plugins';
|
||||
import { applyStatsConfig } from './resolve/stats';
|
||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||
import { applyTopLevelConfig } from './resolve/top-level';
|
||||
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
|
||||
applyIntegrationConfig,
|
||||
applyImmersionTrackingConfig,
|
||||
applyStatsConfig,
|
||||
applyPluginsConfig,
|
||||
applyAnkiConnectResolution,
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
|
||||
test('resolveConfig keeps plugin entries and their settings', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
plugins: {
|
||||
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||
dormant: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.plugins, {
|
||||
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||
dormant: { enabled: false, settings: {} },
|
||||
});
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
plugins: {
|
||||
flag: { enabled: 'yes' as never, settings: [] as never },
|
||||
scalar: true as never,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
|
||||
assert.deepEqual(
|
||||
warnings.map((warning) => warning.path),
|
||||
['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ResolveContext } from './context';
|
||||
import { isObject } from './shared';
|
||||
|
||||
/** Keeps well-formed `plugins.<id>` entries; ids are validated again when the plugin loads. */
|
||||
export function applyPluginsConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
if (src.plugins === undefined) return;
|
||||
if (!isObject(src.plugins)) {
|
||||
warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, entry] of Object.entries(src.plugins)) {
|
||||
const path = `plugins.${id}`;
|
||||
if (!isObject(entry)) {
|
||||
warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
|
||||
continue;
|
||||
}
|
||||
if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
|
||||
warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
|
||||
}
|
||||
if (entry.settings !== undefined && !isObject(entry.settings)) {
|
||||
warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
|
||||
}
|
||||
resolved.plugins[id] = {
|
||||
enabled: entry.enabled === true,
|
||||
settings: isObject(entry.settings) ? { ...entry.settings } : {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
|
||||
'keybindings',
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'plugins',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.secondary.css',
|
||||
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('notifications.')) {
|
||||
return { category: 'behavior', section: 'Notifications' };
|
||||
}
|
||||
if (path === 'plugins') {
|
||||
return { category: 'advanced', section: 'Plugins' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user