feat(plugins): add JavaScript plugin system with overlay panel (#303)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:09:54 -07:00
1 parent 90fc7cf940
commit a9a4c4f083
110 files changed
+5897 -344

No files matched your search

+2
View File
@@ -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,
+2
View File
@@ -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,
};
+7
View File
@@ -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',
+1
View File
@@ -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.'],
+2
View File
@@ -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;
+33
View File
@@ -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'],
);
});
+30
View File
@@ -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 } : {},
};
}
}
+4
View File
@@ -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' };
}