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
@@ -0,0 +1,165 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { Checkbox, HStack, Label, ScrollContainer, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { PluginManager, PluginRecord } from './loader';
|
||||
|
||||
const NAME_WIDTH = 200;
|
||||
const VERSION_WIDTH = 70;
|
||||
const FILE_WIDTH = 200;
|
||||
|
||||
/** What one plugin's row says about its state, beside the switch. */
|
||||
export function statusText(record: PluginRecord): string {
|
||||
if (record.status === 'failed') return `Failed: ${record.error ?? 'unknown reason'}`;
|
||||
if (record.error) return `Switched off, but: ${record.error}`;
|
||||
return record.status === 'active' ? 'Active' : 'Off';
|
||||
}
|
||||
|
||||
/** The name to show: known once the plugin has been active in this session, the file name until then. */
|
||||
export function displayName(record: PluginRecord): string {
|
||||
return record.meta?.name ?? '—';
|
||||
}
|
||||
|
||||
class PluginRow extends HStack {
|
||||
readonly switch: Checkbox;
|
||||
private readonly nameLabel: Label;
|
||||
private readonly version: Label;
|
||||
private readonly file: Label;
|
||||
private readonly status: Label;
|
||||
|
||||
constructor(
|
||||
public record: PluginRecord,
|
||||
onToggle: (active: boolean) => void,
|
||||
) {
|
||||
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
this.minHeight = Theme.rowHeight + Theme.padding;
|
||||
this.switch = new Checkbox(record.status !== 'inactive');
|
||||
this.switch.onChange = onToggle;
|
||||
this.add(this.switch);
|
||||
this.nameLabel = new Label('', { width: NAME_WIDTH, style: 'bodyBold' });
|
||||
this.version = new Label('', { width: VERSION_WIDTH, style: 'muted' });
|
||||
this.file = new Label('', { width: FILE_WIDTH, style: 'muted' });
|
||||
this.status = new Label('', { style: 'small' });
|
||||
this.add(this.nameLabel);
|
||||
this.add(this.version);
|
||||
this.add(this.file);
|
||||
this.add(this.status, 1);
|
||||
this.show(record);
|
||||
}
|
||||
|
||||
show(record: PluginRecord): void {
|
||||
this.record = record;
|
||||
this.switch.setChecked(record.status !== 'inactive');
|
||||
this.nameLabel.setText(displayName(record));
|
||||
this.version.setText(record.meta?.version ?? '');
|
||||
this.file.setText(record.file);
|
||||
this.status.setText(statusText(record));
|
||||
this.status.setStyle(record.status === 'failed' ? 'warning' : 'small');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Plugins tab (#114): every file in `data/plugins/` with a switch. Switching one on loads and
|
||||
* activates it there and then, switching it off takes its tabs and listeners away again – the
|
||||
* state is the server's, so it holds for everyone who opens this editor.
|
||||
*
|
||||
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
|
||||
* reading a name out of it would mean running it.
|
||||
*/
|
||||
export class PluginsPane extends VStack {
|
||||
private readonly headerBg = new Graphics();
|
||||
private readonly hint: Label;
|
||||
private readonly rows = new VStack();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly empty: Label;
|
||||
private manager: PluginManager | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
header.add(new Label('Plugins', { style: 'bodyBold' }));
|
||||
header.add(new Spacer(Theme.padding));
|
||||
this.hint = new Label('', { style: 'small' });
|
||||
header.add(this.hint, 1);
|
||||
this.add(header);
|
||||
this.empty = new Label('Loading…', { style: 'muted', padding: Theme.padding });
|
||||
this.rows.add(this.empty);
|
||||
this.scroll.setContent(this.rows);
|
||||
this.add(this.scroll, 1);
|
||||
}
|
||||
|
||||
setManager(manager: PluginManager): void {
|
||||
this.unsubscribe?.();
|
||||
this.manager = manager;
|
||||
this.unsubscribe = manager.onChange(() => this.refresh());
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The list could not even be read; there is nothing to show but why. */
|
||||
showError(message: string): void {
|
||||
this.manager = null;
|
||||
this.empty.setText(message);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The rows as drawn, and the switch to click – what the check scripts read. */
|
||||
debugInfo(): {
|
||||
visible: boolean;
|
||||
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
|
||||
} {
|
||||
return {
|
||||
visible: this.visible,
|
||||
rows: this.rowWidgets().map((row) => ({
|
||||
file: row.record.file,
|
||||
name: displayName(row.record),
|
||||
status: row.record.status,
|
||||
checked: row.switch.checked,
|
||||
text: statusText(row.record),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The switch of one plugin's row, for the check scripts; null when it has no row. */
|
||||
switchOf(file: string): Checkbox | null {
|
||||
return this.rowWidgets().find((row) => row.record.file === file)?.switch ?? null;
|
||||
}
|
||||
|
||||
private rowWidgets(): PluginRow[] {
|
||||
return this.rows.children.filter((child): child is PluginRow => child instanceof PluginRow);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const records = this.manager?.records ?? [];
|
||||
const rows = this.rowWidgets();
|
||||
const sameList =
|
||||
rows.length === records.length && rows.every((row, i) => row.record.file === records[i]?.file);
|
||||
if (sameList) {
|
||||
rows.forEach((row, i) => row.show(records[i]!));
|
||||
} else {
|
||||
for (const row of rows) row.destroy({ children: true });
|
||||
for (const record of records) {
|
||||
const row = new PluginRow(record, (active) => void this.manager?.setActive(record.file, active));
|
||||
this.rows.add(row);
|
||||
}
|
||||
}
|
||||
const nothing = records.length === 0;
|
||||
this.empty.visible = nothing;
|
||||
if (nothing && this.manager) this.empty.setText('No plugins in data/plugins');
|
||||
this.hint.setText(
|
||||
nothing
|
||||
? ''
|
||||
: 'Loaded from data/plugins, for everyone using this server. A new file stays off until switched on here.',
|
||||
);
|
||||
this.scroll.relayoutContent();
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.clear();
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
@@ -18,6 +18,32 @@ export interface PluginHost {
|
||||
/** Whether a tab of this id exists already, built in or added by a plugin. */
|
||||
hasTab(id: string): boolean;
|
||||
addTab(id: string, label: string, content: Widget): void;
|
||||
/** Takes a plugin's tab away again, its content with it. */
|
||||
removeTab(id: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host as one plugin sees it: every listener it subscribes and every tab it adds is written
|
||||
* into `scope`, so switching the plugin off can take them away. An unsubscribe the plugin calls
|
||||
* itself stays harmless when the scope calls it again.
|
||||
*/
|
||||
export function scopedHost(host: PluginHost, scope: { add(cleanup: () => void): void }): PluginHost {
|
||||
return {
|
||||
...host,
|
||||
onProjectChange(listener) {
|
||||
let off: (() => void) | null = host.onProjectChange(listener);
|
||||
const unsubscribe = () => {
|
||||
off?.();
|
||||
off = null;
|
||||
};
|
||||
scope.add(unsubscribe);
|
||||
return unsubscribe;
|
||||
},
|
||||
addTab(id, label, content) {
|
||||
host.addTab(id, label, content);
|
||||
scope.add(() => host.removeTab(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A change of the asset list, as `project.on('change')` sees it: ids, names and types in order. */
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { apiRequest } from '../../core/store/http';
|
||||
import type { Editor } from '../Editor';
|
||||
import type { EditorState } from '../state';
|
||||
import { activatePlugins } from './loader';
|
||||
import { createPluginApi, type PluginHost } from './pluginApi';
|
||||
import { type PluginEntry, PluginManager } from './loader';
|
||||
import { createPluginApi, type PluginHost, scopedHost } from './pluginApi';
|
||||
|
||||
/**
|
||||
* Loads what is in `data/plugins/` into the running editor (#113). A plugin is an ES module with
|
||||
* no imports of its own, fetched through the API route rather than as a static file – so the
|
||||
* folder cannot be used to reach anything but a plugin. A failure is a console line and a notice;
|
||||
* the editor goes on without that plugin.
|
||||
* Loads what is switched on in `data/plugins/` into the running editor (#113, #114). A plugin is an
|
||||
* ES module with no imports of its own, fetched through the API route rather than as a static file
|
||||
* – so the folder cannot be used to reach anything but a plugin. A failure is a console line and a
|
||||
* notice; the editor goes on without that plugin.
|
||||
*/
|
||||
export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise<void> {
|
||||
const report = (message: string) => {
|
||||
console.error(message);
|
||||
state.setNotice(message, 'quiet');
|
||||
};
|
||||
let files: string[];
|
||||
let entries: PluginEntry[];
|
||||
try {
|
||||
const list = await apiRequest<{ file: string }[]>(endpoint, 'GET', 'plugins');
|
||||
files = list.map((entry) => entry.file);
|
||||
entries = await apiRequest<PluginEntry[]>(endpoint, 'GET', 'plugins');
|
||||
} catch (error) {
|
||||
report(`Plugins not loaded: ${error instanceof Error ? error.message : String(error)}`);
|
||||
const message = `Plugins not loaded: ${error instanceof Error ? error.message : String(error)}`;
|
||||
report(message);
|
||||
editor.pluginsPane.showError(message);
|
||||
return;
|
||||
}
|
||||
const host: PluginHost = {
|
||||
@@ -28,10 +29,23 @@ export async function startPlugins(endpoint: string, state: EditorState, editor:
|
||||
onProjectChange: (listener) => state.on('project', listener),
|
||||
hasTab: (id) => editor.hasTab(id),
|
||||
addTab: (id, label, content) => editor.addPluginTab(id, label, content),
|
||||
removeTab: (id) => editor.removePluginTab(id),
|
||||
};
|
||||
editor.plugins = await activatePlugins(files, {
|
||||
load: (file) => import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}`),
|
||||
api: (meta) => createPluginApi(meta.id, host),
|
||||
const manager = new PluginManager({
|
||||
// The file's time in the URL: a module is evaluated once per URL, so a replaced file switched
|
||||
// back on would otherwise run as the old one.
|
||||
load: (file, updatedAt) =>
|
||||
import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`),
|
||||
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
||||
store: async (file, active) => {
|
||||
await apiRequest(endpoint, 'PUT', `plugins/${file}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active }),
|
||||
});
|
||||
},
|
||||
report,
|
||||
});
|
||||
editor.plugins = manager;
|
||||
editor.pluginsPane.setManager(manager);
|
||||
await manager.start(entries);
|
||||
}
|
||||
Reference in new issue
Block a user