Plugins are switched on and off in a Plugins tab, the server keeps the state in data/plugins/plugins.json, and switched-off plugins are never delivered (#114)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

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

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}`);
+12
View File
@@ -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).
+8 -1
View File
@@ -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);
+165
View File
@@ -0,0 +1,165 @@
import { Graphics } from 'pixi.js';
import { Checkbox, HStack, Label, ScrollContainer, Spacer, Theme, VStack } from '../../ui';
import type { PluginManager, PluginRecord } from './loader';
const NAME_WIDTH = 200;
const VERSION_WIDTH = 70;
const FILE_WIDTH = 200;
/** What one plugin's row says about its state, beside the switch. */
export function statusText(record: PluginRecord): string {
if (record.status === 'failed') return `Failed: ${record.error ?? 'unknown reason'}`;
if (record.error) return `Switched off, but: ${record.error}`;
return record.status === 'active' ? 'Active' : 'Off';
}
/** The name to show: known once the plugin has been active in this session, the file name until then. */
export function displayName(record: PluginRecord): string {
return record.meta?.name ?? '—';
}
class PluginRow extends HStack {
readonly switch: Checkbox;
private readonly nameLabel: Label;
private readonly version: Label;
private readonly file: Label;
private readonly status: Label;
constructor(
public record: PluginRecord,
onToggle: (active: boolean) => void,
) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.minHeight = Theme.rowHeight + Theme.padding;
this.switch = new Checkbox(record.status !== 'inactive');
this.switch.onChange = onToggle;
this.add(this.switch);
this.nameLabel = new Label('', { width: NAME_WIDTH, style: 'bodyBold' });
this.version = new Label('', { width: VERSION_WIDTH, style: 'muted' });
this.file = new Label('', { width: FILE_WIDTH, style: 'muted' });
this.status = new Label('', { style: 'small' });
this.add(this.nameLabel);
this.add(this.version);
this.add(this.file);
this.add(this.status, 1);
this.show(record);
}
show(record: PluginRecord): void {
this.record = record;
this.switch.setChecked(record.status !== 'inactive');
this.nameLabel.setText(displayName(record));
this.version.setText(record.meta?.version ?? '');
this.file.setText(record.file);
this.status.setText(statusText(record));
this.status.setStyle(record.status === 'failed' ? 'warning' : 'small');
}
}
/**
* The Plugins tab (#114): every file in `data/plugins/` with a switch. Switching one on loads and
* activates it there and then, switching it off takes its tabs and listeners away again – the
* state is the server's, so it holds for everyone who opens this editor.
*
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
* reading a name out of it would mean running it.
*/
export class PluginsPane extends VStack {
private readonly headerBg = new Graphics();
private readonly hint: Label;
private readonly rows = new VStack();
private readonly scroll = new ScrollContainer();
private readonly empty: Label;
private manager: PluginManager | null = null;
private unsubscribe: (() => void) | null = null;
constructor() {
super();
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
header.add(new Label('Plugins', { style: 'bodyBold' }));
header.add(new Spacer(Theme.padding));
this.hint = new Label('', { style: 'small' });
header.add(this.hint, 1);
this.add(header);
this.empty = new Label('Loading…', { style: 'muted', padding: Theme.padding });
this.rows.add(this.empty);
this.scroll.setContent(this.rows);
this.add(this.scroll, 1);
}
setManager(manager: PluginManager): void {
this.unsubscribe?.();
this.manager = manager;
this.unsubscribe = manager.onChange(() => this.refresh());
this.refresh();
}
/** The list could not even be read; there is nothing to show but why. */
showError(message: string): void {
this.manager = null;
this.empty.setText(message);
this.refresh();
}
/** The rows as drawn, and the switch to click – what the check scripts read. */
debugInfo(): {
visible: boolean;
rows: { file: string; name: string; status: string; checked: boolean; text: string }[];
} {
return {
visible: this.visible,
rows: this.rowWidgets().map((row) => ({
file: row.record.file,
name: displayName(row.record),
status: row.record.status,
checked: row.switch.checked,
text: statusText(row.record),
})),
};
}
/** The switch of one plugin's row, for the check scripts; null when it has no row. */
switchOf(file: string): Checkbox | null {
return this.rowWidgets().find((row) => row.record.file === file)?.switch ?? null;
}
private rowWidgets(): PluginRow[] {
return this.rows.children.filter((child): child is PluginRow => child instanceof PluginRow);
}
private refresh(): void {
const records = this.manager?.records ?? [];
const rows = this.rowWidgets();
const sameList =
rows.length === records.length && rows.every((row, i) => row.record.file === records[i]?.file);
if (sameList) {
rows.forEach((row, i) => row.show(records[i]!));
} else {
for (const row of rows) row.destroy({ children: true });
for (const record of records) {
const row = new PluginRow(record, (active) => void this.manager?.setActive(record.file, active));
this.rows.add(row);
}
}
const nothing = records.length === 0;
this.empty.visible = nothing;
if (nothing && this.manager) this.empty.setText('No plugins in data/plugins');
this.hint.setText(
nothing
? ''
: 'Loaded from data/plugins, for everyone using this server. A new file stays off until switched on here.',
);
this.scroll.relayoutContent();
this.relayout();
}
override redraw(): void {
super.redraw();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.clear();
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}
+172 -33
View File
@@ -7,24 +7,68 @@ export interface PluginMeta {
api: number;
}
/** What became of one file in `data/plugins/`; the check scripts and a later settings list read it. */
/**
* What became of one file in `data/plugins/`; the Plugins tab and the check scripts read it.
* `inactive` is switched off, `failed` is switched on but not running. A switched-off plugin is
* never loaded, so its `meta` is only known once it has been active in this session.
*/
export interface PluginRecord {
file: string;
status: 'active' | 'failed';
status: 'active' | 'inactive' | 'failed';
meta: PluginMeta | null;
/** Why it is not active. */
/** Why it is not running although switched on, or what went wrong switching it off. */
error?: string;
}
/** A file as the server lists it. */
export interface PluginEntry {
file: string;
updatedAt: number;
active: boolean;
}
/**
* What one active plugin left in the editor – a tab, a listener – as the functions that take it
* away again. Switching the plugin off runs them, newest first; so does an `activate` that fails
* halfway, so a half-built plugin leaves nothing behind.
*/
export class PluginScope {
private readonly cleanups: (() => void)[] = [];
add(cleanup: () => void): void {
this.cleanups.push(cleanup);
}
/** Runs every cleanup even when one throws; returns the messages of those that did. */
dispose(): string[] {
const errors: string[] = [];
for (const cleanup of this.cleanups.splice(0).reverse()) {
try {
cleanup();
} catch (error) {
errors.push(messageOf(error));
}
}
return errors;
}
}
export interface PluginLoaderDeps {
/** The module a file holds. */
load(file: string): Promise<unknown>;
/** A fresh API for a plugin that passed the checks. */
api(meta: PluginMeta): unknown;
/** One line for the user about a plugin that could not be activated. */
/** The module a file holds; `updatedAt` tells a replaced file from the one loaded before. */
load(file: string, updatedAt: number): Promise<unknown>;
/** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */
api(meta: PluginMeta, scope: PluginScope): unknown;
/** Stores the switch on the server; throws when it was not stored. */
store(file: string, active: boolean): Promise<void>;
/** One line for the user about a plugin that could not be switched. */
report(message: string): void;
}
interface Loaded {
module: { activate: (api: unknown) => unknown; deactivate?: unknown };
scope: PluginScope;
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -37,6 +81,8 @@ export function readMeta(module: unknown): PluginMeta | string {
const { id, name, version, api } = meta as Record<string, unknown>;
if (typeof id !== 'string' || id === '') return 'meta.id must be a non-empty string';
if (typeof exports.activate !== 'function') return 'exports no activate function';
if (exports.deactivate !== undefined && typeof exports.deactivate !== 'function')
return 'deactivate must be a function';
if (api !== PLUGIN_API_VERSION)
return `needs plugin API ${String(api)}, this editor offers ${PLUGIN_API_VERSION}`;
return {
@@ -48,48 +94,141 @@ export function readMeta(module: unknown): PluginMeta | string {
}
/**
* Loads and activates every plugin, one after the other in file-name order, so that what an
* earlier one registers (a tab id, say) is settled before the next runs. Nothing a plugin does
* wrong stops the others or the editor: a file that does not load, a module that is not a plugin,
* an API version this editor does not offer, a second plugin of the same id and an `activate`
* that throws each leave a failed record and a report.
* The plugins of this editor and their switches (#113, #114). At start every switched-on plugin is
* activated, one after the other in file-name order, so that what an earlier one registers (a tab
* id, say) is settled before the next runs; one switched on later comes after all of them.
*
* Nothing a plugin does wrong stops the others or the editor: a file that does not load, a module
* that is not a plugin, an API version this editor does not offer, a second plugin of an id that
* is active already and an `activate` that throws each leave a failed record and a report.
*
* Switches run one at a time in the order they were made, so a quick off-on cannot overtake itself.
*/
export async function activatePlugins(
files: readonly string[],
deps: PluginLoaderDeps,
): Promise<PluginRecord[]> {
const records: PluginRecord[] = [];
const ids = new Set<string>();
for (const file of [...files].sort()) {
export class PluginManager {
private list: PluginRecord[] = [];
private readonly loaded = new Map<string, Loaded>();
private readonly entries = new Map<string, PluginEntry>();
private readonly listeners = new Set<() => void>();
private queue: Promise<void> = Promise.resolve();
/** False until `start` has been through the list – the tab says "Loading…" until then. */
started = false;
constructor(private readonly deps: PluginLoaderDeps) {}
get records(): readonly PluginRecord[] {
return this.list;
}
/** Called after every change of a record; returns the unsubscribe. */
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
start(entries: readonly PluginEntry[]): Promise<void> {
return this.enqueue(async () => {
const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
for (const entry of sorted) this.entries.set(entry.file, entry);
this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null }));
for (const entry of sorted) if (entry.active) await this.activate(entry.file);
this.started = true;
this.changed();
});
}
/** Switches a plugin on or off: stored on the server first, and only then applied. */
setActive(file: string, active: boolean): Promise<void> {
return this.enqueue(async () => {
const record = this.record(file);
if (!record || (record.status !== 'inactive') === active) return;
try {
await this.deps.store(file, active);
} catch (error) {
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
this.changed();
return;
}
if (active) await this.activate(file);
else this.deactivate(file);
this.changed();
});
}
private async activate(file: string): Promise<void> {
const fail = (meta: PluginMeta | null, error: string) => {
records.push({ file, status: 'failed', meta, error });
deps.report(`Plugin ${file} not activated: ${error}`);
this.update(file, { status: 'failed', meta, error });
this.deps.report(`Plugin ${file} not activated: ${error}`);
};
let module: unknown;
try {
module = await deps.load(file);
module = await this.deps.load(file, this.entries.get(file)?.updatedAt ?? 0);
} catch (error) {
fail(null, `does not load (${messageOf(error)})`);
continue;
return;
}
const meta = readMeta(module);
if (typeof meta === 'string') {
fail(null, meta);
continue;
return;
}
if (ids.has(meta.id)) {
if (this.list.some((r) => r.status === 'active' && r.meta?.id === meta.id)) {
fail(meta, `a plugin with the id "${meta.id}" is active already`);
continue;
return;
}
const scope = new PluginScope();
const loaded = { module: module as Loaded['module'], scope };
try {
const activate = (module as { activate: (api: unknown) => unknown }).activate;
await activate(deps.api(meta));
await loaded.module.activate(this.deps.api(meta, scope));
} catch (error) {
scope.dispose();
fail(meta, `activate failed (${messageOf(error)})`);
continue;
return;
}
ids.add(meta.id);
records.push({ file, status: 'active', meta });
this.loaded.set(file, loaded);
this.update(file, { status: 'active', meta, error: undefined });
}
/**
* The plugin's own `deactivate` first, while its tabs are still there to be tidied, then
* everything it registered. It is off afterwards whatever threw on the way – what threw is
* reported and kept on the record.
*/
private deactivate(file: string): void {
const loaded = this.loaded.get(file);
this.loaded.delete(file);
const errors: string[] = [];
if (loaded) {
try {
if (typeof loaded.module.deactivate === 'function') (loaded.module.deactivate as () => unknown)();
} catch (error) {
errors.push(`deactivate failed (${messageOf(error)})`);
}
errors.push(...loaded.scope.dispose().map((message) => `cleanup failed (${message})`));
}
const error = errors.length > 0 ? errors.join('; ') : undefined;
this.update(file, { status: 'inactive', error });
if (error) this.deps.report(`Plugin ${file} switched off, but: ${error}`);
}
private record(file: string): PluginRecord | undefined {
return this.list.find((r) => r.file === file);
}
private update(file: string, change: Partial<PluginRecord>): void {
this.list = this.list.map((r) => {
if (r.file !== file) return r;
const next: PluginRecord = { ...r, ...change };
if (next.error === undefined) delete next.error;
return next;
});
}
private changed(): void {
for (const listener of [...this.listeners]) listener();
}
private enqueue(work: () => Promise<void>): Promise<void> {
this.queue = this.queue.then(work, work);
return this.queue;
}
return records;
}
+26
View File
@@ -18,6 +18,32 @@ export interface PluginHost {
/** Whether a tab of this id exists already, built in or added by a plugin. */
hasTab(id: string): boolean;
addTab(id: string, label: string, content: Widget): void;
/** Takes a plugin's tab away again, its content with it. */
removeTab(id: string): void;
}
/**
* The host as one plugin sees it: every listener it subscribes and every tab it adds is written
* into `scope`, so switching the plugin off can take them away. An unsubscribe the plugin calls
* itself stays harmless when the scope calls it again.
*/
export function scopedHost(host: PluginHost, scope: { add(cleanup: () => void): void }): PluginHost {
return {
...host,
onProjectChange(listener) {
let off: (() => void) | null = host.onProjectChange(listener);
const unsubscribe = () => {
off?.();
off = null;
};
scope.add(unsubscribe);
return unsubscribe;
},
addTab(id, label, content) {
host.addTab(id, label, content);
scope.add(() => host.removeTab(id));
},
};
}
/** A change of the asset list, as `project.on('change')` sees it: ids, names and types in order. */
+27 -13
View File
@@ -1,26 +1,27 @@
import { apiRequest } from '../../core/store/http';
import type { Editor } from '../Editor';
import type { EditorState } from '../state';
import { activatePlugins } from './loader';
import { createPluginApi, type PluginHost } from './pluginApi';
import { type PluginEntry, PluginManager } from './loader';
import { createPluginApi, type PluginHost, scopedHost } from './pluginApi';
/**
* Loads what is in `data/plugins/` into the running editor (#113). A plugin is an ES module with
* no imports of its own, fetched through the API route rather than as a static file – so the
* folder cannot be used to reach anything but a plugin. A failure is a console line and a notice;
* the editor goes on without that plugin.
* Loads what is switched on in `data/plugins/` into the running editor (#113, #114). A plugin is an
* ES module with no imports of its own, fetched through the API route rather than as a static file
* – so the folder cannot be used to reach anything but a plugin. A failure is a console line and a
* notice; the editor goes on without that plugin.
*/
export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise<void> {
const report = (message: string) => {
console.error(message);
state.setNotice(message, 'quiet');
};
let files: string[];
let entries: PluginEntry[];
try {
const list = await apiRequest<{ file: string }[]>(endpoint, 'GET', 'plugins');
files = list.map((entry) => entry.file);
entries = await apiRequest<PluginEntry[]>(endpoint, 'GET', 'plugins');
} catch (error) {
report(`Plugins not loaded: ${error instanceof Error ? error.message : String(error)}`);
const message = `Plugins not loaded: ${error instanceof Error ? error.message : String(error)}`;
report(message);
editor.pluginsPane.showError(message);
return;
}
const host: PluginHost = {
@@ -28,10 +29,23 @@ export async function startPlugins(endpoint: string, state: EditorState, editor:
onProjectChange: (listener) => state.on('project', listener),
hasTab: (id) => editor.hasTab(id),
addTab: (id, label, content) => editor.addPluginTab(id, label, content),
removeTab: (id) => editor.removePluginTab(id),
};
editor.plugins = await activatePlugins(files, {
load: (file) => import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}`),
api: (meta) => createPluginApi(meta.id, host),
const manager = new PluginManager({
// The file's time in the URL: a module is evaluated once per URL, so a replaced file switched
// back on would otherwise run as the old one.
load: (file, updatedAt) =>
import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`),
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
store: async (file, active) => {
await apiRequest(endpoint, 'PUT', `plugins/${file}`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
});
},
report,
});
editor.plugins = manager;
editor.pluginsPane.setManager(manager);
await manager.start(entries);
}
+274 -39
View File
@@ -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([
+77 -5
View File
@@ -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');
}
+1
View File
@@ -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',
};
+1
View File
@@ -0,0 +1 @@
export function publishPlugins(root: string, options?: { force?: boolean }): void;
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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,