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

+5
View File
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.includes('setLogLevel:info:config'));
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
});
+17
View File
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
});
});
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'runPluginAction',
payload: { pluginActionId: 'anime/open' },
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
+6
View File
@@ -422,6 +422,12 @@ export function handleCliCommand(
`sessionAction:${args.sessionAction.actionId}`,
'Session action failed',
);
} else if (args.pluginAction) {
dispatchCliSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
`pluginAction:${args.pluginAction}`,
'Plugin action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
+5 -3
View File
@@ -576,8 +576,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const openedModals: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: (modal) => {
closedModals.push(modal);
onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
},
onOverlayModalOpened: (modal) => {
openedModals.push(modal);
@@ -638,7 +638,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
assert.deepEqual(closedModals, ['subsync', 'kiku']);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad');
assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
+16 -7
View File
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
@@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions {
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
@@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(parsedModal, senderWindow);
});
ipc.on(
IPC_CHANNELS.command.overlayModalClosed,
(event: unknown, modal: unknown, instanceId: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(
parsedModal,
senderWindow,
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
);
},
);
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
+16
View File
@@ -486,3 +486,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
});
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
const received: unknown[] = [];
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
await dispatchMpvProtocolMessage(
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
assert.deepEqual(received, [
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
{ reason: 'unknown', playlistEntryId: null, error: null },
]);
});
+25
View File
@@ -1,6 +1,7 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import type { PluginMpvEventMap } from '../../plugin-api';
import { toVideoClock } from './subtitle-clock';
export type MpvMessage = {
@@ -10,8 +11,26 @@ export type MpvMessage = {
args?: unknown;
request_id?: number;
error?: string;
reason?: unknown;
playlist_entry_id?: unknown;
file_error?: unknown;
};
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
export type MpvEndFileReason = PluginMpvEventMap['end-file']['reason'];
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
export type MpvEndFileEvent = PluginMpvEventMap['end-file'];
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
return {
reason,
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
};
}
export const MPV_REQUEST_ID_SUBTEXT = 101;
export const MPV_REQUEST_ID_PATH = 102;
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
@@ -103,6 +122,8 @@ export interface MpvProtocolHandleMessageDeps {
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
emitClientMessage?: (payload: { args: string[] }) => void;
emitEndFile?: (payload: MpvEndFileEvent) => void;
emitFileLoaded?: () => void;
}
type SubtitleTrackCandidate = {
@@ -433,6 +454,10 @@ export async function dispatchMpvProtocolMessage(
if (args.length > 0) {
deps.emitClientMessage?.({ args });
}
} else if (msg.event === 'file-loaded') {
deps.emitFileLoaded?.();
} else if (msg.event === 'end-file') {
deps.emitEndFile?.(parseEndFileEvent(msg));
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
+14 -17
View File
@@ -1,3 +1,4 @@
import type { PluginMpvEventMap } from '../../plugin-api';
import { EventEmitter } from 'events';
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
import {
@@ -120,24 +121,8 @@ export interface MpvIpcClientProtocolDeps {
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap {
'connection-change': { connected: boolean };
'subtitle-change': { text: string; isOverlayVisible: boolean };
'subtitle-timing': { text: string; start: number; end: number };
'time-pos-change': { time: number };
'duration-change': { duration: number };
'pause-change': { paused: boolean };
'fullscreen-change': { fullscreen: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'secondary-subtitle-track-change': { sid: number | null };
'secondary-subtitle-delay-change': { delay: number };
'subtitle-track-list-change': { trackList: unknown[] | null };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
export interface MpvIpcClientEventMap extends PluginMpvEventMap {
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean };
'client-message': { args: string[] };
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -525,6 +510,10 @@ export class MpvIpcClient implements MpvClient {
emitClientMessage: (payload) => {
this.emit('client-message', payload);
},
emitEndFile: (payload) => {
this.emit('end-file', payload);
},
emitFileLoaded: () => this.emit('file-loaded', undefined),
};
}
@@ -604,6 +593,14 @@ export class MpvIpcClient implements MpvClient {
return response.data;
}
async requestCommand(command: (string | number | boolean)[]): Promise<unknown> {
const response = await this.request(command);
if (response.error && response.error !== 'success') {
throw new Error(`MPV command failed: ${response.error}`);
}
return response.data;
}
private failPendingRequests(): void {
for (const [requestId, resolve] of this.pendingRequests.entries()) {
resolve({ request_id: requestId, error: 'disconnected' });
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
// The test runtime (bun or node) doubles as a portable child process.
const runtime = process.execPath;
test('pipes stdin to the child and collects stdout', async () => {
const result = await execPluginCommand(
runtime,
['-e', 'process.stdin.on("data", (d) => process.stdout.write(String(d).toUpperCase()))'],
{ stdin: '字幕 line' },
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '字幕 LINE');
});
test('resolves with a non-zero exit code and stderr', async () => {
const result = await execPluginCommand(runtime, [
'-e',
'process.stderr.write("bad things"); process.exit(3)',
]);
assert.equal(result.exitCode, 3);
assert.equal(result.stderr, 'bad things');
});
test('drops the AppImage library path from the child environment', async () => {
const previous = process.env.LD_LIBRARY_PATH;
process.env.LD_LIBRARY_PATH = '/tmp/.mount_SubMinXXXXXX/usr/lib';
try {
const result = await execPluginCommand(
runtime,
['-e', 'process.stdout.write(String(process.env.LD_LIBRARY_PATH) + "|" + process.env.EXTRA)'],
{ env: { EXTRA: 'kept' } },
);
assert.equal(result.stdout, 'undefined|kept');
} finally {
if (previous === undefined) delete process.env.LD_LIBRARY_PATH;
else process.env.LD_LIBRARY_PATH = previous;
}
});
test('aborting kills the child and rejects with AbortError', async () => {
const controller = new AbortController();
const pending = execPluginCommand(runtime, ['-e', 'setTimeout(() => {}, 60_000)'], {
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await assert.rejects(pending, { name: 'AbortError' });
});
test('force-kills a child that ignores SIGTERM after the grace period', async (t) => {
if (process.platform === 'win32') return t.skip('POSIX signals only');
const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid');
const controller = new AbortController();
const pending = execPluginCommand(
runtime,
[
'-e',
`process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`,
],
{ signal: controller.signal },
100,
);
let pid = 0;
for (let i = 0; i < 100 && !pid; i += 1) {
pid = Number(await readFile(pidFile, 'utf8').catch(() => '0'));
if (!pid) await sleep(20);
}
assert.ok(pid > 0, 'child never reported its pid');
try {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
let alive = true;
for (let i = 0; i < 100 && alive; i += 1) {
await sleep(20);
alive = isRunning(pid);
}
assert.equal(alive, false, 'child survived SIGTERM');
} finally {
if (isRunning(pid)) process.kill(pid, 'SIGKILL');
await rm(path.dirname(pidFile), { recursive: true, force: true });
}
});
function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
test('rejects when the binary does not exist', async () => {
await assert.rejects(
execPluginCommand('subminer-definitely-missing-binary', []),
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
);
});
test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-'));
try {
await writeFile(path.join(dir, 'codex.cmd'), '');
await writeFile(path.join(dir, 'native.exe'), '');
const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' };
const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32');
assert.equal(shim.viaShell, true);
assert.equal(shim.file, 'cmd.exe');
assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']);
assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd')));
assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), {
file: path.join(dir, 'native.exe'),
args: ['-v'],
viaShell: false,
});
assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), {
file: 'codex',
args: ['-'],
viaShell: false,
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+134
View File
@@ -0,0 +1,134 @@
import { type ChildProcess, spawn } from 'node:child_process';
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
resolveWindowsCommand,
} from '../../utils/windows-command-shim';
import type { PluginExecOptions, PluginExecResult } from './types';
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
const DEFAULT_KILL_GRACE_MS = 3_000;
function appendCapped(current: string, chunk: string): string {
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
}
interface SpawnTarget {
file: string;
args: string[];
viaShell: boolean;
}
/**
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
* binaries, and every command on Linux and macOS, are spawned directly.
*/
export function resolvePluginSpawnTarget(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): SpawnTarget {
if (platform !== 'win32') return { file: command, args, viaShell: false };
const resolved = resolveWindowsCommand(command, env);
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
}
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
function killWindowsProcessTree(child: ChildProcess): void {
if (child.pid === undefined) return;
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
}).on('error', () => child.kill());
}
/**
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
* process tree is ended with taskkill instead).
*/
export function execPluginCommand(
command: string,
args: string[],
options: PluginExecOptions = {},
killGraceMs = DEFAULT_KILL_GRACE_MS,
): Promise<PluginExecResult> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(new DOMException('Command aborted.', 'AbortError'));
return;
}
const env = { ...buildSystemBinaryEnv(), ...options.env };
const target = resolvePluginSpawnTarget(command, args, env);
const child = spawn(target.file, target.args, {
cwd: options.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
windowsVerbatimArguments: target.viaShell,
});
let stdout = '';
let stderr = '';
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const settle = (finish: () => void): void => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
options.signal?.removeEventListener('abort', onAbort);
finish();
};
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
const killWith = (error: Error): void => {
settle(() => reject(error));
if (target.viaShell) {
killWindowsProcessTree(child);
return;
}
child.kill('SIGTERM');
const forceKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}, killGraceMs);
forceKill.unref();
child.once('exit', () => clearTimeout(forceKill));
};
function onAbort(): void {
killWith(new DOMException('Command aborted.', 'AbortError'));
}
options.signal?.addEventListener('abort', onAbort, { once: true });
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
timeout = setTimeout(
() => killWith(new Error(`${command} timed out after ${options.timeoutMs} ms.`)),
options.timeoutMs,
);
}
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout = appendCapped(stdout, chunk);
});
child.stderr.on('data', (chunk: string) => {
stderr = appendCapped(stderr, chunk);
});
// A child that exits without reading stdin raises EPIPE here; the exit code tells the story.
child.stdin.on('error', () => {});
child.on('error', (error) => settle(() => reject(error)));
child.on('close', (exitCode) => settle(() => resolve({ exitCode, stdout, stderr })));
child.stdin.end(options.stdin ?? '');
});
}
+57
View File
@@ -0,0 +1,57 @@
import path from 'node:path';
import { PLUGIN_API_VERSION, type PluginManifest } from './types';
const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
/** Plugin ids double as directory names and action prefixes, and action names follow the same rule. */
export function isValidPluginName(id: string): boolean {
return PLUGIN_NAME_PATTERN.test(id);
}
/** Validates parsed `plugin.json` content. Returns an error message instead of throwing. */
export function parsePluginManifest(
raw: unknown,
fallbackName: string,
): { manifest: PluginManifest } | { error: string } {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return { error: 'plugin.json must contain a JSON object.' };
}
const value = raw as Record<string, unknown>;
if (typeof value.main !== 'string' || value.main.trim().length === 0) {
return { error: 'plugin.json needs a "main" entry file.' };
}
if (value.name !== undefined && typeof value.name !== 'string') {
return { error: 'plugin.json "name" must be a string.' };
}
if (value.version !== undefined && typeof value.version !== 'string') {
return { error: 'plugin.json "version" must be a string.' };
}
const apiVersion = value.apiVersion === undefined ? 1 : value.apiVersion;
if (typeof apiVersion !== 'number' || !Number.isInteger(apiVersion) || apiVersion < 1) {
return { error: 'plugin.json "apiVersion" must be a positive integer.' };
}
if (apiVersion !== PLUGIN_API_VERSION) {
return {
error: `Plugin requires API ${apiVersion}; this SubMiner supports API ${PLUGIN_API_VERSION}.`,
};
}
return {
manifest: {
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
version: typeof value.version === 'string' ? value.version : null,
apiVersion,
main: value.main.trim(),
},
};
}
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
const resolved = path.resolve(pluginDir, file);
const relative = path.relative(pluginDir, resolved);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
}
return resolved;
}
+46
View File
@@ -0,0 +1,46 @@
import type { PluginMediaSnapshot } from './types';
/** Keeps plugin snapshots coherent without asynchronous property reads crossing file changes. */
export function createPluginMediaState(onChange: (media: PluginMediaSnapshot | null) => void) {
let nextId = 0;
let current: PluginMediaSnapshot | null = null;
let waitingForLoad = false;
function publish(media: PluginMediaSnapshot | null): void {
current = media && Object.freeze(media);
onChange(current);
}
function start(path: string, title: string | null = null): void {
publish({ playbackId: ++nextId, path, title });
}
return {
current: () => current,
reset(path = '', title: string | null = null): void {
waitingForLoad = false;
if (path) start(path, title);
else if (current) publish(null);
},
pathChanged(path: string): void {
if (!path) {
this.reset();
} else if (path !== current?.path) {
waitingForLoad = true;
start(path);
}
},
titleChanged(title: string | null): void {
if (current && title !== current.title) publish({ ...current, title });
},
fileLoaded(path: string, title: string | null): void {
if (!path) return;
if (waitingForLoad && current?.path === path) {
this.titleChanged(title);
} else {
start(path, title);
}
waitingForLoad = false;
},
};
}
@@ -0,0 +1,791 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { BrowserWindow } from 'electron';
import type { SessionActionDispatchRequest } from '../../../types/runtime';
import { createPluginHost, type PluginHostDeps } from './plugin-host';
import type {
PluginConfigEntry,
PluginContext,
PluginLogger,
PluginMpvEventName,
PluginMpvEventMap,
PluginWindowRequest,
} from './types';
function writePlugin(root: string, id: string, files: Record<string, string>): void {
const dir = path.join(root, id);
mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(path.join(dir, name), content);
}
}
type FakeWindow = ReturnType<typeof createFakeWindow>;
/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */
function createFakeWindow() {
const events = new EventEmitter();
const fake = {
destroyed: false,
isDestroyed: () => fake.destroyed,
destroy: () => {
fake.destroyed = true;
events.emit('closed');
},
once: (event: string, listener: () => void) => events.once(event, listener),
asWindow: () => fake as unknown as BrowserWindow,
};
return fake;
}
function createHarness(
config: Record<string, PluginConfigEntry>,
overrides: Partial<PluginHostDeps<{ marker: string }>> = {},
) {
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
const errors: string[] = [];
const notifications: string[] = [];
const sessionActions: SessionActionDispatchRequest[] = [];
const windowRequests: PluginWindowRequest[] = [];
const windows = new Map<string, FakeWindow>();
const logger: PluginLogger = {
debug: () => {},
info: () => {},
warn: () => {},
error: (message) => errors.push(message),
};
const deps: PluginHostDeps<{ marker: string }> = {
pluginsDir: path.join(root, 'plugins'),
pluginDataDir: path.join(root, 'data'),
getPluginConfig: () => config,
createLogger: () => logger,
isMpvConnected: () => true,
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
requestMpvCommand: async () => null,
ensureMpvRunning: async () => true,
invokeAnkiConnect: async () => null,
dispatchSessionAction: async (request) => {
sessionActions.push(request);
},
getCurrentSubtitle: () => null,
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
notify: (message) => notifications.push(message),
openPanel: () => {
throw new Error('not used');
},
openWindow: (request) => {
windowRequests.push(request);
const existing = windows.get(request.key);
if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false };
const window = createFakeWindow();
windows.set(request.key, window);
return { window: window.asWindow(), created: true };
},
unstable: { marker: 'internals' },
...overrides,
};
return {
root: deps.pluginsDir,
dataRoot: deps.pluginDataDir,
errors,
notifications,
sessionActions,
windowRequests,
windows,
host: createPluginHost(deps),
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}
const enabled = (settings: Record<string, unknown> = {}): PluginConfigEntry => ({
enabled: true,
settings,
});
function deferred() {
let resolve = () => {};
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve: () => resolve() };
}
function createFakeMpvClient() {
const bus = new EventEmitter();
return {
on: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => bus.on(event, listener),
off: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => bus.off(event, listener),
emit: <E extends PluginMpvEventName>(event: E, payload: PluginMpvEventMap[E]) =>
bus.emit(event, payload),
};
}
test('loads enabled plugins and runs their registered actions', async () => {
const h = createHarness({
greeter: enabled({ greeting: 'hi' }),
off: { enabled: false, settings: {} },
});
try {
writePlugin(h.root, 'greeter', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('say', () => ctx.notify(ctx.settings.greeting + ' ' + ctx.unstable.marker));
};`,
});
writePlugin(h.root, 'off', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `throw new Error('disabled plugins must not load');`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('greeter/say'), true);
assert.deepEqual(h.notifications, ['hi internals']);
assert.equal(await h.host.runAction('greeter/missing'), false);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('loads ES module plugins', async () => {
const h = createHarness({ esm: enabled() });
try {
writePlugin(h.root, 'esm', {
'plugin.json': JSON.stringify({ main: 'main.mjs' }),
'main.mjs': `export function activate(ctx) { ctx.actions.register('go', () => ctx.notify('esm')); }`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('esm/go'), true);
assert.deepEqual(h.notifications, ['esm']);
} finally {
h.cleanup();
}
});
test('a failed activation rolls back its registrations without affecting other plugins', async () => {
const h = createHarness({ broken: enabled(), healthy: enabled() });
try {
writePlugin(h.root, 'broken', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('half', () => {});
throw new Error('boom');
};`,
});
writePlugin(h.root, 'healthy', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('ok', () => {}); };`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('broken/half'), false);
assert.equal(await h.host.runAction('healthy/ok'), true);
assert.deepEqual(h.errors, ['Activation failed: boom']);
} finally {
h.cleanup();
}
});
test('rejects entry files outside the plugin directory', async () => {
const h = createHarness({ escape: enabled() });
try {
writePlugin(h.root, 'escape', {
'plugin.json': JSON.stringify({ main: '../elsewhere.cjs' }),
});
await h.host.loadAll();
assert.equal(h.errors.length, 1);
assert.match(h.errors[0]!, /must stay inside/);
} finally {
h.cleanup();
}
});
test('mpv listener errors are contained and unload detaches listeners and calls deactivate', async () => {
const h = createHarness({ watcher: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'watcher', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', ({ paused }) => {
ctx.notify('paused=' + paused);
throw new Error('listener bug');
});
return () => ctx.notify('deactivated');
};`,
});
h.host.attachMpvClient(client);
await h.host.loadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['paused=true']);
assert.deepEqual(h.errors, ['mpv pause-change listener failed: listener bug']);
await h.host.unloadAll();
client.emit('pause-change', { paused: false });
assert.deepEqual(h.notifications, ['paused=true', 'deactivated']);
} finally {
h.cleanup();
}
});
test('unload gives up on a deactivate that never settles and still disposes the plugin', async () => {
const h = createHarness({ stuck: enabled() }, { deactivateTimeoutMs: 20 });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'stuck', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => new Promise(() => {});
};`,
});
h.host.attachMpvClient(client);
await h.host.loadAll();
await h.host.unloadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, []);
// Deactivate gets what is left of the shared unload budget, so the reported ms can be below 20.
assert.equal(h.errors.length, 1);
assert.match(h.errors[0]!, /^deactivate failed: timed out after \d+ ms$/);
} finally {
h.cleanup();
}
});
test('events follow a replacement mpv client', async () => {
const h = createHarness({ watcher: enabled() });
const first = createFakeMpvClient();
const second = createFakeMpvClient();
try {
writePlugin(h.root, 'watcher', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('media-path-change', ({ path }) => ctx.notify(path));
};`,
});
h.host.attachMpvClient(first);
await h.host.loadAll();
h.host.attachMpvClient(second);
first.emit('media-path-change', { path: 'stale.mkv' });
second.emit('media-path-change', { path: 'current.mkv' });
assert.deepEqual(h.notifications, ['current.mkv']);
} finally {
h.cleanup();
}
});
test('unload skips modules whose manifest is still being read and prevents later loads', async () => {
const h = createHarness({ late: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'late', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `throw new Error('module must not execute after unload starts');`,
});
h.host.attachMpvClient(client);
// The plugin is still reading its manifest when unload starts.
const loading = h.host.loadAll();
await h.host.unloadAll();
await loading;
await h.host.loadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('a plugin that finishes activating after unload gave up unloads itself', async () => {
const started = deferred();
const resume = deferred();
const cleaned = deferred();
let deactivated = 0;
const h = createHarness(
{ slow: enabled() },
{
deactivateTimeoutMs: 20,
loadModule: () => ({
activate: async (ctx) => {
started.resolve();
await resume.promise;
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return async () => {
deactivated++;
await Promise.resolve();
cleaned.resolve();
};
},
}),
},
);
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'slow', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
});
h.host.attachMpvClient(client);
const loading = h.host.loadAll();
await started.promise;
await h.host.unloadAll();
resume.resolve();
await loading;
await cleaned.promise;
await h.host.unloadAll();
client.emit('pause-change', { paused: true });
assert.equal(deactivated, 1);
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('unload awaits asynchronous cleanup once when activation finishes during unload', async () => {
const started = deferred();
const resume = deferred();
const deactivating = deferred();
const finish = deferred();
let deactivated = 0;
const h = createHarness(
{ slow: enabled() },
{
loadModule: () => ({
activate: async (ctx) => {
started.resolve();
await resume.promise;
return async () => {
deactivated++;
deactivating.resolve();
await finish.promise;
await ctx.anki.invoke('sync');
ctx.notify('cleaned');
};
},
}),
},
);
try {
writePlugin(h.root, 'slow', { 'plugin.json': '{"main":"main.cjs"}' });
const loading = h.host.loadAll();
await started.promise;
let unloaded = false;
const unloading = Promise.all([h.host.unloadAll(), h.host.unloadAll()]).then(() => {
unloaded = true;
});
resume.resolve();
await deactivating.promise;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(unloaded, false);
assert.equal(deactivated, 1);
finish.resolve();
await unloading;
await loading;
assert.deepEqual(h.notifications, ['cleaned']);
assert.deepEqual(h.errors, []);
} finally {
finish.resolve();
resume.resolve();
await h.host.unloadAll();
h.cleanup();
}
});
test('a timed-out plugin cannot call app APIs after disposal', async () => {
let context: PluginContext | undefined;
const calls: string[] = [];
const h = createHarness(
{ stuck: enabled() },
{
activateTimeoutMs: 10,
loadModule: () => ({
activate: (ctx) => {
context = ctx;
return new Promise(() => {});
},
}),
sendMpvCommand: () => {
calls.push('command');
},
requestMpvCommand: async () => {
calls.push('request');
},
requestMpvProperty: async () => {
calls.push('property');
},
ensureMpvRunning: async () => {
calls.push('start');
return true;
},
invokeAnkiConnect: async () => {
calls.push('anki');
},
},
);
try {
writePlugin(h.root, 'stuck', { 'plugin.json': '{"main":"main.cjs"}' });
await h.host.loadAll();
assert.ok(context);
const ctx = context;
assert.equal(ctx.signal.aborted, true);
assert.throws(() => ctx.mpv.command(['stop']), /disposed/);
assert.throws(() => ctx.notify('late'), /disposed/);
await assert.rejects(ctx.mpv.request(['stop']), /disposed/);
await assert.rejects(ctx.mpv.getProperty('path'), /disposed/);
await assert.rejects(ctx.mpv.ensureRunning(), /disposed/);
await assert.rejects(ctx.anki.invoke('sync'), /disposed/);
await assert.rejects(ctx.actions.run('openJimaku'), /disposed/);
assert.deepEqual(calls, []);
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.sessionActions, []);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('runAction waits for a load that is still in progress', async () => {
const h = createHarness({ slow: enabled() });
try {
writePlugin(h.root, 'slow', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 20));
ctx.actions.register('go', () => ctx.notify('went'));
};`,
});
void h.host.loadAll();
assert.equal(await h.host.runAction('slow/go'), true);
assert.deepEqual(h.notifications, ['went']);
} finally {
h.cleanup();
}
});
test('a stuck activation cannot block another plugin and times out its own registrations', async () => {
const h = createHarness({ stuck: enabled(), healthy: enabled() }, { activateTimeoutMs: 30 });
try {
writePlugin(h.root, 'stuck', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('go', () => ctx.notify('stuck ran'));
return new Promise(() => {});
};`,
});
writePlugin(h.root, 'healthy', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => ctx.actions.register('go', () => ctx.notify('healthy ran'));`,
});
const loading = h.host.loadAll();
assert.equal(await h.host.runAction('healthy/go'), true);
assert.deepEqual(h.notifications, ['healthy ran']);
assert.deepEqual(h.errors, []);
await loading;
assert.equal(await h.host.runAction('stuck/go'), false);
assert.match(h.errors[0]!, /timed out/);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('lifetime aborts before deactivate and disposes early and late registrations once', async () => {
const events: string[] = [];
const h = createHarness(
{ lifetime: enabled() },
{
loadModule: () => ({
activate: (ctx) => {
const dispose = ctx.onDispose(() => events.push('early'));
dispose();
dispose();
ctx.onDispose(() => events.push('cleanup'));
ctx.signal.addEventListener('abort', () => events.push('aborted'));
return () => {
events.push('deactivate aborted=' + ctx.signal.aborted);
ctx.onDispose(() => events.push('late cleanup'));
ctx.after(0, () => events.push('leaked timer'));
ctx.actions.register('late', () => events.push('leaked action'));
};
},
}),
},
);
try {
writePlugin(h.root, 'lifetime', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
});
await h.host.loadAll();
await h.host.unloadAll();
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(await h.host.runAction('lifetime/late'), false);
assert.deepEqual(events, [
'early',
'aborted',
'deactivate aborted=true',
'late cleanup',
'cleanup',
]);
} finally {
h.cleanup();
}
});
test('media subscriptions replay the initial state and follow repeated loads and disconnects', async () => {
const h = createHarness({ media: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'media', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.media.on('change', (media) => ctx.notify(JSON.stringify(media)));
};`,
});
h.host.attachMpvClient(client);
client.emit('media-path-change', { path: 'episode.mkv' });
client.emit('media-title-change', { title: 'Episode 1' });
await h.host.loadAll();
const first = h.host.getMediaSnapshot();
assert.ok(first);
assert.deepEqual(h.notifications, [JSON.stringify(first)]);
client.emit('file-loaded', undefined);
client.emit('file-loaded', undefined);
assert.notEqual(h.host.getMediaSnapshot()?.playbackId, first.playbackId);
assert.equal(first.title, 'Episode 1');
client.emit('media-path-change', { path: 'next.mkv' });
assert.equal(h.host.getMediaSnapshot()?.title, null);
client.emit('connection-change', { connected: false });
assert.equal(h.host.getMediaSnapshot(), null);
await h.host.unloadAll();
const count = h.notifications.length;
client.emit('media-path-change', { path: 'later.mkv' });
assert.equal(h.notifications.length, count);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('incompatible API versions are rejected before the entry module executes', async () => {
const h = createHarness({ future: enabled() });
try {
writePlugin(h.root, 'future', {
'plugin.json': JSON.stringify({ apiVersion: 999, main: 'main.cjs' }),
'main.cjs': `throw new Error('should never execute');`,
});
await h.host.loadAll();
assert.match(h.errors[0]!, /requires API 999/);
assert.equal(h.errors.length, 1);
} finally {
h.cleanup();
}
});
test('a malformed manifest version cannot prevent compatible plugins from loading', async () => {
const h = createHarness({ bad: enabled(), good: enabled() });
try {
writePlugin(h.root, 'bad', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: { toString: null } }),
'main.cjs': `throw new Error('should never execute');`,
});
writePlugin(h.root, 'good', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }),
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('go', () => ctx.notify('good')); };`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('good/go'), true);
assert.deepEqual(h.notifications, ['good']);
assert.deepEqual(h.errors, ['plugin.json "apiVersion" must be a positive integer.']);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('episode-tags uses mining provenance for a kept note older than this playback', async () => {
const calls: unknown[] = [];
const h = createHarness(
{ tags: enabled() },
{
invokeAnkiConnect: async (action, params) => {
calls.push({ action, params });
},
},
);
try {
writePlugin(h.root, 'tags', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }),
'main.cjs': `module.exports = require(${JSON.stringify(path.resolve(__dirname, '../../../../assets/plugins/episode-tags/main.js'))});`,
});
await h.host.loadAll();
h.host.emitAnkiEvent('card-mined', {
noteIds: [7],
notes: [
{
originalNoteId: 1234,
noteId: 7,
provenance: {
minedAt: 1234,
media: { playbackId: 1, path: 'Show - 02.mkv', title: null },
subtitle: null,
},
},
],
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
{ action: 'addTags', params: { notes: [7], tags: 'show::Show::E02' } },
]);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('each plugin gets its own data folder, created before activate', async () => {
const h = createHarness({ keeper: enabled() });
try {
writePlugin(h.root, 'keeper', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `const fs = require('node:fs');
exports.activate = (ctx) => {
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
ctx.notify(ctx.dataDir);
};`,
});
await h.host.loadAll();
const expected = path.join(h.dataRoot, 'keeper');
assert.deepEqual(h.notifications, [expected]);
assert.equal(existsSync(path.join(expected, 'state.json')), true);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('anki card-mined reaches listeners until unload, and invoke forwards to AnkiConnect', async () => {
const invoked: unknown[][] = [];
const h = createHarness(
{ syncer: enabled() },
{
invokeAnkiConnect: async (...args) => {
invoked.push(args);
return 'synced';
},
},
);
try {
writePlugin(h.root, 'syncer', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.anki.on('card-mined', async ({ noteIds }) => {
ctx.notify(noteIds.join(',') + ' ' + (await ctx.anki.invoke('sync', undefined, { timeoutMs: 5 })));
});
};`,
});
await h.host.loadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2], notes: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.notifications, ['1,2 synced']);
assert.deepEqual(invoked, [['sync', {}, { timeoutMs: 5 }]]);
await h.host.unloadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [3], notes: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.notifications.length, 1);
} finally {
h.cleanup();
}
});
test('actions.run forwards built-in session actions', async () => {
const h = createHarness({ caller: enabled() });
try {
writePlugin(h.root, 'caller', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await ctx.actions.run('openJimaku');
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
};`,
});
await h.host.loadAll();
assert.deepEqual(h.sessionActions, [
{ actionId: 'openJimaku' },
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
]);
} finally {
h.cleanup();
}
});
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
const h = createHarness({ viewer: enabled() });
try {
writePlugin(h.root, 'viewer', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
};`,
});
await h.host.loadAll();
await h.host.runAction('viewer/open');
await h.host.runAction('viewer/open');
const dir = path.join(h.root, 'viewer');
assert.deepEqual(h.windowRequests[0], {
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
htmlPath: path.join(dir, 'ui', 'index.html'),
preloadPath: path.join(dir, 'preload.js'),
title: 'viewer',
width: 400,
height: 720,
});
assert.equal(h.windows.size, 1);
await h.host.runAction('viewer/escape');
assert.equal(h.windowRequests.length, 2);
assert.match(h.errors[0] ?? '', /must stay inside/);
const [window] = [...h.windows.values()];
await h.host.unloadAll();
assert.equal(window?.destroyed, true);
} finally {
h.cleanup();
}
});
+568
View File
@@ -0,0 +1,568 @@
import { EventEmitter } from 'node:events';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { BrowserWindow } from 'electron';
import type { SessionActionDispatchRequest } from '../../../types/runtime';
import { execPluginCommand } from './exec';
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
import { createPluginMediaState } from './media-state';
import {
PLUGIN_API_VERSION,
PLUGIN_MPV_EVENT_NAMES,
type PluginAnkiEventMap,
type PluginAnkiEventName,
type PluginAnkiInvokeOptions,
type PluginConfigEntry,
type PluginContext,
type PluginLogger,
type PluginModule,
type PluginMpvEventName,
type PluginMpvEventMap,
type PluginNotifyVariant,
type PluginPanelContent,
type PluginPanelHandle,
type PluginSubtitleContext,
type PluginSubtitleLine,
type PluginWindowRequest,
} from './types';
const MIN_TIMER_INTERVAL_MS = 100;
const DEFAULT_WINDOW_WIDTH = 960;
const DEFAULT_WINDOW_HEIGHT = 720;
/**
* Quit waits for unloadAll, so a load or deactivate that never settles must not hold the app open.
* The forced-exit fallback in main adds its own budget on top of this.
*/
export const PLUGIN_DEACTIVATE_TIMEOUT_MS = 2_000;
export const PLUGIN_ACTIVATE_TIMEOUT_MS = 10_000;
interface MpvClientEvents {
currentVideoPath?: string;
currentMediaTitle?: string | null;
on: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => void;
off: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => void;
}
export interface PluginHostDeps<TUnstable> {
pluginsDir: string;
/** Root of the per-plugin data folders; each plugin gets `<pluginDataDir>/<id>`. */
pluginDataDir: string;
getPluginConfig: () => Record<string, PluginConfigEntry>;
createLogger: (scope: string) => PluginLogger;
isMpvConnected: () => boolean;
sendMpvCommand: (command: (string | number)[]) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
requestMpvCommand: (command: (string | number | boolean)[]) => Promise<unknown>;
ensureMpvRunning: () => Promise<boolean>;
invokeAnkiConnect: (
action: string,
params: Record<string, unknown>,
options: PluginAnkiInvokeOptions,
) => Promise<unknown>;
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
getCurrentSubtitle: () => PluginSubtitleLine | null;
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
notify: (message: string, variant: PluginNotifyVariant) => void;
openPanel: (content: PluginPanelContent) => PluginPanelHandle;
/** Focuses the window already open under `request.key`, or creates it (`created: true`). */
openWindow: (request: PluginWindowRequest) => { window: BrowserWindow; created: boolean };
unstable: TUnstable;
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
loadModule?: (entryPath: string) => PluginModule;
exec?: typeof execPluginCommand;
/** How long unload waits, in total, for loading and deactivate before disposing anyway. */
deactivateTimeoutMs?: number;
/** Async activation is bounded; synchronous plugin code still shares the main thread. */
activateTimeoutMs?: number;
}
interface LoadedPlugin {
id: string;
disposers: Set<() => void>;
deactivate: (() => unknown) | null;
deactivation: Promise<void> | null;
disposed: boolean;
/** Aborted on unload so in-flight `exec` children die with the plugin. */
lifetime: AbortController;
}
/** Resolves when `promise` settles, or rejects once `timeoutMs` passes first. */
async function settleWithin<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs} ms`)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { then?: unknown }).then === 'function'
);
}
function pickExport<K extends 'activate' | 'deactivate'>(
mod: PluginModule,
key: K,
): NonNullable<PluginModule[K]> | null {
const direct = mod[key];
if (typeof direct === 'function') return direct as NonNullable<PluginModule[K]>;
const fromDefault = mod.default?.[key];
return typeof fromDefault === 'function' ? (fromDefault as NonNullable<PluginModule[K]>) : null;
}
/**
* Loads enabled plugins from `<pluginsDir>/<id>/plugin.json` and gives each one a scoped context.
* Every plugin callback runs behind a guard, so a throwing plugin logs instead of breaking the app.
* Plugins run in the main process: a synchronous busy loop still blocks it.
*/
export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
const mpvEvents = new EventEmitter();
mpvEvents.setMaxListeners(0);
const ankiEvents = new EventEmitter();
ankiEvents.setMaxListeners(0);
const mediaEvents = new EventEmitter();
mediaEvents.setMaxListeners(0);
const media = createPluginMediaState((snapshot) => mediaEvents.emit('change', snapshot));
const plugins = new Map<string, LoadedPlugin>();
const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
const exec = deps.exec ?? execPluginCommand;
let detachClient: (() => void) | null = null;
let loadPromise: Promise<void> | null = null;
const pluginLoads = new Map<string, Promise<void>>();
let unloading = false;
function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
try {
const result = callback();
if (isPromiseLike(result)) {
Promise.resolve(result).catch((error) =>
log.error(`${label} failed: ${errorMessage(error)}`),
);
}
} catch (error) {
log.error(`${label} failed: ${errorMessage(error)}`);
}
}
function createContext(
plugin: LoadedPlugin,
dir: string,
dataDir: string,
entry: PluginConfigEntry,
log: PluginLogger,
): PluginContext<TUnstable> {
const assertActive = (): void => {
if (plugin.disposed) throw new Error(`Plugin "${plugin.id}" has been disposed.`);
};
const track = (dispose: () => void): (() => void) => {
let disposed = false;
const disposer = (): void => {
if (disposed) return;
disposed = true;
plugin.disposers.delete(disposer);
dispose();
};
if (plugin.lifetime.signal.aborted) disposer();
else plugin.disposers.add(disposer);
return disposer;
};
return {
apiVersion: PLUGIN_API_VERSION,
id: plugin.id,
signal: plugin.lifetime.signal,
onDispose: (dispose) => track(() => runGuarded(log, 'Cleanup', dispose)),
dir,
settings: Object.freeze({ ...entry.settings }),
dataDir,
log,
every: (intervalMs, callback) => {
const handle = setInterval(
() => runGuarded(log, 'Timer', callback),
Math.max(MIN_TIMER_INTERVAL_MS, intervalMs),
);
return track(() => clearInterval(handle));
},
after: (delayMs, callback) => {
const handle = setTimeout(
() => {
dispose();
runGuarded(log, 'Timer', callback);
},
Math.max(0, delayMs),
);
const dispose = track(() => clearTimeout(handle));
return dispose;
},
mpv: {
get connected() {
return deps.isMpvConnected();
},
on: (event, listener) => {
const wrapped = (payload: unknown): void =>
runGuarded(log, `mpv ${event} listener`, () =>
listener(payload as Parameters<typeof listener>[0]),
);
mpvEvents.on(event, wrapped);
return track(() => mpvEvents.off(event, wrapped));
},
command: (command) => {
assertActive();
deps.sendMpvCommand(command);
},
request: async (command) => {
assertActive();
return deps.requestMpvCommand(command);
},
getProperty: async (name) => {
assertActive();
return deps.requestMpvProperty(name);
},
ensureRunning: async () => {
assertActive();
return deps.ensureMpvRunning();
},
},
media: {
current: () => media.current(),
on: (event, listener, options) => {
if (event !== 'change') throw new Error(`Unknown media event "${event}".`);
const wrapped = (snapshot: ReturnType<typeof media.current>): void =>
runGuarded(log, 'media change listener', () => listener(snapshot));
mediaEvents.on('change', wrapped);
const dispose = track(() => mediaEvents.off('change', wrapped));
if (!plugin.lifetime.signal.aborted && options?.emitCurrent !== false) {
wrapped(media.current());
}
return dispose;
},
},
anki: {
on: (event, listener) => {
const wrapped = (payload: unknown): void =>
runGuarded(log, `anki ${event} listener`, () =>
listener(payload as Parameters<typeof listener>[0]),
);
ankiEvents.on(event, wrapped);
return track(() => ankiEvents.off(event, wrapped));
},
invoke: async (action, params = {}, options = {}) => {
assertActive();
return deps.invokeAnkiConnect(action, params, options);
},
},
subtitles: {
current: () => deps.getCurrentSubtitle(),
context: (options = {}) => deps.getSubtitleContext(options),
},
actions: {
register: (name, handler) => {
if (!isValidPluginName(name)) {
throw new Error(`Invalid action name "${name}"; use letters, digits, ".", "_" or "-".`);
}
const actionId = `${plugin.id}/${name}`;
if (actions.has(actionId)) {
throw new Error(`Action "${actionId}" is already registered.`);
}
actions.set(actionId, { pluginId: plugin.id, handler });
return track(() => actions.delete(actionId));
},
run: async (actionId, payload) => {
assertActive();
return deps.dispatchSessionAction(
payload === undefined ? { actionId } : { actionId, payload },
);
},
},
notify: (message, options) => {
assertActive();
deps.notify(message, options?.variant ?? 'info');
},
ui: {
panel: (content) => {
plugin.lifetime.signal.throwIfAborted();
const handle = deps.openPanel({
title: content.title ?? plugin.id,
markdown: content.markdown,
});
const dispose = track(() => handle.close());
handle.signal.addEventListener('abort', dispose, { once: true });
return handle;
},
openWindow: (options) => {
plugin.lifetime.signal.throwIfAborted();
const resolveFile = (label: string, file: string): string => {
const resolved = resolvePluginFilePath(dir, file);
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
return resolved;
};
const htmlPath = resolveFile('html', options.html);
const { window, created } = deps.openWindow({
key: `${plugin.id}:${htmlPath}`,
htmlPath,
preloadPath:
options.preload === undefined ? null : resolveFile('preload', options.preload),
title: options.title ?? plugin.id,
width: options.width ?? DEFAULT_WINDOW_WIDTH,
height: options.height ?? DEFAULT_WINDOW_HEIGHT,
});
if (created) {
let closed = false;
const dispose = track(() => {
if (!closed && !window.isDestroyed()) window.destroy();
});
window.once('closed', () => {
closed = true;
dispose();
});
}
return window;
},
},
exec: (command, args, options = {}) =>
exec(command, args, {
...options,
signal: options.signal
? AbortSignal.any([options.signal, plugin.lifetime.signal])
: plugin.lifetime.signal,
}),
unstable: deps.unstable,
};
}
function disposePlugin(plugin: LoadedPlugin): void {
if (plugin.disposed) return;
plugin.disposed = true;
plugin.lifetime.abort();
for (const dispose of [...plugin.disposers]) {
try {
dispose();
} catch {
// A failing disposer must not keep the rest registered.
}
}
plugins.delete(plugin.id);
}
/** Shares one bounded deactivate call between unload and an activation that finishes late. */
function finishPlugin(
plugin: LoadedPlugin,
timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS,
): Promise<void> {
if (plugin.deactivation) return plugin.deactivation;
if (!plugin.deactivate) {
disposePlugin(plugin);
return Promise.resolve();
}
plugin.deactivation = (async () => {
try {
await settleWithin(
Promise.resolve().then(() => plugin.deactivate?.()),
timeoutMs,
);
} catch (error) {
deps.createLogger(`plugin:${plugin.id}`).error(`deactivate failed: ${errorMessage(error)}`);
} finally {
disposePlugin(plugin);
}
})();
return plugin.deactivation;
}
async function loadPlugin(id: string, entry: PluginConfigEntry): Promise<void> {
const log = deps.createLogger(`plugin:${id}`);
if (!isValidPluginName(id)) {
log.error(`Invalid plugin id "${id}"; use letters, digits, ".", "_" or "-".`);
return;
}
const dir = path.join(deps.pluginsDir, id);
let rawManifest: unknown;
try {
rawManifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
} catch (error) {
log.error(`Cannot read ${path.join(dir, 'plugin.json')}: ${errorMessage(error)}`);
return;
}
const parsed = parsePluginManifest(rawManifest, id);
if ('error' in parsed) {
log.error(parsed.error);
return;
}
const entryPath = resolvePluginFilePath(dir, parsed.manifest.main);
if (!entryPath) {
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
return;
}
const dataDir = path.join(deps.pluginDataDir, id);
try {
await fs.mkdir(dataDir, { recursive: true });
} catch (error) {
log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`);
return;
}
if (unloading) return;
const plugin: LoadedPlugin = {
id,
disposers: new Set(),
deactivate: null,
deactivation: null,
disposed: false,
lifetime: new AbortController(),
};
plugins.set(id, plugin);
try {
const mod = loadModule(entryPath);
const activate = pickExport(mod, 'activate');
if (!activate) {
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
}
const activation = Promise.resolve(
activate(createContext(plugin, dir, dataDir, entry, log)),
).then((returned) => {
plugin.deactivate =
typeof returned === 'function'
? (returned as () => unknown)
: pickExport(mod, 'deactivate');
// Unload owns cleanup while it is waiting for activation. A timed-out plugin owns it later.
if (plugin.disposed) void finishPlugin(plugin);
});
await settleWithin(activation, deps.activateTimeoutMs ?? PLUGIN_ACTIVATE_TIMEOUT_MS);
if (plugin.lifetime.signal.aborted) return;
const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
log.info(`Loaded ${parsed.manifest.name}${version}.`);
} catch (error) {
log.error(`Activation failed: ${errorMessage(error)}`);
disposePlugin(plugin);
}
}
return {
getMediaSnapshot: () => media.current(),
/** Forwards the client's events to plugin listeners, replacing any previously attached client. */
attachMpvClient(client: MpvClientEvents): void {
detachClient?.();
const disposers: Array<() => void> = [];
const listen = <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
): void => {
client.on(event, listener);
disposers.push(() => client.off(event, listener));
};
listen('media-path-change', ({ path }) => media.pathChanged(path));
listen('media-title-change', ({ title }) => media.titleChanged(title));
listen('file-loaded', () =>
media.fileLoaded(
client.currentVideoPath ?? media.current()?.path ?? '',
client.currentMediaTitle === undefined
? (media.current()?.title ?? null)
: client.currentMediaTitle,
),
);
listen('end-file', () => media.reset());
listen('connection-change', ({ connected }) => {
if (!connected) media.reset();
});
for (const event of PLUGIN_MPV_EVENT_NAMES) {
listen(event, (payload) => {
mpvEvents.emit(event, payload);
});
}
detachClient = () => {
for (const dispose of disposers) dispose();
};
media.reset(client.currentVideoPath, client.currentMediaTitle);
},
/** Delivers an Anki event to every plugin listening for it. */
emitAnkiEvent<E extends PluginAnkiEventName>(event: E, payload: PluginAnkiEventMap[E]): void {
ankiEvents.emit(event, payload);
},
/** Loads every enabled plugin once; later calls return the first load. */
loadAll(): Promise<void> {
if (unloading) return Promise.resolve();
loadPromise ??= Promise.all(
Object.entries(deps.getPluginConfig())
.filter(([, entry]) => entry.enabled)
.map(([id, entry]) => {
const loading = loadPlugin(id, entry);
pluginLoads.set(id, loading);
return loading;
}),
).then(() => undefined);
return loadPromise;
},
async unloadAll(): Promise<void> {
unloading = true;
for (const plugin of plugins.values()) plugin.lifetime.abort();
// One budget covers the load wait and every deactivate, so quit is bounded by it.
const timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
// Plugins still reading their files are not in `plugins` yet; let the load finish first.
if (loadPromise) {
try {
await settleWithin(loadPromise, timeoutMs);
} catch (error) {
deps
.createLogger('plugins')
.warn(`Unloading before load finished: ${errorMessage(error)}`);
}
}
loadPromise = null;
await Promise.all(
[...plugins.values()].map((plugin) =>
finishPlugin(plugin, Math.max(0, deadline - Date.now())),
),
);
},
/**
* Runs a registered `<plugin id>/<name>` action, waiting for an in-progress load first so a
* command sent at startup is not lost. Resolves false when no plugin registered the action.
*/
async runAction(actionId: string): Promise<boolean> {
const separator = actionId.indexOf('/');
if (separator < 1) return false;
const pluginId = actionId.slice(0, separator);
await pluginLoads.get(pluginId);
if (unloading) return false;
const action = actions.get(actionId);
if (!action) return false;
runGuarded(
deps.createLogger(`plugin:${action.pluginId}`),
`Action ${actionId}`,
action.handler,
);
return true;
},
};
}
export type PluginHost = ReturnType<typeof createPluginHost>;
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildPluginSubtitleContext } from './subtitle-context';
const cues = [
{ text: '一', startTime: 1, endTime: 2 },
{ text: 'はい', startTime: 3, endTime: 4 },
{ text: '二', startTime: 5, endTime: 6 },
{ text: '三', startTime: 7, endTime: 8 },
{ text: 'はい', startTime: 9, endTime: 10 },
{ text: '四', startTime: 11, endTime: 12 },
];
test('returns neighbors of the matching cue, preferring the one nearest the live timing', () => {
// Live timing is shifted by a +1.5 s sub-delay, so it sits outside both はい cues.
const context = buildPluginSubtitleContext({
cues,
current: { text: 'はい', startTime: 10.5, endTime: 11.5 },
recentLines: [],
before: 2,
after: 1,
});
assert.deepEqual(
context.before.map((line) => line.text),
['二', '三'],
);
assert.deepEqual(
context.after.map((line) => line.text),
['四'],
);
});
test('falls back to recently displayed lines when the line is not in the cue list', () => {
const context = buildPluginSubtitleContext({
cues: [],
current: { text: '今', startTime: 20, endTime: 21 },
recentLines: [
{ text: '前の前', startTime: 16, endTime: 17 },
{ text: '前', startTime: 18, endTime: 19 },
{ text: '今', startTime: 20, endTime: 21 },
],
before: 2,
});
assert.deepEqual(
context.before.map((line) => line.text),
['前の前', '前'],
);
assert.deepEqual(context.after, []);
});
test('returns an empty context when no subtitle is displayed', () => {
assert.deepEqual(
buildPluginSubtitleContext({
cues,
current: { text: ' ', startTime: null, endTime: null },
recentLines: [],
}),
{ current: null, before: [], after: [] },
);
});
@@ -0,0 +1,86 @@
import type { PluginSubtitleContext, PluginSubtitleLine } from './types';
interface CueLike {
text: string;
startTime: number;
endTime: number;
}
const MAX_CONTEXT_LINES = 20;
function normalizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
function clampCount(value: number | undefined, fallback: number): number {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(MAX_CONTEXT_LINES, Math.max(0, Math.floor(value)));
}
function toLine(cue: CueLike): PluginSubtitleLine {
return { text: cue.text.trim(), startTime: cue.startTime, endTime: cue.endTime };
}
/**
* Locates the displayed line in the parsed cue list. Text decides, timing breaks ties: mpv's live
* timing can be shifted by sub-delay, so the same text nearest the reported start wins.
*/
function findCurrentCueIndex(cues: readonly CueLike[], current: PluginSubtitleLine): number {
const target = normalizeText(current.text);
const anchor = current.startTime;
let best = -1;
let bestDistance = Number.POSITIVE_INFINITY;
cues.forEach((cue, index) => {
if (normalizeText(cue.text) !== target) return;
const distance = anchor === null ? 0 : Math.abs(cue.startTime - anchor);
if (distance < bestDistance) {
best = index;
bestDistance = distance;
}
});
if (best >= 0 || anchor === null) return best;
return cues.findIndex((cue) => anchor >= cue.startTime && anchor < cue.endTime);
}
/**
* Builds the current line plus neighbors. Uses the parsed subtitle file when one is loaded and
* falls back to recently displayed lines (no `after` context) for streams without a cue list.
*/
export function buildPluginSubtitleContext(input: {
cues: readonly CueLike[];
current: PluginSubtitleLine | null;
recentLines: readonly PluginSubtitleLine[];
before?: number;
after?: number;
}): PluginSubtitleContext {
const beforeCount = clampCount(input.before, 2);
const afterCount = clampCount(input.after, 1);
const current = input.current && normalizeText(input.current.text) ? input.current : null;
if (!current) return { current: null, before: [], after: [] };
const cues = input.cues
.filter((cue) => normalizeText(cue.text).length > 0)
.slice()
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
const index = findCurrentCueIndex(cues, current);
if (index >= 0) {
return {
current,
before: cues.slice(Math.max(0, index - beforeCount), index).map(toLine),
after: cues.slice(index + 1, index + 1 + afterCount).map(toLine),
};
}
const target = normalizeText(current.text);
const recent = input.recentLines.filter((line) => normalizeText(line.text).length > 0);
// The tracker usually already holds the current line as its newest entry.
const history =
recent.length > 0 && normalizeText(recent[recent.length - 1]!.text) === target
? recent.slice(0, -1)
: recent;
return {
current,
before: beforeCount > 0 ? history.slice(-beforeCount) : [],
after: [],
};
}
+55
View File
@@ -0,0 +1,55 @@
import type { PluginMpvEventName } from '../../../plugin-api';
export type * from '../../../plugin-api';
export const PLUGIN_API_VERSION = 1;
/** `plugin.json` at the root of a plugin directory. */
export interface PluginManifest {
/** Display name; defaults to the directory name. */
name: string;
version: string | null;
/** Omitted by older plugins, which use API v1. */
apiVersion: number;
/** Entry module, relative to the plugin directory. */
main: string;
}
/** One `plugins.<id>` entry from config. */
export interface PluginConfigEntry {
enabled: boolean;
settings: Record<string, unknown>;
}
const pluginMpvEvents = {
'connection-change': true,
'subtitle-change': true,
'subtitle-timing': true,
'time-pos-change': true,
'duration-change': true,
'pause-change': true,
'fullscreen-change': true,
'secondary-subtitle-change': true,
'subtitle-track-change': true,
'secondary-subtitle-track-change': true,
'secondary-subtitle-delay-change': true,
'subtitle-track-list-change': true,
'media-path-change': true,
'media-title-change': true,
'secondary-subtitle-visibility': true,
'client-message': true,
'file-loaded': true,
'end-file': true,
} satisfies Record<PluginMpvEventName, true>;
export const PLUGIN_MPV_EVENT_NAMES = Object.keys(pluginMpvEvents) as PluginMpvEventName[];
/** What the host needs to show a plugin window; paths are absolute and already validated. */
export interface PluginWindowRequest {
/** Same key, same window: reopening focuses the existing one. */
key: string;
htmlPath: string;
preloadPath: string | null;
title: string;
width: number;
height: number;
}
+23
View File
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'),
runPluginAction: async (pluginActionId) => {
calls.push(`plugin:${pluginActionId}`);
return pluginActionId === 'ai-explain/explain';
},
showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides,
};
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']);
});
test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
deps,
);
await dispatchSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
deps,
);
assert.deepEqual(calls, [
'plugin:ai-explain/explain',
'plugin:ai-explain/missing',
'osd:Plugin action not available: ai-explain/missing',
]);
});
+9
View File
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
/** Returns false when no loaded plugin registered the action. */
runPluginAction: (pluginActionId: string) => Promise<boolean>;
showMpvOsd: (text: string) => void;
}
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
}
return;
}
case 'runPluginAction': {
const pluginActionId = request.payload?.pluginActionId;
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
}
return;
}
default:
return assertUnreachableSessionAction(request.actionId);
}
@@ -323,6 +323,31 @@ test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime
);
});
test('compileSessionBindings maps plugin action commands to a validated session action', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Ctrl+E', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}ai-explain/explain`]),
createKeybinding('Ctrl+R', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}no-action-name`]),
],
platform: 'linux',
});
const binding = result.bindings[0];
assert.equal(result.bindings.length, 1);
assert.ok(binding?.actionType === 'session-action');
assert.equal(binding.actionId, 'runPluginAction');
assert.deepEqual(binding.payload, { pluginActionId: 'ai-explain/explain' });
assert.deepEqual(
parseSessionActionDispatchRequest({ actionId: binding.actionId, payload: binding.payload }),
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:keybindings[1].command'],
);
});
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
+10
View File
@@ -342,6 +342,16 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
const [pluginId, actionName, ...rest] = pluginActionId.split('/');
if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
return {
actionType: 'session-action',
actionId: 'runPluginAction',
payload: { pluginActionId },
};
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) {
return null;
+3
View File
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void;
resolveKeybindings: () => void;
createMpvClient: () => void;
/** Loads enabled plugins; full startup only. */
startPlugins?: () => void;
reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[];
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createMpvClient();
deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
deps.startPlugins?.();
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';