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)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 20:15:51 +02:00
1 parent 57a97ba94f
commit c17b2b5426
17 files changed
+994 -129

No files matched your search

+165
View File
@@ -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
View File
@@ -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;
}
+26
View File
@@ -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. */
+27 -13
View File
@@ -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);
}