Plugins are switched on and off in a Plugins tab, the server keeps the state in data/plugins/plugins.json, and switched-off plugins are never delivered (#114)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a97ba94f
commit
c17b2b5426
17 files changed
+994
-129
No files matched your search
+172
-33
@@ -7,24 +7,68 @@ export interface PluginMeta {
|
||||
api: number;
|
||||
}
|
||||
|
||||
/** What became of one file in `data/plugins/`; the check scripts and a later settings list read it. */
|
||||
/**
|
||||
* What became of one file in `data/plugins/`; the Plugins tab and the check scripts read it.
|
||||
* `inactive` is switched off, `failed` is switched on but not running. A switched-off plugin is
|
||||
* never loaded, so its `meta` is only known once it has been active in this session.
|
||||
*/
|
||||
export interface PluginRecord {
|
||||
file: string;
|
||||
status: 'active' | 'failed';
|
||||
status: 'active' | 'inactive' | 'failed';
|
||||
meta: PluginMeta | null;
|
||||
/** Why it is not active. */
|
||||
/** Why it is not running although switched on, or what went wrong switching it off. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A file as the server lists it. */
|
||||
export interface PluginEntry {
|
||||
file: string;
|
||||
updatedAt: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one active plugin left in the editor – a tab, a listener – as the functions that take it
|
||||
* away again. Switching the plugin off runs them, newest first; so does an `activate` that fails
|
||||
* halfway, so a half-built plugin leaves nothing behind.
|
||||
*/
|
||||
export class PluginScope {
|
||||
private readonly cleanups: (() => void)[] = [];
|
||||
|
||||
add(cleanup: () => void): void {
|
||||
this.cleanups.push(cleanup);
|
||||
}
|
||||
|
||||
/** Runs every cleanup even when one throws; returns the messages of those that did. */
|
||||
dispose(): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const cleanup of this.cleanups.splice(0).reverse()) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
errors.push(messageOf(error));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginLoaderDeps {
|
||||
/** The module a file holds. */
|
||||
load(file: string): Promise<unknown>;
|
||||
/** A fresh API for a plugin that passed the checks. */
|
||||
api(meta: PluginMeta): unknown;
|
||||
/** One line for the user about a plugin that could not be activated. */
|
||||
/** The module a file holds; `updatedAt` tells a replaced file from the one loaded before. */
|
||||
load(file: string, updatedAt: number): Promise<unknown>;
|
||||
/** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */
|
||||
api(meta: PluginMeta, scope: PluginScope): unknown;
|
||||
/** Stores the switch on the server; throws when it was not stored. */
|
||||
store(file: string, active: boolean): Promise<void>;
|
||||
/** One line for the user about a plugin that could not be switched. */
|
||||
report(message: string): void;
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
module: { activate: (api: unknown) => unknown; deactivate?: unknown };
|
||||
scope: PluginScope;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -37,6 +81,8 @@ export function readMeta(module: unknown): PluginMeta | string {
|
||||
const { id, name, version, api } = meta as Record<string, unknown>;
|
||||
if (typeof id !== 'string' || id === '') return 'meta.id must be a non-empty string';
|
||||
if (typeof exports.activate !== 'function') return 'exports no activate function';
|
||||
if (exports.deactivate !== undefined && typeof exports.deactivate !== 'function')
|
||||
return 'deactivate must be a function';
|
||||
if (api !== PLUGIN_API_VERSION)
|
||||
return `needs plugin API ${String(api)}, this editor offers ${PLUGIN_API_VERSION}`;
|
||||
return {
|
||||
@@ -48,48 +94,141 @@ export function readMeta(module: unknown): PluginMeta | string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and activates every plugin, one after the other in file-name order, so that what an
|
||||
* earlier one registers (a tab id, say) is settled before the next runs. Nothing a plugin does
|
||||
* wrong stops the others or the editor: a file that does not load, a module that is not a plugin,
|
||||
* an API version this editor does not offer, a second plugin of the same id and an `activate`
|
||||
* that throws each leave a failed record and a report.
|
||||
* The plugins of this editor and their switches (#113, #114). At start every switched-on plugin is
|
||||
* activated, one after the other in file-name order, so that what an earlier one registers (a tab
|
||||
* id, say) is settled before the next runs; one switched on later comes after all of them.
|
||||
*
|
||||
* Nothing a plugin does wrong stops the others or the editor: a file that does not load, a module
|
||||
* that is not a plugin, an API version this editor does not offer, a second plugin of an id that
|
||||
* is active already and an `activate` that throws each leave a failed record and a report.
|
||||
*
|
||||
* Switches run one at a time in the order they were made, so a quick off-on cannot overtake itself.
|
||||
*/
|
||||
export async function activatePlugins(
|
||||
files: readonly string[],
|
||||
deps: PluginLoaderDeps,
|
||||
): Promise<PluginRecord[]> {
|
||||
const records: PluginRecord[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const file of [...files].sort()) {
|
||||
export class PluginManager {
|
||||
private list: PluginRecord[] = [];
|
||||
private readonly loaded = new Map<string, Loaded>();
|
||||
private readonly entries = new Map<string, PluginEntry>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
/** False until `start` has been through the list – the tab says "Loading…" until then. */
|
||||
started = false;
|
||||
|
||||
constructor(private readonly deps: PluginLoaderDeps) {}
|
||||
|
||||
get records(): readonly PluginRecord[] {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
/** Called after every change of a record; returns the unsubscribe. */
|
||||
onChange(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
start(entries: readonly PluginEntry[]): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
||||
for (const entry of sorted) this.entries.set(entry.file, entry);
|
||||
this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null }));
|
||||
for (const entry of sorted) if (entry.active) await this.activate(entry.file);
|
||||
this.started = true;
|
||||
this.changed();
|
||||
});
|
||||
}
|
||||
|
||||
/** Switches a plugin on or off: stored on the server first, and only then applied. */
|
||||
setActive(file: string, active: boolean): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const record = this.record(file);
|
||||
if (!record || (record.status !== 'inactive') === active) return;
|
||||
try {
|
||||
await this.deps.store(file, active);
|
||||
} catch (error) {
|
||||
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
|
||||
this.changed();
|
||||
return;
|
||||
}
|
||||
if (active) await this.activate(file);
|
||||
else this.deactivate(file);
|
||||
this.changed();
|
||||
});
|
||||
}
|
||||
|
||||
private async activate(file: string): Promise<void> {
|
||||
const fail = (meta: PluginMeta | null, error: string) => {
|
||||
records.push({ file, status: 'failed', meta, error });
|
||||
deps.report(`Plugin ${file} not activated: ${error}`);
|
||||
this.update(file, { status: 'failed', meta, error });
|
||||
this.deps.report(`Plugin ${file} not activated: ${error}`);
|
||||
};
|
||||
let module: unknown;
|
||||
try {
|
||||
module = await deps.load(file);
|
||||
module = await this.deps.load(file, this.entries.get(file)?.updatedAt ?? 0);
|
||||
} catch (error) {
|
||||
fail(null, `does not load (${messageOf(error)})`);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const meta = readMeta(module);
|
||||
if (typeof meta === 'string') {
|
||||
fail(null, meta);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
if (ids.has(meta.id)) {
|
||||
if (this.list.some((r) => r.status === 'active' && r.meta?.id === meta.id)) {
|
||||
fail(meta, `a plugin with the id "${meta.id}" is active already`);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
const scope = new PluginScope();
|
||||
const loaded = { module: module as Loaded['module'], scope };
|
||||
try {
|
||||
const activate = (module as { activate: (api: unknown) => unknown }).activate;
|
||||
await activate(deps.api(meta));
|
||||
await loaded.module.activate(this.deps.api(meta, scope));
|
||||
} catch (error) {
|
||||
scope.dispose();
|
||||
fail(meta, `activate failed (${messageOf(error)})`);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
ids.add(meta.id);
|
||||
records.push({ file, status: 'active', meta });
|
||||
this.loaded.set(file, loaded);
|
||||
this.update(file, { status: 'active', meta, error: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's own `deactivate` first, while its tabs are still there to be tidied, then
|
||||
* everything it registered. It is off afterwards whatever threw on the way – what threw is
|
||||
* reported and kept on the record.
|
||||
*/
|
||||
private deactivate(file: string): void {
|
||||
const loaded = this.loaded.get(file);
|
||||
this.loaded.delete(file);
|
||||
const errors: string[] = [];
|
||||
if (loaded) {
|
||||
try {
|
||||
if (typeof loaded.module.deactivate === 'function') (loaded.module.deactivate as () => unknown)();
|
||||
} catch (error) {
|
||||
errors.push(`deactivate failed (${messageOf(error)})`);
|
||||
}
|
||||
errors.push(...loaded.scope.dispose().map((message) => `cleanup failed (${message})`));
|
||||
}
|
||||
const error = errors.length > 0 ? errors.join('; ') : undefined;
|
||||
this.update(file, { status: 'inactive', error });
|
||||
if (error) this.deps.report(`Plugin ${file} switched off, but: ${error}`);
|
||||
}
|
||||
|
||||
private record(file: string): PluginRecord | undefined {
|
||||
return this.list.find((r) => r.file === file);
|
||||
}
|
||||
|
||||
private update(file: string, change: Partial<PluginRecord>): void {
|
||||
this.list = this.list.map((r) => {
|
||||
if (r.file !== file) return r;
|
||||
const next: PluginRecord = { ...r, ...change };
|
||||
if (next.error === undefined) delete next.error;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
|
||||
private enqueue(work: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(work, work);
|
||||
return this.queue;
|
||||
}
|
||||
return records;
|
||||
}
|
||||
Reference in new issue
Block a user