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
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 ?? '');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildNotifySendEnv,
|
||||
createNotifySendReplacer,
|
||||
resolveDefaultNotificationIconPath,
|
||||
} from './notification';
|
||||
|
||||
test('notify-send child environment drops the AppImage library-path override', () => {
|
||||
const env = buildNotifySendEnv({
|
||||
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
|
||||
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
||||
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
||||
assert.equal(env.HOME, '/home/user');
|
||||
});
|
||||
import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
|
||||
|
||||
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||
const path = resolveDefaultNotificationIconPath({
|
||||
|
||||
@@ -3,6 +3,7 @@ import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||
|
||||
const { Notification, nativeImage } = electron;
|
||||
const logger = createLogger('core:notification');
|
||||
@@ -203,19 +204,8 @@ export function createNotifySendReplacer(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libnotify predates the
|
||||
* symbols the system notify-send links against, so an inherited environment kills the child with a
|
||||
* symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
|
||||
* so the override is dropped entirely rather than filtered.
|
||||
*/
|
||||
export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
|
||||
execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
|
||||
execFile('notify-send', args, { timeout: 5_000, env: buildSystemBinaryEnv() }, (error, stdout) =>
|
||||
callback(error, stdout ?? ''),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||
|
||||
test('system binary environment drops the AppImage library-path override', () => {
|
||||
const env = buildSystemBinaryEnv({
|
||||
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
|
||||
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
||||
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
||||
assert.equal(env.HOME, '/home/user');
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libraries (libnotify and
|
||||
* friends) predate the symbols system binaries link against, so an inherited environment can kill a
|
||||
* spawned child with a symbol lookup error. A system binary resolves its own libraries fine, so the
|
||||
* override is dropped entirely rather than filtered.
|
||||
*/
|
||||
export function buildSystemBinaryEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
||||
return rest;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
resolveWindowsCommand,
|
||||
} from './windows-command-shim';
|
||||
|
||||
test('resolves a bare name through PATH and PATHEXT in order', () => {
|
||||
const npmDir = path.join('/fake', 'npm');
|
||||
const binDir = path.join('/fake', 'bin');
|
||||
const existing = new Set([path.join(npmDir, 'codex.CMD'), path.join(binDir, 'codex.EXE')]);
|
||||
const env = { PATH: [npmDir, binDir].join(path.delimiter), PATHEXT: '.EXE;.CMD' };
|
||||
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex', env, (candidate) => existing.has(candidate)),
|
||||
path.join(npmDir, 'codex.CMD'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex.exe', env, () => true),
|
||||
'codex.exe',
|
||||
'commands with an extension are left alone',
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('missing', env, () => false),
|
||||
'missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves an extensionless path in its own directory', () => {
|
||||
const shim = path.join('/fake', 'npm', 'codex');
|
||||
assert.equal(
|
||||
resolveWindowsCommand(shim, { PATHEXT: '.CMD' }, (candidate) => candidate === `${shim}.CMD`),
|
||||
`${shim}.CMD`,
|
||||
);
|
||||
});
|
||||
|
||||
test('recognizes batch shims only', () => {
|
||||
assert.equal(isWindowsCommandShim('C:\\npm\\codex.cmd'), true);
|
||||
assert.equal(isWindowsCommandShim('run.BAT'), true);
|
||||
assert.equal(isWindowsCommandShim('codex.exe'), false);
|
||||
});
|
||||
|
||||
test('quotes the shim call for cmd.exe', () => {
|
||||
const invocation = buildWindowsShimInvocation(
|
||||
'C:\\npm\\codex.cmd',
|
||||
['-C', 'C:\\Temp dir\\a&b', '-c', 'effort="low"'],
|
||||
{ ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
|
||||
);
|
||||
|
||||
assert.deepEqual(invocation, {
|
||||
file: 'C:\\Windows\\system32\\cmd.exe',
|
||||
args: [
|
||||
'/d',
|
||||
'/s',
|
||||
'/v:off',
|
||||
'/c',
|
||||
'"^"C:\\npm\\codex.cmd^" ^"-C^" ^"C:\\Temp^ dir\\a^&b^" ^"-c^" ^"effort=\\^"low\\^"^""',
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** True for batch shims (`.cmd`/`.bat`), which Windows only runs through cmd.exe. */
|
||||
export function isWindowsCommandShim(command: string): boolean {
|
||||
return /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extensionless Windows command the way cmd.exe does: each `PATHEXT` extension in
|
||||
* the command's own directory, or in each `PATH` entry for a bare name. Returns `command`
|
||||
* unchanged when it already has an extension or nothing matches.
|
||||
*/
|
||||
export function resolveWindowsCommand(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
existsSync: (candidate: string) => boolean = fs.existsSync,
|
||||
): string {
|
||||
if (path.extname(command)) return command;
|
||||
const directory = path.dirname(command);
|
||||
const directories =
|
||||
directory === '.' ? (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean) : [''];
|
||||
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
||||
for (const entry of directories) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = entry ? path.join(entry, command + extension) : command + extension;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
/*!
|
||||
* Windows command escaping adapted from cross-spawn 7.0.6.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
|
||||
|
||||
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
|
||||
// are consumed by cmd, leaving the quoted argument unchanged for the command.
|
||||
function escapeWindowsShellArgument(value: string): string {
|
||||
const quotesEscaped = value
|
||||
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
|
||||
.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
||||
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cmd.exe invocation that runs a batch shim with `args`. Spawn the result with
|
||||
* `windowsVerbatimArguments: true` so Node does not re-quote the prepared command line.
|
||||
*/
|
||||
export function buildWindowsShimInvocation(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): { file: string; args: string[] } {
|
||||
const commandLine = [command, ...args].map(escapeWindowsShellArgument).join(' ');
|
||||
return {
|
||||
file: env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe',
|
||||
args: ['/d', '/s', '/v:off', '/c', `"${commandLine}"`],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user