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
@@ -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
|
||||
|
||||
+25
-5
@@ -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<string> $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.
|
||||
|
||||
+101
-13
@@ -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<array{file: string, bytes: int, updatedAt: int}>
|
||||
* @return list<array{file: string, bytes: int, updatedAt: int, active: bool}>
|
||||
*/
|
||||
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<string, mixed>|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<string, mixed> $state */
|
||||
private static function activeIn(array $state, string $file): bool
|
||||
{
|
||||
return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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),
|
||||
];
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -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<CentreTab, string> = {
|
||||
@@ -85,6 +88,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
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<TabId>;
|
||||
private readonly panes: Record<CentreTab, Widget>;
|
||||
private readonly pluginPanes = new Map<PluginTabId, Widget>();
|
||||
/** 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}`);
|
||||
|
||||
@@ -44,6 +44,18 @@ export class TabStrip<T extends string> 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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+274
-39
@@ -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<string, () => 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<string, () => 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<string, PluginRecord | undefined>;
|
||||
|
||||
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<string, () => 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<typeof createPluginApi>) => {
|
||||
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<typeof createPluginApi>) => {
|
||||
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<typeof createPluginApi>) =>
|
||||
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>): 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([
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function publishPlugins(root: string, options?: { force?: boolean }): void;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -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
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user