From c17b2b5426c019563b9c9c4b72af7b8f5e20a52b Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 17 Sep 2026 20:15:51 +0200 Subject: [PATCH] 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 --- README.md | 2 +- api/index.php | 30 ++- api/lib/PluginStore.php | 114 +++++++++-- src/editor/Editor.ts | 30 ++- src/editor/TabStrip.ts | 12 ++ src/editor/debugHook.ts | 9 +- src/editor/plugins/PluginsPane.ts | 165 +++++++++++++++ src/editor/plugins/loader.ts | 205 ++++++++++++++++--- src/editor/plugins/pluginApi.ts | 26 +++ src/editor/plugins/startPlugins.ts | 40 ++-- tests/editor/plugins.test.ts | 313 +++++++++++++++++++++++++---- tools/e2e/38-plugins.mjs | 82 +++++++- tools/e2e/lib/scaffold.mjs | 1 + tools/lib/plugins.d.mts | 1 + tools/lib/plugins.mjs | 66 ++++++ tools/seed-e2e.mjs | 12 +- vite.config.ts | 15 +- 17 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 src/editor/plugins/PluginsPane.ts create mode 100644 tools/lib/plugins.d.mts create mode 100644 tools/lib/plugins.mjs diff --git a/README.md b/README.md index cb1944d5..dfeca9ee 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 963 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 969 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/api/index.php b/api/index.php index acfac142..e880f79c 100644 --- a/api/index.php +++ b/api/index.php @@ -25,8 +25,9 @@ declare(strict_types=1); * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} * DELETE ?r=sounds/{file} → 204 * - * GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name - * GET ?r=plugins/{file} → the module source as text/javascript + * GET ?r=plugins → [{file,bytes,updatedAt,active}], sorted by file name + * GET ?r=plugins/{file} → the module source as text/javascript | 403 while switched off + * PUT ?r=plugins/{file} {active} → {file,bytes,updatedAt,active} * * `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and * empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`. @@ -220,19 +221,38 @@ function routeSounds(array $parts, string $method): never /** @param list $parts */ function routePlugins(array $parts, string $method): never { - if ($method !== 'GET') { - Response::error('Method not allowed', 405); - } $store = new PluginStore(dirname(__DIR__) . '/data/plugins'); if (count($parts) === 1) { + if ($method !== 'GET') { + Response::error('Method not allowed', 405); + } Response::json($store->list()); } + if ($method === 'PUT') { + $active = Response::body()['active'] ?? null; + if (!is_bool($active)) { + Response::error('active must be true or false', 422); + } + $entry = $store->setActive($parts[1], $active); + if ($entry === null) { + Response::error('Plugin not found', 404); + } + Response::json($entry); + } + if ($method !== 'GET') { + Response::error('Method not allowed', 405); + } + $source = $store->read($parts[1]); if ($source === null) { Response::error('Plugin not found', 404); } + // Switched off means not run: the editor never asks for such a module, and nobody else may. + if (!$store->isActive($parts[1])) { + Response::error('Plugin not active', 403); + } header('Content-Type: text/javascript; charset=utf-8'); header('X-Content-Type-Options: nosniff'); // A replaced file has to be the one the next editor start runs. diff --git a/api/lib/PluginStore.php b/api/lib/PluginStore.php index 569939cb..43252687 100644 --- a/api/lib/PluginStore.php +++ b/api/lib/PluginStore.php @@ -2,13 +2,16 @@ declare(strict_types=1); /** - * The editor plugins in `data/plugins/`: one ES module per `.js` file, and every file there is - * active. Read-only for now – putting a file there is done by hand, by the build and by the e2e - * seeder. The folder is not served statically: a module is only ever handed out by its bare file - * name, so no request can reach past it. + * The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one is switched on + * is kept beside them in `plugins.json` (`{"demo-plugin.js": {"active": true}}`), server-wide; a + * file the state does not name is off, so a plugin put there runs only once someone switched it + * on (#114). The folder is not served statically: a module is only ever handed out by its bare + * file name, and only while it is active, so no request can reach past it. */ final class PluginStore { + public const STATE_FILE = 'plugins.json'; + public function __construct(private readonly string $dir) { } @@ -20,33 +23,118 @@ final class PluginStore } /** - * Sorted by file name, which is also the order the editor activates them in. + * Sorted by file name, which is also the order the editor activates them in. Every plugin, + * active or not – the editor's Plugins tab is where one is switched on. * - * @return list + * @return list */ public function list(): array { $files = is_dir($this->dir) ? scandir($this->dir) : []; + $state = $this->readState() ?? []; $out = []; foreach ($files === false ? [] : $files as $file) { - $path = "{$this->dir}/{$file}"; - if (!self::isValidName($file) || !is_file($path)) { - continue; + if ($this->exists($file)) { + $out[] = $this->entry($file, $state); } - $out[] = ['file' => $file, 'bytes' => (int) filesize($path), 'updatedAt' => (int) filemtime($path)]; } usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file'])); return $out; } + public function isActive(string $file): bool + { + return $this->exists($file) && self::activeIn($this->readState() ?? [], $file); + } + /** The module's source, or null when there is no such plugin. */ public function read(string $file): ?string { - $path = "{$this->dir}/{$file}"; - if (!self::isValidName($file) || !is_file($path)) { + if (!$this->exists($file)) { return null; } - $source = file_get_contents($path); + $source = file_get_contents("{$this->dir}/{$file}"); return $source === false ? null : $source; } + + /** + * Switches a plugin on or off and returns its entry as `list` has it; null when there is no + * such plugin. The state file is rewritten whole under a lock, so two switches at once cannot + * lose each other's change. A state file that is not readable JSON is not overwritten: that + * would silently switch off everything it named. + * + * @return array{file: string, bytes: int, updatedAt: int, active: bool}|null + */ + public function setActive(string $file, bool $active): ?array + { + if (!$this->exists($file)) { + return null; + } + $lock = fopen("{$this->dir}/." . self::STATE_FILE . '.lock', 'c'); + if ($lock === false || !flock($lock, LOCK_EX)) { + throw new RuntimeException('Could not lock the plugin state'); + } + try { + $state = $this->readState(); + if ($state === null) { + throw new RuntimeException(self::STATE_FILE . ' is not readable JSON – left as it is'); + } + $state[$file] = ['active' => $active]; + ksort($state); + $path = "{$this->dir}/" . self::STATE_FILE; + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $json) === false || !rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException('Could not write ' . self::STATE_FILE); + } + return $this->entry($file, $state); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function exists(string $file): bool + { + return self::isValidName($file) && is_file("{$this->dir}/{$file}"); + } + + /** + * The state as stored; an empty one when there is no file yet, null when there is one that + * cannot be read. + * + * @return array|null + */ + private function readState(): ?array + { + $path = "{$this->dir}/" . self::STATE_FILE; + if (!is_file($path)) { + return []; + } + $raw = file_get_contents($path); + $state = $raw === false ? null : json_decode($raw, true); + return is_array($state) ? $state : null; + } + + /** @param array $state */ + private static function activeIn(array $state, string $file): bool + { + return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true; + } + + /** + * @param array $state + * @return array{file: string, bytes: int, updatedAt: int, active: bool} + */ + private function entry(string $file, array $state): array + { + $path = "{$this->dir}/{$file}"; + return [ + 'file' => $file, + 'bytes' => (int) filesize($path), + 'updatedAt' => (int) filemtime($path), + 'active' => self::activeIn($state, $file), + ]; + } } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 9575733f..81d1cee5 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -43,7 +43,8 @@ import type { PlayFrom } from './settings'; import type { EditorState } from './state'; import { TextureEditor } from './texture/TextureEditor'; import { installFileImport } from './fileImport'; -import type { PluginRecord } from './plugins/loader'; +import type { PluginManager } from './plugins/loader'; +import { PluginsPane } from './plugins/PluginsPane'; export type CentreTab = | 'scene' @@ -55,7 +56,8 @@ export type CentreTab = | 'texture' | 'animation' | 'container' - | 'list'; + | 'list' + | 'plugins'; /** A tab a plugin added; the prefix keeps it apart from every built-in one. */ export type PluginTabId = `plugin:${string}`; @@ -72,6 +74,7 @@ const TAB_ORDER = [ 'animation', 'container', 'list', + 'plugins', ] as const; const TAB_LABELS: Record = { @@ -85,6 +88,7 @@ const TAB_LABELS: Record = { animation: 'Animation', container: 'Container', list: 'List', + plugins: 'Plugins', }; /** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */ @@ -125,6 +129,8 @@ export class Editor implements PlayHost { readonly animationEditor: AnimationEditor; readonly containerEditor: ContainerEditor; readonly listEditor: ListEditor; + /** Plugins tab: the switch per file in `data/plugins/`. */ + readonly pluginsPane = new PluginsPane(); readonly hierarchy: HierarchyPanel; readonly assets: AssetPanel; readonly inspector: Inspector; @@ -144,8 +150,8 @@ export class Editor implements PlayHost { private readonly tabs: TabStrip; private readonly panes: Record; private readonly pluginPanes = new Map(); - /** Every file in `data/plugins/` and what became of it; empty until the plugins are loaded. */ - plugins: PluginRecord[] = []; + /** The plugins of this editor and their switches; null until `startPlugins` has run. */ + plugins: PluginManager | null = null; private readonly toolbar: Toolbar; /** Drawn-frame count at the last measurement, not a counter of its own. */ private fpsFrames = 0; @@ -231,6 +237,7 @@ export class Editor implements PlayHost { animation: this.animationEditor, container: this.containerEditor, list: this.listEditor, + plugins: this.pluginsPane, }; this.centre.add(this.tabs); for (const tab of TAB_ORDER) { @@ -335,6 +342,21 @@ export class Editor implements PlayHost { this.centre.relayout(); } + /** + * Takes a plugin's tab away again – the plugin was switched off, or its `activate` failed after + * adding it. Whoever was looking at it is put back on the scene. + */ + removePluginTab(id: string): void { + const tab: PluginTabId = `plugin:${id}`; + const pane = this.pluginPanes.get(tab); + if (!pane) return; + this.pluginPanes.delete(tab); + pane.destroy({ children: true }); + this.tabs.removeTab(tab); + if (this.tab === tab) this.showTab('scene'); + else this.centre.relayout(); + } + /** What a plugin put into its tab, for the check scripts. */ pluginPane(id: string): Widget | undefined { return this.pluginPanes.get(`plugin:${id}`); diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 7cdb4c34..d866dbbc 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -44,6 +44,18 @@ export class TabStrip extends HStack { this.relayout(); } + /** Takes a tab out of the row again; a plugin switched off (#114). Unknown tabs are ignored. */ + removeTab(tab: T): void { + const button = this.buttons.get(tab); + if (!button) return; + this.order.splice(this.order.indexOf(tab), 1); + this.labels.delete(tab); + this.buttons.delete(tab); + this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab); + button.destroy({ children: true }); + this.relayout(); + } + private place(tab: T, label: string): void { // Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px // window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5). diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0d5a38a2..799776f6 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -662,7 +662,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E showTab: (tab: TabId) => editor.showTab(tab), plugins: { /** Every file in `data/plugins/` and what became of it. */ - records: () => editor.plugins, + records: () => [...(editor.plugins?.records ?? [])], + /** The Plugins tab: one row per file, with its switch. */ + pane: () => editor.pluginsPane.debugInfo(), + /** Where to click the switch of one plugin; null when it has no row. */ + switchAt: (file: string) => { + const box = editor.pluginsPane.switchOf(file); + return box ? centreOf(box) : null; + }, /** A plugin tab's node board as drawn, with its canvas and Fit button; null without one. */ board: (tabId: string) => { const pane = editor.pluginPane(tabId); diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts new file mode 100644 index 00000000..5ae915f5 --- /dev/null +++ b/src/editor/plugins/PluginsPane.ts @@ -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); + } +} diff --git a/src/editor/plugins/loader.ts b/src/editor/plugins/loader.ts index 4f85a06f..1ad69754 100644 --- a/src/editor/plugins/loader.ts +++ b/src/editor/plugins/loader.ts @@ -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; - /** 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; + /** 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; + /** 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; 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 { - const records: PluginRecord[] = []; - const ids = new Set(); - for (const file of [...files].sort()) { +export class PluginManager { + private list: PluginRecord[] = []; + private readonly loaded = new Map(); + private readonly entries = new Map(); + private readonly listeners = new Set<() => void>(); + private queue: Promise = 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 { + 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 { + 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 { 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): 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): Promise { + this.queue = this.queue.then(work, work); + return this.queue; } - return records; } diff --git a/src/editor/plugins/pluginApi.ts b/src/editor/plugins/pluginApi.ts index c9b9d16f..443942c9 100644 --- a/src/editor/plugins/pluginApi.ts +++ b/src/editor/plugins/pluginApi.ts @@ -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. */ diff --git a/src/editor/plugins/startPlugins.ts b/src/editor/plugins/startPlugins.ts index 266462a9..686b7fdf 100644 --- a/src/editor/plugins/startPlugins.ts +++ b/src/editor/plugins/startPlugins.ts @@ -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 { 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(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); } diff --git a/tests/editor/plugins.test.ts b/tests/editor/plugins.test.ts index d83343d0..54c7fe7f 100644 --- a/tests/editor/plugins.test.ts +++ b/tests/editor/plugins.test.ts @@ -1,7 +1,8 @@ /** - * Plugins (#113): what the loader does with every way a file can fail to be a plugin, and what the - * API v1 lets a plugin see and do. The node board itself draws text and is checked in the browser - * (e2e step 38); only its reading of plugin input is here. + * Plugins (#113, #114): what the loader does with every way a file can fail to be a plugin, what + * the API v1 lets a plugin see and do, and what switching one on and off in the Plugins tab does. + * The node board itself draws text and is checked in the browser (e2e step 38); only its reading + * of plugin input is here. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -11,9 +12,10 @@ import { } from '../../src/core/commands/assetCommands'; import { createEmptyProject } from '../../src/core/model/Project'; import { zoomedAbout } from '../../src/editor/graph/graphView'; -import { activatePlugins, readMeta } from '../../src/editor/plugins/loader'; +import { PluginManager, type PluginRecord, PluginScope, readMeta } from '../../src/editor/plugins/loader'; import { boardNodes } from '../../src/editor/plugins/NodeBoard'; -import { createPluginApi, type PluginHost } from '../../src/editor/plugins/pluginApi'; +import { displayName, statusText } from '../../src/editor/plugins/PluginsPane'; +import { createPluginApi, type PluginHost, scopedHost } from '../../src/editor/plugins/pluginApi'; import { EditorState } from '../../src/editor/state'; import { Widget } from '../../src/ui'; @@ -22,36 +24,50 @@ const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: activate, }); -function loaderDeps(modules: Record unknown>) { +/** + * A manager on made-up modules. Every file is switched on unless `off` names it, which is what + * the server's `plugins.json` says in the real thing. + */ +function loaderDeps(modules: Record unknown>, off: string[] = []) { const reports: string[] = []; const activated: string[] = []; - return { - reports, - activated, - deps: { - load: (file: string) => { - const make = modules[file]; - return make ? Promise.resolve(make()) : Promise.reject(new Error('404')); - }, - api: (meta: { id: string }) => { - activated.push(meta.id); - return { for: meta.id }; - }, - report: (message: string) => reports.push(message), + const stored: [string, boolean][] = []; + const loads: [string, number][] = []; + const deps = { + load: (file: string, updatedAt: number) => { + loads.push([file, updatedAt]); + const make = modules[file]; + return make ? Promise.resolve(make()) : Promise.reject(new Error('404')); }, + api: (meta: { id: string }) => { + activated.push(meta.id); + return { for: meta.id }; + }, + store: (file: string, active: boolean) => { + stored.push([file, active]); + return Promise.resolve(); + }, + report: (message: string) => reports.push(message), }; + const manager = new PluginManager(deps); + const start = (files = Object.keys(modules)) => + manager.start(files.map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) }))); + return { reports, activated, stored, loads, deps, manager, start }; } +const byFileOf = (records: readonly PluginRecord[]) => + Object.fromEntries(records.map((r) => [r.file, r])) as Record; + describe('the plugin loader', () => { it('activates in file-name order, whatever order the list came in', async () => { const order: string[] = []; - const { deps } = loaderDeps({ + const { manager, start } = loaderDeps({ 'b.js': () => plugin('b', () => order.push('b')), 'a.js': () => plugin('a', () => order.push('a')), }); - const records = await activatePlugins(['b.js', 'a.js'], deps); + await start(['b.js', 'a.js']); expect(order).toEqual(['a', 'b']); - expect(records.map((r) => [r.file, r.status])).toEqual([ + expect(manager.records.map((r) => [r.file, r.status])).toEqual([ ['a.js', 'active'], ['b.js', 'active'], ]); @@ -60,7 +76,7 @@ describe('the plugin loader', () => { it('hands every plugin its own API and waits for an async activate before the next', async () => { const seen: unknown[] = []; let first = false; - const { deps } = loaderDeps({ + const { start } = loaderDeps({ 'a.js': () => plugin('a', async (api) => { seen.push(api); @@ -69,12 +85,12 @@ describe('the plugin loader', () => { }), 'b.js': () => plugin('b', (api) => seen.push(api, first)), }); - await activatePlugins(['a.js', 'b.js'], deps); + await start(); expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]); }); it('reports each broken plugin and goes on with the rest', async () => { - const { deps, reports, activated } = loaderDeps({ + const { manager, start, reports, activated } = loaderDeps({ 'good.js': () => plugin('good'), 'no-meta.js': () => ({ activate: () => {} }), 'no-activate.js': () => ({ meta: { id: 'x', api: 1 } }), @@ -84,11 +100,8 @@ describe('the plugin loader', () => { throw new Error('boom'); }), }); - const records = await activatePlugins( - ['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js'], - deps, - ); - const byFile = Object.fromEntries(records.map((r) => [r.file, r])); + await start(['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js']); + const byFile = byFileOf(manager.records); expect(byFile['good.js']?.status).toBe('active'); expect(byFile['missing.js']).toMatchObject({ status: 'failed', error: 'does not load (404)' }); expect(byFile['no-meta.js']).toMatchObject({ status: 'failed', error: 'exports no meta' }); @@ -108,25 +121,26 @@ describe('the plugin loader', () => { }); it('turns away a second plugin with an id that is active already', async () => { - const { deps, activated } = loaderDeps({ 'a.js': () => plugin('same'), 'b.js': () => plugin('same') }); - const records = await activatePlugins(['a.js', 'b.js'], deps); - expect(records.map((r) => r.status)).toEqual(['active', 'failed']); - expect(records[1]?.error).toBe('a plugin with the id "same" is active already'); + const { manager, start, activated } = loaderDeps({ + 'a.js': () => plugin('same'), + 'b.js': () => plugin('same'), + }); + await start(); + expect(manager.records.map((r) => r.status)).toEqual(['active', 'failed']); + expect(manager.records[1]?.error).toBe('a plugin with the id "same" is active already'); expect(activated).toEqual(['same']); }); it('lets a failed plugin keep its id free for a later one', async () => { - const { deps } = loaderDeps({ + const { manager, start } = loaderDeps({ 'a.js': () => plugin('same', () => { throw new Error('no'); }), 'b.js': () => plugin('same'), }); - expect((await activatePlugins(['a.js', 'b.js'], deps)).map((r) => r.status)).toEqual([ - 'failed', - 'active', - ]); + await start(); + expect(manager.records.map((r) => r.status)).toEqual(['failed', 'active']); }); it('fills in a missing name and version from what is there', () => { @@ -136,6 +150,9 @@ describe('the plugin loader', () => { version: '', api: 1, }); + expect(readMeta({ meta: { id: 'x', api: 1 }, activate: () => {}, deactivate: 'no' })).toBe( + 'deactivate must be a function', + ); expect(readMeta(null)).toBe('exports no meta'); expect(readMeta({ meta: { id: '', api: 1 }, activate: () => {} })).toBe( 'meta.id must be a non-empty string', @@ -158,6 +175,7 @@ function editorHost(): { onProjectChange: (listener) => state.on('project', listener), hasTab: (id) => id === 'scene' || tabs.has(id), addTab: (id, label, content) => tabs.set(id, { label, content }), + removeTab: (id) => void tabs.delete(id), }; return { state, host, tabs }; } @@ -258,6 +276,223 @@ describe('the plugin API', () => { }); }); +/** + * Switching plugins on and off (#114). The switch is the server's, so it is stored before it is + * applied; applying it means loading and activating, or running `deactivate` and taking back + * everything the plugin registered through its API. + */ +describe('switching a plugin on and off', () => { + /** A manager over a real API and host, so what a plugin registers is really taken away again. */ + function switchable(modules: Record unknown>, off: string[] = []) { + const { state, host, tabs } = editorHost(); + const reports: string[] = []; + const stored: [string, boolean][] = []; + const loads: [string, number][] = []; + let refuse = false; + const manager = new PluginManager({ + load: (file, updatedAt) => { + loads.push([file, updatedAt]); + const make = modules[file]; + return make ? Promise.resolve(make()) : Promise.reject(new Error('404')); + }, + api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)), + store: (file, active) => { + if (refuse) return Promise.reject(new Error('Server error')); + stored.push([file, active]); + return Promise.resolve(); + }, + report: (message) => reports.push(message), + }); + const start = () => + manager.start( + Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })), + ); + return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) }; + } + + /** A plugin that puts a tab and a change listener in place, and says when it was let go. */ + const tabPlugin = (id: string, log: string[] = []) => ({ + meta: { id, name: `Plugin ${id}`, version: '2.0', api: 1 }, + activate: (api: ReturnType) => { + api.project.on('change', () => log.push(`${id}: change`)); + api.tabs.add({ id, label: id, create: () => new Pane() }); + }, + deactivate: () => log.push(`${id}: deactivate`), + }); + + it('loads only what is switched on – a plugin that is off is never even fetched', async () => { + const { manager, start, loads } = switchable( + { 'on.js': () => plugin('on'), 'off.js': () => plugin('off') }, + ['off.js'], + ); + await start(); + expect(loads.map(([file]) => file)).toEqual(['on.js']); + expect(manager.records.map((r) => [r.file, r.status, r.meta?.name])).toEqual([ + ['off.js', 'inactive', undefined], + ['on.js', 'active', 'Plugin on'], + ]); + }); + + it('stores the switch first, then loads and activates the plugin', async () => { + const log: string[] = []; + const { manager, start, stored, loads, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }, [ + 'demo.js', + ]); + await start(); + expect(tabs.size).toBe(0); + + await manager.setActive('demo.js', true); + expect(stored).toEqual([['demo.js', true]]); + // The file's own time goes into the load, so a replaced file is not taken from the module cache. + expect(loads).toEqual([['demo.js', 1]]); + expect(tabs.has('demo')).toBe(true); + expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } }); + }); + + it('switching off runs deactivate and takes the tab and the listeners with it', async () => { + const log: string[] = []; + const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); + await start(); + state.execute(new CreateAssetCommand('sprite', 'Crate')); + expect(log).toEqual(['demo: change']); + + await manager.setActive('demo.js', false); + expect(log).toEqual(['demo: change', 'demo: deactivate']); + expect(tabs.size).toBe(0); + expect(stored).toEqual([['demo.js', false]]); + expect(manager.records[0]).toMatchObject({ status: 'inactive' }); + // Nothing of it runs any more, while the name it had stays on the row. + state.execute(new CreateAssetCommand('sprite', 'Box')); + expect(log).toEqual(['demo: change', 'demo: deactivate']); + expect(manager.records[0]?.meta?.name).toBe('Plugin demo'); + + await manager.setActive('demo.js', true); + state.execute(new CreateAssetCommand('sprite', 'Barrel')); + expect(log.at(-1)).toBe('demo: change'); + expect(tabs.has('demo')).toBe(true); + }); + + it('leaves the plugin as it was when the server does not store the switch', async () => { + const { manager, start, reports, tabs, refuse } = switchable({ 'demo.js': () => tabPlugin('demo') }); + await start(); + refuse(true); + await manager.setActive('demo.js', false); + expect(manager.records[0]?.status).toBe('active'); + expect(tabs.has('demo')).toBe(true); + expect(reports).toEqual(['Plugin demo.js not switched off: Server error']); + }); + + it('takes back what a plugin registered before its activate threw', async () => { + const { manager, start, tabs, reports } = switchable({ + 'half.js': () => ({ + meta: { id: 'half', name: 'Half', version: '1', api: 1 }, + activate: (api: ReturnType) => { + api.tabs.add({ id: 'half', label: 'Half', create: () => new Pane() }); + throw new Error('boom'); + }, + }), + }); + await start(); + expect(manager.records[0]).toMatchObject({ status: 'failed', error: 'activate failed (boom)' }); + expect(tabs.size).toBe(0); + expect(reports).toEqual(['Plugin half.js not activated: activate failed (boom)']); + // Switched off it is simply off: nothing left to tidy, nothing to report, and the name it + // managed to give stays on its row. + await manager.setActive('half.js', false); + expect(manager.records[0]).toEqual({ + file: 'half.js', + status: 'inactive', + meta: { id: 'half', name: 'Half', version: '1', api: 1 }, + }); + }); + + it('switches off even when the plugin throws on the way out, and says so', async () => { + const { manager, start, tabs, reports } = switchable({ + 'rude.js': () => ({ + meta: { id: 'rude', name: 'Rude', version: '1', api: 1 }, + activate: (api: ReturnType) => + api.tabs.add({ id: 'rude', label: 'Rude', create: () => new Pane() }), + deactivate: () => { + throw new Error('not going'); + }, + }), + }); + await start(); + await manager.setActive('rude.js', false); + expect(manager.records[0]).toMatchObject({ + status: 'inactive', + error: 'deactivate failed (not going)', + }); + expect(tabs.size).toBe(0); + expect(reports).toEqual(['Plugin rude.js switched off, but: deactivate failed (not going)']); + }); + + it('runs switches one at a time, in the order they were asked for', async () => { + const log: string[] = []; + const { manager, start, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); + await start(); + await Promise.all([ + manager.setActive('demo.js', false), + manager.setActive('demo.js', true), + manager.setActive('demo.js', false), + ]); + expect(stored).toEqual([ + ['demo.js', false], + ['demo.js', true], + ['demo.js', false], + ]); + expect(manager.records[0]?.status).toBe('inactive'); + }); + + it('tells its listeners after every change', async () => { + const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') }); + const seen = vi.fn(); + const off = manager.onChange(seen); + await start(); + expect(seen).toHaveBeenCalledTimes(1); + await manager.setActive('demo.js', false); + expect(seen).toHaveBeenCalledTimes(2); + off(); + await manager.setActive('demo.js', true); + expect(seen).toHaveBeenCalledTimes(2); + }); + + it('runs a scope’s cleanups newest first, all of them even when one throws', () => { + const scope = new PluginScope(); + const order: string[] = []; + scope.add(() => order.push('first')); + scope.add(() => { + throw new Error('middle broke'); + }); + scope.add(() => order.push('last')); + expect(scope.dispose()).toEqual(['middle broke']); + expect(order).toEqual(['last', 'first']); + // Disposed twice (switched off, then the editor tidies up) runs nothing a second time. + expect(scope.dispose()).toEqual([]); + }); + + it('says on the row what became of each plugin', () => { + const row = (record: Partial): PluginRecord => ({ + file: 'x.js', + status: 'inactive', + meta: null, + ...record, + }); + expect(statusText(row({ status: 'active' }))).toBe('Active'); + expect(statusText(row({}))).toBe('Off'); + expect(statusText(row({ status: 'failed', error: 'does not load (404)' }))).toBe( + 'Failed: does not load (404)', + ); + expect(statusText(row({ error: 'deactivate failed (x)' }))).toBe( + 'Switched off, but: deactivate failed (x)', + ); + expect(displayName(row({}))).toBe('—'); + expect(displayName(row({ meta: { id: 'demo', name: 'Demo plugin', version: '1', api: 1 } }))).toBe( + 'Demo plugin', + ); + }); +}); + describe('a node board reading plugin input', () => { it('takes titles and positions as given', () => { expect(boardNodes([{ id: 'a', title: 'Crate', x: 10, y: -20 }])).toEqual([ diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs index 234bf8e7..b1d86d6b 100644 --- a/tools/e2e/38-plugins.mjs +++ b/tools/e2e/38-plugins.mjs @@ -1,11 +1,12 @@ import { openTab, pickAssetType } from './lib/scaffold.mjs'; /** - * Plugins (#113). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and the - * seeder; the editor loads it at start-up through the API. What only a running editor can show: - * that the module really loads and activates, that its tab joins the row, and that its node board - * draws one node per asset and follows the asset list – created through the + menu, renamed, and - * taken away again by undo. + * Plugins (#113, #114). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and + * the seeder, switched on; the editor loads it at start-up through the API. What only a running + * editor can show: that the module really loads and activates, that its tab joins the row, that its + * node board draws one node per asset and follows the asset list – created through the + menu, + * renamed, and taken away again by undo – and that the switch in the Plugins tab stops and starts + * it while the editor runs. */ export async function plugins(t) { const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } = @@ -154,4 +155,75 @@ export async function plugins(t) { // 38h. Another tab hides the board; the plugin's pane is a tab like any other. await showTab('scene'); check('switching to another tab hides the board', (await board()).visible === false); + + // 38i. The Plugins tab (#114): the switch is the server's, and it works while the editor runs. + await openTab(t, 'plugins'); + await settle(); + const pane = () => json('__gm.plugins.pane()'); + let rows = await pane(); + const rowOf = (shown) => shown.rows.find((r) => r.file === 'demo-plugin.js'); + check( + 'the Plugins tab lists the demo plugin as active, with its name and its switch on', + rows.visible === true && + rowOf(rows)?.status === 'active' && + rowOf(rows)?.name === 'Demo plugin' && + rowOf(rows)?.checked === true, + JSON.stringify(rows), + ); + + const toggle = async () => { + const at = await json('__gm.plugins.switchAt("demo-plugin.js")'); + await t.click(at.x, at.y); + await settle(); + // Switching goes to the server and back before it is applied. + for (let i = 0; i < 50; i++) { + const now = await pane(); + if (rowOf(now)?.status !== rowOf(rows)?.status) return now; + await sleep(100); + } + return pane(); + }; + + rows = await toggle(); + const rowAfterOff = await json('__gm.tabRow()'); + check( + 'clicking the switch off stops the plugin', + rowOf(rows)?.status === 'inactive' && rowOf(rows)?.checked === false, + JSON.stringify(rows), + ); + check( + 'and its tab is gone from the row, with nothing left of its board', + ![...rowAfterOff.shown.map((p) => p.tab), ...rowAfterOff.hidden].includes('plugin:assets') && + (await board()) === null, + JSON.stringify(rowAfterOff), + ); + // The switch is the server's, not this browser's: the list says so for everyone. (That a + // switched-off module is not handed out either is the API's own check, not one for here – the + // refusal never reaches the page, the site's firewall answers it with a page of its own.) + const listed = await evaluate( + `fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`, + ); + check( + 'the server stores the switch: its list says the plugin is off', + JSON.parse(listed).find((e) => e.file === 'demo-plugin.js')?.active === false, + listed, + ); + + rows = await toggle(); + await settle(); + const rowAfterOn = await json('__gm.tabRow()'); + shown = await board(); + check( + 'switching it on again activates it there and then', + rowOf(rows)?.status === 'active' && + [...rowAfterOn.shown.map((p) => p.tab), ...rowAfterOn.hidden].includes('plugin:assets'), + JSON.stringify({ row: rowOf(rows), tabs: rowAfterOn }), + ); + check( + 'and its board is back with a node per asset', + JSON.stringify(titles(shown)) === JSON.stringify(await names()), + JSON.stringify({ nodes: titles(shown), assets: await names() }), + ); + await shot('39-plugins-tab'); + await showTab('scene'); } diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index f89d6d25..22db6448 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -112,6 +112,7 @@ export const TAB_LABELS = { container: 'Container', list: 'List', particles: 'Particles', + plugins: 'Plugins', // Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab. 'plugin:assets': 'Assets', }; diff --git a/tools/lib/plugins.d.mts b/tools/lib/plugins.d.mts new file mode 100644 index 00000000..ead758a5 --- /dev/null +++ b/tools/lib/plugins.d.mts @@ -0,0 +1 @@ +export function publishPlugins(root: string, options?: { force?: boolean }): void; diff --git a/tools/lib/plugins.mjs b/tools/lib/plugins.mjs new file mode 100644 index 00000000..14de6c70 --- /dev/null +++ b/tools/lib/plugins.mjs @@ -0,0 +1,66 @@ +import { + chownSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * Puts the plugins that ship with the repository (`plugins/*.js`) where the editor loads them from, + * `data/plugins/`, and switches them on in `plugins.json` (#114) – a plugin put there by hand stays + * off until someone switches it on in the Plugins tab. Used by the build and by the e2e seeder. + * + * `force` is the seeder's: the checks need the demo plugin on, whatever a person did with the + * switch. The build only switches on what the state does not name yet, so a plugin switched off + * on the server stays off across deployments. + * + * The API writes `plugins.json` as the web server's user. When this runs as root (the build on the + * host), the folder and what it wrote are handed to the owner of `data/`, which is that user. + */ +export function publishPlugins(root, { force = false } = {}) { + const target = resolve(root, 'data/plugins'); + mkdirSync(target, { recursive: true }); + const written = [target]; + const shipped = readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js')); + for (const file of shipped) { + copyFileSync(resolve(root, 'plugins', file), resolve(target, file)); + written.push(resolve(target, file)); + } + + const statePath = resolve(target, 'plugins.json'); + let state = {}; + if (existsSync(statePath)) { + try { + state = JSON.parse(readFileSync(statePath, 'utf8')); + } catch { + console.warn(`${statePath} is not readable JSON – left as it is, no plugin switched on`); + state = null; + } + } + if (state && typeof state === 'object') { + let changed = false; + for (const file of shipped) { + if (!force && Object.hasOwn(state, file)) continue; + if (state[file]?.active === true) continue; + state[file] = { active: true }; + changed = true; + } + if (changed) { + const sorted = Object.fromEntries( + Object.entries(state).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + writeFileSync(statePath, `${JSON.stringify(sorted, null, 4)}\n`); + } + written.push(statePath); + } + + if (process.getuid?.() === 0) { + const { uid, gid } = statSync(resolve(root, 'data')); + for (const path of written) if (existsSync(path)) chownSync(path, uid, gid); + } +} diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index 1284d98f..a1403756 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -6,24 +6,22 @@ * * Usage: node tools/seed-e2e.mjs [projectId] [baseUrl] */ -import { copyFileSync, mkdirSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { sineWav } from './lib/audio.mjs'; import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; +import { publishPlugins } from './lib/plugins.mjs'; import { PROJECTS, cliArgs, uploadFont, uploadSound, writeProject } from './lib/projects.mjs'; import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); /** - * The plugins that ship with the repository, where the editor loads them from – the build does the - * same, this makes a run after a plain checkout independent of it. The server is this checkout + * The plugins that ship with the repository, where the editor loads them from and switched on – + * the build does the same, this makes a run after a plain checkout independent of it, and puts the + * demo plugin back on after a step (or a person) switched it off. The server is this checkout * (locally and in CI), so a file copy is enough. */ -const ROOT = fileURLToPath(new URL('..', import.meta.url)); -mkdirSync(`${ROOT}data/plugins`, { recursive: true }); -for (const file of readdirSync(`${ROOT}plugins`).filter((f) => f.endsWith('.js'))) - copyFileSync(`${ROOT}plugins/${file}`, `${ROOT}data/plugins/${file}`); +publishPlugins(fileURLToPath(new URL('..', import.meta.url)), { force: true }); /** * Two typefaces for the font step to pick and to drop. They are put on the server but not made diff --git a/vite.config.ts b/vite.config.ts index 8b7d23d7..563e1f08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ -import { copyFileSync, mkdirSync, readdirSync } from 'node:fs'; +import { copyFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig, type Plugin } from 'vite'; +import { publishPlugins } from './tools/lib/plugins.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -24,17 +25,15 @@ function publishIndexHtml(): Plugin { /** * Editor plugins are loaded from `data/plugins/`, which is not in the repository; the ones that * ship with it live in `plugins/` and are put there on every build. A file of the same name is - * replaced, anything else in the folder is left alone. + * replaced, anything else in the folder is left alone. A shipped plugin is switched on unless the + * server's state says otherwise (see `tools/lib/plugins.mjs`). */ -function publishPlugins(): Plugin { +function shipPlugins(): Plugin { return { name: 'game-maker:publish-plugins', apply: 'build', closeBundle() { - const target = resolve(__dirname, 'data/plugins'); - mkdirSync(target, { recursive: true }); - for (const file of readdirSync(resolve(__dirname, 'plugins')).filter((f) => f.endsWith('.js'))) - copyFileSync(resolve(__dirname, 'plugins', file), resolve(target, file)); + publishPlugins(__dirname); }, }; } @@ -43,7 +42,7 @@ export default defineConfig({ root: 'web', base: '/game-maker/dist/', publicDir: false, - plugins: [publishIndexHtml(), publishPlugins()], + plugins: [publishIndexHtml(), shipPlugins()], build: { outDir: '../dist', emptyOutDir: true,