diff --git a/README.md b/README.md index e52a0ae4..25dbc764 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,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, 1126 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1129 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index 0eb30905..7c7bb0e6 100644 --- a/api/index.php +++ b/api/index.php @@ -35,9 +35,8 @@ 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,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} + * GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name + * GET ?r=plugins/{file} → the module source as text/javascript * * `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}`. @@ -299,17 +298,6 @@ function routePlugins(array $parts, string $method): never 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); } @@ -318,10 +306,8 @@ function routePlugins(array $parts, string $method): never 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); - } + // Every plugin file is handed out: since #119 the project says which of them the editor runs, + // and the server has no way of knowing which project the asking tab has open. header('Content-Type: text/javascript; charset=utf-8'); header('X-Content-Type-Options: nosniff'); // A replaced file has to be the one the next editor start runs. diff --git a/api/lib/PluginStore.php b/api/lib/PluginStore.php index 43252687..17b4cdaa 100644 --- a/api/lib/PluginStore.php +++ b/api/lib/PluginStore.php @@ -2,16 +2,13 @@ declare(strict_types=1); /** - * 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. + * The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one runs is written + * in the project that is open, not here (#119) – the server hands out every plugin it has and knows + * nothing about switches. The folder is still not served statically: a module is only ever handed + * out by its bare file name, so no request can reach past it. */ final class PluginStore { - public const STATE_FILE = 'plugins.json'; - public function __construct(private readonly string $dir) { } @@ -23,30 +20,28 @@ final class PluginStore } /** - * 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. + * Sorted by file name, which is also the order the editor activates them in. * - * @return list + * @return list */ public function list(): array { $files = is_dir($this->dir) ? scandir($this->dir) : []; - $state = $this->readState() ?? []; $out = []; foreach ($files === false ? [] : $files as $file) { if ($this->exists($file)) { - $out[] = $this->entry($file, $state); + $path = "{$this->dir}/{$file}"; + $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 { @@ -57,84 +52,8 @@ final class PluginStore return $source === false ? null : $source; } - /** - * Switches a plugin on or off and returns its entry as `list` has it; null when there is no - * such plugin. The state file is rewritten whole under a lock, so two switches at once cannot - * lose each other's change. A state file that is not readable JSON is not overwritten: that - * would silently switch off everything it named. - * - * @return array{file: string, bytes: int, updatedAt: int, active: bool}|null - */ - public function setActive(string $file, bool $active): ?array - { - if (!$this->exists($file)) { - return null; - } - $lock = fopen("{$this->dir}/." . self::STATE_FILE . '.lock', 'c'); - if ($lock === false || !flock($lock, LOCK_EX)) { - throw new RuntimeException('Could not lock the plugin state'); - } - try { - $state = $this->readState(); - if ($state === null) { - throw new RuntimeException(self::STATE_FILE . ' is not readable JSON – left as it is'); - } - $state[$file] = ['active' => $active]; - ksort($state); - $path = "{$this->dir}/" . self::STATE_FILE; - $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Could not write ' . self::STATE_FILE); - } - return $this->entry($file, $state); - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } - } - private function exists(string $file): bool { return self::isValidName($file) && is_file("{$this->dir}/{$file}"); } - - /** - * The state as stored; an empty one when there is no file yet, null when there is one that - * cannot be read. - * - * @return array|null - */ - private function readState(): ?array - { - $path = "{$this->dir}/" . self::STATE_FILE; - if (!is_file($path)) { - return []; - } - $raw = file_get_contents($path); - $state = $raw === false ? null : json_decode($raw, true); - return is_array($state) ? $state : null; - } - - /** @param array $state */ - private static function activeIn(array $state, string $file): bool - { - return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true; - } - - /** - * @param array $state - * @return array{file: string, bytes: int, updatedAt: int, active: bool} - */ - private function entry(string $file, array $state): array - { - $path = "{$this->dir}/{$file}"; - return [ - 'file' => $file, - 'bytes' => (int) filesize($path), - 'updatedAt' => (int) filemtime($path), - 'active' => self::activeIn($state, $file), - ]; - } } diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 3254b1d5..6457f010 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -354,6 +354,32 @@ export class SetStartSceneCommand implements Command { } } +/** + * Switches an editor plugin on or off for this project (#119). It is an undo step like any other + * change to the project: the switch changes the project, and the editor follows the project. + */ +export class SetPluginActiveCommand implements Command { + readonly label: string; + private previous: string[] = []; + + constructor( + private readonly file: string, + private readonly active: boolean, + ) { + this.label = `Switch plugin ${active ? 'on' : 'off'}`; + } + + execute(project: Project): void { + this.previous = [...project.plugins]; + const rest = this.previous.filter((f) => f !== this.file); + project.plugins = this.active ? [...rest, this.file].sort() : rest; + } + + undo(project: Project): void { + project.plugins = [...this.previous]; + } +} + export class SetActiveSceneCommand implements Command { readonly label = 'Switch scene'; private previous = ''; diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index e3ceedcb..fab4f02b 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -23,6 +23,12 @@ export interface Project { * the active scene; no format bump needed for that, since an older project reads as exactly that. */ startSceneId: string; + /** + * The plugin files (`data/plugins/*.js`) this project switches on (#119). The editor loads them + * before the project itself, so what they register is there before anything is read. A project + * written before there was such a field names none, which is also what a new one does. + */ + plugins: string[]; } export function createEmptyProject(id: string, name: string): Project { @@ -33,6 +39,7 @@ export function createEmptyProject(id: string, name: string): Project { assets: [], activeSceneId: '', startSceneId: '', + plugins: [], }; } diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 0ce9efc9..625b838d 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -15,11 +15,12 @@ export class HttpProjectStore implements ProjectStore { return this.request('GET', 'projects'); } - async load(id: string): Promise { + async load(id: string, prepare?: (raw: unknown) => Promise): Promise { const body = await this.request<{ revision: number; project: unknown }>( 'GET', `projects/${encodeURIComponent(id)}`, ); + await prepare?.(body.project); return { project: deserializeProject(body.project, id), revision: body.revision }; } diff --git a/src/core/store/ProjectStore.ts b/src/core/store/ProjectStore.ts index 692d4289..5b604217 100644 --- a/src/core/store/ProjectStore.ts +++ b/src/core/store/ProjectStore.ts @@ -22,7 +22,12 @@ export class StaleRevisionError extends Error { export interface ProjectStore { list(): Promise; - load(id: string): Promise; + /** + * `prepare` sees the untrusted JSON as it came, and is awaited before it is turned into a + * Project. That is where the editor plugins of this project are loaded (#119): what they register + * has to exist before the deserializer reads the file, or it would drop it as unknown. + */ + load(id: string, prepare?: (raw: unknown) => Promise): Promise; /** Returns the new revision; rejects with `StaleRevisionError` on conflict. */ save(project: Project, revision: number): Promise; create(name: string): Promise; diff --git a/src/core/store/projectPlugins.ts b/src/core/store/projectPlugins.ts new file mode 100644 index 00000000..66e8521c --- /dev/null +++ b/src/core/store/projectPlugins.ts @@ -0,0 +1,15 @@ +/** + * Which plugins a project switches on (#119), read straight off the untrusted JSON of the project + * file. This is deliberately its own module rather than a part of `deserializeProject`: the editor + * has to know the list *before* it deserializes anything, because a plugin may add node types and + * asset kinds that the deserializer would otherwise throw away as unknown. + */ +export function pluginsOf(raw: unknown): string[] { + const list = (raw as { plugins?: unknown } | null)?.plugins; + const files = (Array.isArray(list) ? list : []).filter( + (file): file is string => typeof file === 'string' && file !== '', + ); + // Sorted and without repeats: that is also the order the loader activates them in, so a project + // file written by hand cannot make one plugin come before another by listing it first. + return [...new Set(files)].sort(); +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d43a8ffa..9ca451e8 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -28,6 +28,7 @@ import type { TilemapLayer, } from '../model/Asset'; import { parseCellKey } from '../model/tilemap'; +import { pluginsOf } from './projectPlugins'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; @@ -218,6 +219,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets, activeSceneId, startSceneId, + // The same reading the editor did before it loaded the plugins (#119), so the model says what + // is actually running. + plugins: pluginsOf(r), }; return project; } diff --git a/src/editor/App.ts b/src/editor/App.ts index 2a42be5f..10291fba 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -101,9 +101,11 @@ export async function startEditor(app: Application, root: VStack): Promise }, ); installDebugHook(app, state, editor); + // Before the project, not after it (#119): which plugins run is written in the project file, + // and `boot()` hands that list over before it reads the file into the model. + await startPlugins(API_ENDPOINT, state, editor); await editor.boot(); loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error)); - await startPlugins(API_ENDPOINT, state, editor); }; /** diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 30451580..2cf4c82e 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -2,6 +2,7 @@ import type { Application, Ticker } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; +import { pluginsOf } from '../core/store/projectPlugins'; import type { FontStore } from '../core/store/FontStore'; import type { SoundStore } from '../core/store/SoundStore'; import type { TextureStore } from '../core/store/TextureStore'; @@ -39,7 +40,7 @@ import { resolveList } from './list/slot'; import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; -import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons'; +import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG, PLUGINS_ICON_SVG } from './panels/assetIcons'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { TilemapPanel } from './panels/TilemapPanel'; import { Toolbar } from './panels/Toolbar'; @@ -125,10 +126,11 @@ const TAB_LABELS: Record = { plugins: 'Plugins', }; -/** Icons of the fixed tabs (#176); the Plugins tab has none, as plugins have no picture of their own. */ +/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */ const FIXED_TAB_ICONS: Partial> = { scene: ASSET_ICON_SVG.scene, game: GAME_ICON_SVG, + plugins: PLUGINS_ICON_SVG, }; function isDocument(tab: StripTab): tab is DocumentId { @@ -247,9 +249,16 @@ export class Editor implements PlayHost { this.viewport.setEditorWorld(world); }, }); - this.projects = new ProjectSession(state, store, () => { - if (this.play.isPlaying) this.play.stop(); - }); + this.projects = new ProjectSession( + state, + store, + () => { + if (this.play.isPlaying) this.play.stop(); + }, + // Every project brings its own plugins (#119); until `startPlugins` has run there is no + // manager, and a project with plugins simply has none of them. + async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))), + ); this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.timeProvider = () => this.play.time; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 8545dc78..82d440de 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -16,6 +16,12 @@ export class ProjectSession { private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void, + /** + * Runs on the project as it came off the server, before it is read into the model: the plugins + * this project switches on are loaded there (#119), so that what they register is in place + * before the deserializer would drop it as unknown. + */ + private readonly prepare: (raw: unknown) => Promise = () => Promise.resolve(), ) {} /** Opens the project named in the URL, else the first one on the server, else a new one. */ @@ -37,7 +43,7 @@ export class ProjectSession { private async open(id: string): Promise { this.beforeSwitch(); - const loaded = await this.store.load(id); + const loaded = await this.store.load(id, this.prepare); this.state.loadProject(loaded.project, loaded.revision); const url = new URL(location.href); url.searchParams.set('project', id); diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index cfcbbb4d..a787cedc 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -12,6 +12,7 @@ import font from './icons/font.svg?raw'; import game from './icons/game.svg?raw'; import list from './icons/list.svg?raw'; import particles from './icons/particles.svg?raw'; +import plugins from './icons/plugins.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw'; import scene from './icons/scene.svg?raw'; import shader from './icons/shader.svg?raw'; @@ -50,3 +51,6 @@ export const LIST_ICON_SVG = list; /** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */ export const GAME_ICON_SVG = game; + +/** The Plugins tab's: a puzzle piece, as a plugin is a piece added to the editor (#119). */ +export const PLUGINS_ICON_SVG = plugins; diff --git a/src/editor/panels/icons/plugins.svg b/src/editor/panels/icons/plugins.svg new file mode 100644 index 00000000..6b2f8d4b --- /dev/null +++ b/src/editor/panels/icons/plugins.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts index 8d105ea0..5bcd84ba 100644 --- a/src/editor/plugins/PluginsPane.ts +++ b/src/editor/plugins/PluginsPane.ts @@ -57,9 +57,9 @@ class PluginRow extends HStack { } /** - * 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. + * The Plugins tab (#114, #119): 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 + * switch is part of the open project, so it travels with it and undo takes it back. * * 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. @@ -144,9 +144,7 @@ export class PluginsPane extends VStack { 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.', + nothing ? '' : 'Loaded from data/plugins. The switch belongs to this project and is an undo step.', ); this.scroll.relayoutContent(); this.relayout(); diff --git a/src/editor/plugins/loader.ts b/src/editor/plugins/loader.ts index 9b0158ec..7821d8f7 100644 --- a/src/editor/plugins/loader.ts +++ b/src/editor/plugins/loader.ts @@ -20,11 +20,10 @@ export interface PluginRecord { error?: string; } -/** A file as the server lists it. */ +/** A file as the server lists it. Whether it runs is the project's business, not the server's (#119). */ export interface PluginEntry { file: string; updatedAt: number; - active: boolean; } /** @@ -58,8 +57,11 @@ export interface PluginLoaderDeps { load(file: string, updatedAt: number): Promise; /** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */ api(meta: PluginMeta, scope: PluginScope): unknown; - /** Stores the switch on the server; throws when it was not stored. */ - store(file: string, active: boolean): Promise; + /** + * Writes the switch into the project – an undo step (#119). Throws when it was not written, and + * then nothing is switched. + */ + store(file: string, active: boolean): void; /** One line for the user about a plugin that could not be switched. */ report(message: string): void; } @@ -73,6 +75,11 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** Both lists are sorted and without repeats when this is asked. */ +function sameFiles(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((file, i) => file === b[i]); +} + /** The module's `meta` as the loader needs it, or why it cannot be used. */ export function readMeta(module: unknown): PluginMeta | string { const exports = (typeof module === 'object' && module !== null ? module : {}) as Record; @@ -94,9 +101,11 @@ export function readMeta(module: unknown): PluginMeta | string { } /** - * 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. + * The plugins of this editor and their switches (#113, #114, #119). Which ones run is the open + * project's business: `syncTo` takes the list out of the project file and makes the editor match + * it, before the project itself is read. Plugins are 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 @@ -110,8 +119,12 @@ export class PluginManager { private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); private queue: Promise = Promise.resolve(); + /** The files the project last asked for, whether or not each of them made it to `active`. */ + private wanted: string[] = []; /** False until `start` has been through the list – the tab says "Loading…" until then. */ started = false; + /** Set by `stopAll`: this editor is going away, and nothing may start a plugin in it again. */ + private stopped = false; constructor(private readonly deps: PluginLoaderDeps) {} @@ -125,14 +138,41 @@ export class PluginManager { return () => this.listeners.delete(listener); } + /** + * Takes in the files the server has, all of them switched off. Nothing is loaded yet: what runs + * is decided by the project, which is read next (#119). + */ start(entries: readonly PluginEntry[]): Promise { return this.enqueue(async () => { const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); for (const entry of sorted) this.entries.set(entry.file, entry); this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null })); - for (const entry of sorted) if (entry.active) await this.activate(entry.file); this.started = true; this.changed(); + await Promise.resolve(); + }); + } + + /** + * Makes the running plugins match what a project asks for: those it does not name are switched + * off first, so a plugin of the project being left has let go before the next one's plugins + * register anything. A file the server does not have is passed over – it has no row either. + * + * Called for every project the editor opens, before the project is deserialized, and again for + * every change of the list afterwards (the switch in the Plugins tab, and undo). Asking for what + * is running already does nothing at all. + */ + syncTo(files: readonly string[]): Promise { + const wanted = [...new Set(files)].sort(); + if (this.stopped || (this.started && sameFiles(wanted, this.wanted))) return this.queue; + this.wanted = wanted; + return this.enqueue(async () => { + // A plugin that failed counts as switched off here: it is no longer wanted, so its row goes + // back to plain "Off", and naming it again is a fresh attempt. + for (const record of this.list) + if (!wanted.includes(record.file) && record.status !== 'inactive') this.deactivate(record.file); + for (const file of wanted) if (this.record(file)?.status === 'inactive') await this.activate(file); + this.changed(); }); } @@ -144,28 +184,32 @@ export class PluginManager { stopAll(): Promise { return this.enqueue(async () => { for (const file of [...this.loaded.keys()]) this.deactivate(file); + this.wanted = []; this.started = false; + this.stopped = true; this.changed(); await Promise.resolve(); }); } - /** Switches a plugin on or off: stored on the server first, and only then applied. */ + /** + * The switch in the Plugins tab: written into the project first, and only then applied – so what + * runs and what the project says can never come apart, and undo takes both back. + */ setActive(file: string, active: boolean): Promise { - return this.enqueue(async () => { - const record = this.record(file); - if (!record || (record.status !== 'inactive') === active) return; - try { - await this.deps.store(file, active); - } catch (error) { - this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`); - this.changed(); - return; - } - if (active) await this.activate(file); - else this.deactivate(file); + // Against the list asked for, not against the record: a second click while the first switch is + // still running has to count, and the record only catches up when it is done. + if (this.stopped || !this.record(file) || this.wanted.includes(file) === active) return this.queue; + const rest = this.wanted.filter((f) => f !== file); + const next = active ? [...rest, file].sort() : rest; + try { + this.deps.store(file, active); + } catch (error) { + this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`); this.changed(); - }); + return this.queue; + } + return this.syncTo(next); } private async activate(file: string): Promise { diff --git a/src/editor/plugins/startPlugins.ts b/src/editor/plugins/startPlugins.ts index 686b7fdf..2924d648 100644 --- a/src/editor/plugins/startPlugins.ts +++ b/src/editor/plugins/startPlugins.ts @@ -1,3 +1,4 @@ +import { SetPluginActiveCommand } from '../../core/commands/assetCommands'; import { apiRequest } from '../../core/store/http'; import type { Editor } from '../Editor'; import type { EditorState } from '../state'; @@ -5,10 +6,14 @@ import { type PluginEntry, PluginManager } from './loader'; import { createPluginApi, type PluginHost, scopedHost } from './pluginApi'; /** - * Loads what is switched on in `data/plugins/` into the running editor (#113, #114). A plugin is an + * Puts the plugins of `data/plugins/` at the editor's disposal (#113, #114, #119). 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. + * + * Nothing is loaded here: which plugins run is the open project's business, and this runs before + * the first project is even fetched. `Editor.boot()` hands the manager the list out of the raw + * project file, and from then on the project is followed – including undo of the switch. */ export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise { const report = (message: string) => { @@ -37,15 +42,18 @@ export async function startPlugins(endpoint: string, state: EditorState, editor: 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 }), - }); + // A refused command (a notice of its own) must not leave the plugin running against what the + // project says, so it counts as "not stored". + store: (file, active) => { + if (!state.execute(new SetPluginActiveCommand(file, active))) + throw new Error('the project did not take the switch'); }, report, }); editor.plugins = manager; editor.pluginsPane.setManager(manager); + // Undo, redo and a command of any other kind can move the list: the project decides, so the + // editor follows it rather than remembering what it switched itself. + state.on('project', () => void manager.syncTo(state.project.plugins)); await manager.start(entries); } diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 06a0e1a3..7ebfb3f6 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -149,6 +149,7 @@ describe('CommandStack', () => { assets: [], activeSceneId: '', startSceneId: '', + plugins: [], }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts index 79512b9f..3ea9f970 100644 --- a/tests/core/http-store.test.ts +++ b/tests/core/http-store.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HttpProjectStore } from '../../src/core/store/HttpProjectStore'; import { HttpFileStore } from '../../src/core/store/HttpFileStore'; +import { pluginsOf } from '../../src/core/store/projectPlugins'; import { StaleRevisionError } from '../../src/core/store/ProjectStore'; import { makeProject } from '../helpers'; @@ -39,6 +40,27 @@ describe('HttpProjectStore', () => { await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/); }); + /** + * The editor loads the project's plugins in `prepare` (#119). What they register – node types, + * asset kinds – has to be there before the JSON is read, or the deserializer drops it as unknown. + */ + it('runs prepare on the raw JSON and waits for it before deserializing', async () => { + stubFetch(JSON.stringify({ revision: 4, project: { id: 'p', name: 'P', plugins: ['demo.js'] } }), { + status: 200, + }); + let seen: unknown; + const loaded = await new HttpProjectStore('/api').load('p', async (raw) => { + seen = raw; + await Promise.resolve(); + // Whatever prepare does has to be part of what is read: this stands for the asset kinds and + // node types a plugin registers while it is being activated. + (raw as { assets: unknown[] }).assets = [{ id: 's', type: 'scene', name: 'Level' }]; + }); + expect(pluginsOf(seen)).toEqual(['demo.js']); + expect(loaded.project.plugins).toEqual(['demo.js']); + expect(loaded.project.assets.map((a) => a.name)).toEqual(['Level']); + }); + it('turns a 409 into a StaleRevisionError', async () => { stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 }); const { project } = makeProject(); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 0ea1d606..fd08acb3 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -6,6 +6,7 @@ import { } from '../../src/core/commands/assetCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { pluginsOf } from '../../src/core/store/projectPlugins'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; @@ -17,6 +18,21 @@ describe('serialize', () => { expect(roundTrip(f.project)).toEqual(f.project); }); + /** + * Which plugins the project switches on (#119). The editor reads them off the raw file before it + * deserializes anything, so `pluginsOf` is what both sides use and both have to agree. + */ + it('reads the plugins of a project the same way before and after deserializing', () => { + const raw = { id: 'p', name: 'P', plugins: ['b.js', 'a.js', 'a.js', '', 7] }; + expect(pluginsOf(raw)).toEqual(['a.js', 'b.js']); + expect(deserializeProject(raw).plugins).toEqual(['a.js', 'b.js']); + // A project written before there was such a field, and anything that is not a list of names. + expect(pluginsOf({ id: 'p' })).toEqual([]); + expect(pluginsOf({ plugins: 'demo.js' })).toEqual([]); + expect(pluginsOf(null)).toEqual([]); + expect(deserializeProject({ id: 'p' }).plugins).toEqual([]); + }); + /** A behavior's own rows are not the common ones: coercing against those alone dropped the value. */ it('keeps a behavior running in the editor across a save', () => { const f = makeSpriteScene(); diff --git a/tests/editor/plugins.test.ts b/tests/editor/plugins.test.ts index 809c8165..d611122d 100644 --- a/tests/editor/plugins.test.ts +++ b/tests/editor/plugins.test.ts @@ -9,6 +9,7 @@ import { CreateAssetCommand, RenameAssetCommand, SetAssetPropertyCommand, + SetPluginActiveCommand, } from '../../src/core/commands/assetCommands'; import { createEmptyProject } from '../../src/core/model/Project'; import { zoomedAbout } from '../../src/editor/graph/graphView'; @@ -25,8 +26,8 @@ const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: }); /** - * 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. + * A manager on made-up modules. `start` hands it the files the server has and then the list the + * open project switches on – everything but `off`, which stands for what the project leaves out. */ function loaderDeps(modules: Record unknown>, off: string[] = []) { const reports: string[] = []; @@ -43,15 +44,14 @@ function loaderDeps(modules: Record unknown>, off: string[] = []) activated.push(meta.id); return { for: meta.id }; }, - store: (file: string, active: boolean) => { - stored.push([file, active]); - return Promise.resolve(); - }, + store: (file: string, active: boolean) => void stored.push([file, active]), 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) }))); + const start = async (files = Object.keys(modules)) => { + await manager.start(files.map((file, i) => ({ file, updatedAt: i + 1 }))); + await manager.syncTo(files.filter((file) => !off.includes(file))); + }; return { reports, activated, stored, loads, deps, manager, start }; } @@ -277,12 +277,16 @@ 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 + * Switching plugins on and off (#114, #119). The switch belongs to the open project: it is written + * there first and applied afterwards, and the editor follows the project – so undo takes the switch + * back as well. 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. */ + /** + * A manager over a real API, host and project, wired the way `startPlugins` wires it: the switch + * runs through a command, and what runs follows the project's list. + */ function switchable(modules: Record unknown>, off: string[] = []) { const { state, host, tabs } = editorHost(); const reports: string[] = []; @@ -297,16 +301,19 @@ describe('switching a plugin on and off', () => { }, api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)), store: (file, active) => { - if (refuse) return Promise.reject(new Error('Server error')); + if (refuse) throw new Error('Project is read-only'); stored.push([file, active]); - return Promise.resolve(); + state.execute(new SetPluginActiveCommand(file, active)); }, report: (message) => reports.push(message), }); - const start = () => - manager.start( - Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })), - ); + state.on('project', () => void manager.syncTo(state.project.plugins)); + const on = Object.keys(modules).filter((file) => !off.includes(file)); + const start = async () => { + state.project.plugins = [...on]; + await manager.start(Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1 }))); + await manager.syncTo(state.project.plugins); + }; return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) }; } @@ -320,7 +327,7 @@ describe('switching a plugin on and off', () => { deactivate: () => log.push(`${id}: deactivate`), }); - it('loads only what is switched on – a plugin that is off is never even fetched', async () => { + it('loads only what the project asks for – a plugin it leaves out is never even fetched', async () => { const { manager, start, loads } = switchable( { 'on.js': () => plugin('on'), 'off.js': () => plugin('off') }, ['off.js'], @@ -333,22 +340,78 @@ describe('switching a plugin on and off', () => { ]); }); - it('stores the switch first, then loads and activates the plugin', async () => { + it('writes the switch into the project 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', - ]); + const { manager, start, state, 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]]); + expect(state.project.plugins).toEqual(['demo.js']); // 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('is an undo step: undoing the switch stops the plugin again', async () => { + const log: string[] = []; + const { manager, start, state, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); + await start(); + expect(tabs.has('demo')).toBe(true); + + await manager.setActive('demo.js', false); + expect(state.project.plugins).toEqual([]); + expect(tabs.size).toBe(0); + + state.undo(); + await manager.syncTo(state.project.plugins); + expect(state.project.plugins).toEqual(['demo.js']); + expect(tabs.has('demo')).toBe(true); + expect(manager.records[0]?.status).toBe('active'); + + state.redo(); + await manager.syncTo(state.project.plugins); + expect(tabs.size).toBe(0); + expect(manager.records[0]?.status).toBe('inactive'); + }); + + it('follows a project change: the one it leaves goes before the one it opens arrives', async () => { + const log: string[] = []; + const { manager, start, tabs } = switchable( + { 'a.js': () => tabPlugin('a', log), 'b.js': () => tabPlugin('b', log) }, + ['b.js'], + ); + await start(); + expect([...tabs.keys()]).toEqual(['a']); + + // What a different project switches on, as the raw file says it before it is deserialized. + await manager.syncTo(['b.js']); + expect(log).toEqual(['a: deactivate']); + expect([...tabs.keys()]).toEqual(['b']); + expect(manager.records.map((r) => [r.file, r.status])).toEqual([ + ['a.js', 'inactive'], + ['b.js', 'active'], + ]); + + // Asking for what is running already does nothing at all. + await manager.syncTo(['b.js']); + expect(log).toEqual(['a: deactivate']); + }); + + it('passes over a file the server does not have, and says nothing about it', async () => { + const { manager, start, reports, loads } = switchable({ 'a.js': () => plugin('a') }, ['a.js']); + await start(); + await manager.syncTo(['gone.js']); + expect(loads).toEqual([]); + expect(reports).toEqual([]); + expect(manager.records.map((r) => r.file)).toEqual(['a.js']); + }); + it('signing out stops every plugin without touching the switches on the server', async () => { const log: string[] = []; const { manager, start, state, tabs, stored } = switchable({ @@ -362,8 +425,9 @@ describe('switching a plugin on and off', () => { await manager.stopAll(); expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']); expect(tabs.size).toBe(0); - // The switches are the account's, not this editor's: nothing was written (#177). + // The project keeps its list: signing out stops the plugins, it does not switch them off (#177). expect(stored).toEqual([]); + expect(state.project.plugins).toEqual(['a.js', 'b.js', 'off.js']); state.execute(new CreateAssetCommand('sprite', 'Crate')); expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']); }); @@ -391,14 +455,17 @@ describe('switching a plugin on and off', () => { 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') }); + it('leaves the plugin as it was when the project does not take the switch', async () => { + const { manager, start, state, 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(state.project.plugins).toEqual(['demo.js']); expect(tabs.has('demo')).toBe(true); - expect(reports).toEqual(['Plugin demo.js not switched off: Server error']); + expect(reports).toEqual(['Plugin demo.js not switched off: Project is read-only']); }); it('takes back what a plugin registered before its activate threw', async () => { @@ -467,13 +534,14 @@ describe('switching a plugin on and off', () => { const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') }); const seen = vi.fn(); const off = manager.onChange(seen); + // The list of files and the first sync against the project are two changes. await start(); - expect(seen).toHaveBeenCalledTimes(1); - await manager.setActive('demo.js', false); expect(seen).toHaveBeenCalledTimes(2); + await manager.setActive('demo.js', false); + expect(seen).toHaveBeenCalledTimes(3); off(); await manager.setActive('demo.js', true); - expect(seen).toHaveBeenCalledTimes(2); + expect(seen).toHaveBeenCalledTimes(3); }); it('runs a scope’s cleanups newest first, all of them even when one throws', () => { diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index a994420b..04b597e9 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { ASSET_TYPES } from '../../src/core/model/AssetTypes'; -import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons'; +import { + ASSET_ICON_SVG, + GAME_ICON_SVG, + LIST_ICON_SVG, + PLUGINS_ICON_SVG, +} from '../../src/editor/panels/assetIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -33,6 +38,7 @@ describe('icon files', () => { ...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]), ['list tab', LIST_ICON_SVG], ['game tab', GAME_ICON_SVG], + ['plugins tab', PLUGINS_ICON_SVG], ]; // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only @@ -46,14 +52,14 @@ describe('icon files', () => { }); it('draws the asset icons in the 16 units a list row and a button reserve', () => { - for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG]) + for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG]) expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16'); }); - it('gives the List and Game tabs a picture no asset type has (#176)', () => { - expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG); - expect(Object.values(ASSET_ICON_SVG)).not.toContain(GAME_ICON_SVG); - expect(GAME_ICON_SVG).not.toBe(LIST_ICON_SVG); + it('gives the List, Game and Plugins tabs a picture no asset type has (#176, #119)', () => { + const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG]; + for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg); + expect(new Set(tabs).size).toBe(tabs.length); }); it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => { diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index e4c4d86f..5a78722b 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -85,12 +85,12 @@ export async function overflow(t) { .filter((b) => ['scene', 'game', 'plugins'].includes(b.tab)) .map((b) => [b.tab, b.icon]); check( - 'Scene and Game show an icon before their name, Plugins none', + 'Scene, Game and Plugins show an icon before their name', JSON.stringify(fixedIcons) === JSON.stringify([ ['scene', true], ['game', true], - ['plugins', false], + ['plugins', true], ]) && sceneInk >= 20, JSON.stringify({ fixedIcons, sceneInk }), ); @@ -141,8 +141,8 @@ export async function overflow(t) { await clickText('»'); const offered = (await json('__gm.menuRows()')).filter(Boolean); const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null); - // Every hidden tab brings its icon along; only Plugins and a plugin's own tab have none (#176). - const withIcon = (tab) => tab !== 'plugins' && !tab.startsWith('plugin:'); + // Every hidden tab brings its icon along; only a plugin's own tab has none (#176, #119). + const withIcon = (tab) => !tab.startsWith('plugin:'); check( 'the overflow button offers every tab that had to go, each with the icon its tab shows', offered.length === narrow.hidden.length && diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs index 61168dec..fe4d93b4 100644 --- a/tools/e2e/38-plugins.mjs +++ b/tools/e2e/38-plugins.mjs @@ -1,12 +1,12 @@ import { openTab, pickAssetType } from './lib/scaffold.mjs'; /** - * 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. + * Plugins (#113, #114, #119). The demo plugin in `plugins/` is copied to `data/plugins/` by the + * build and the seeder; the fixture project switches it on, and the editor loads it before the + * project itself. 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, as an undo step of the project. */ export async function plugins(t) { const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } = @@ -27,6 +27,11 @@ export async function plugins(t) { demo?.status === 'active' && demo.meta?.id === 'demo' && demo.meta.api === 1, JSON.stringify(records), ); + check( + 'and it runs because the project switches it on, not the server', + JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']), + JSON.stringify(await json('__gm.state.project.plugins')), + ); check( 'no plugin failed', records.every((r) => r.status === 'active'), @@ -158,7 +163,8 @@ export async function plugins(t) { 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. + // 38i. The Plugins tab (#114, #119): the switch belongs to the project, and it works while the + // editor runs. await openTab(t, 'plugins'); await settle(); const pane = () => json('__gm.plugins.pane()'); @@ -173,17 +179,20 @@ export async function plugins(t) { JSON.stringify(rows), ); + const settled = async (was) => { + // Loading a module is a fetch, so the switch is applied a moment after the click. + for (let i = 0; i < 50; i++) { + const now = await pane(); + if (rowOf(now)?.status !== rowOf(was)?.status) return now; + await sleep(100); + } + return pane(); + }; 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(); + return settled(rows); }; rows = await toggle(); @@ -199,16 +208,36 @@ export async function plugins(t) { (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.) + // The switch is the project's (#119): it stands in the project file and travels with it. The + // server's own list knows nothing about it any more – it only says which files there are. const listed = await evaluate( `fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`, ); + const entry = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js'); 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, + 'the project says it is off, while the server still lists the file itself', + JSON.stringify(await json('__gm.state.project.plugins')) === '[]' && + entry !== undefined && + entry.active === undefined, + JSON.stringify({ plugins: await json('__gm.state.project.plugins'), entry }), + ); + + // And because it is a change to the project, undo takes it back – plugin, tab and all. + await undo(); + rows = await settled(rows); + const afterUndo = await json('__gm.tabRow()'); + check( + 'undo switches it on again, as a step of the project', + JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']) && + rowOf(rows)?.status === 'active' && + [...afterUndo.shown.map((p) => p.tab), ...afterUndo.hidden].includes('plugin:assets'), + JSON.stringify({ plugins: await json('__gm.state.project.plugins'), row: rowOf(rows) }), + ); + rows = await toggle(); + check( + 'and clicking the switch off again stops it once more', + rowOf(rows)?.status === 'inactive' && JSON.stringify(await json('__gm.state.project.plugins')) === '[]', + JSON.stringify({ row: rowOf(rows), plugins: await json('__gm.state.project.plugins') }), ); rows = await toggle(); diff --git a/tools/lib/plugins.d.mts b/tools/lib/plugins.d.mts index ead758a5..312ca75f 100644 --- a/tools/lib/plugins.d.mts +++ b/tools/lib/plugins.d.mts @@ -1 +1,2 @@ -export function publishPlugins(root: string, options?: { force?: boolean }): void; +export const DEMO_PLUGIN: string; +export function publishPlugins(root: string): void; diff --git a/tools/lib/plugins.mjs b/tools/lib/plugins.mjs index 14de6c70..0879598c 100644 --- a/tools/lib/plugins.mjs +++ b/tools/lib/plugins.mjs @@ -1,64 +1,29 @@ -import { - chownSync, - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; +/** The demo plugin the seed projects switch on (#119); `DEMO_PLUGIN` is its file name. */ +export const DEMO_PLUGIN = 'demo-plugin.js'; + /** * 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. + * `data/plugins/`. 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. + * Which of them run is not decided here any more (#119): a plugin is switched on by the project + * that wants it, so the seeders name it in the projects they write. What lies in `data/plugins/` + * is only what is available. * - * The API writes `plugins.json` as the web server's user. When this runs as root (the build on the + * The API reads the folder 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 } = {}) { +export function publishPlugins(root) { 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) { + for (const file of readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js'))) { copyFileSync(resolve(root, 'plugins', file), resolve(target, file)); written.push(resolve(target, file)); } - const statePath = resolve(target, 'plugins.json'); - let state = {}; - if (existsSync(statePath)) { - try { - state = JSON.parse(readFileSync(statePath, 'utf8')); - } catch { - console.warn(`${statePath} is not readable JSON – left as it is, no plugin switched on`); - state = null; - } - } - if (state && typeof state === 'object') { - let changed = false; - for (const file of shipped) { - if (!force && Object.hasOwn(state, file)) continue; - if (state[file]?.active === true) continue; - state[file] = { active: true }; - changed = true; - } - if (changed) { - const sorted = Object.fromEntries( - Object.entries(state).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), - ); - writeFileSync(statePath, `${JSON.stringify(sorted, null, 4)}\n`); - } - written.push(statePath); - } - if (process.getuid?.() === 0) { const { uid, gid } = statSync(resolve(root, 'data')); for (const path of written) if (existsSync(path)) chownSync(path, uid, gid); diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index a1403756..064958f5 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -9,19 +9,19 @@ 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 { DEMO_PLUGIN, 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 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. + * 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. That the demo plugin is also + * switched on is part of the fixture project below (#119). The server is this checkout (locally + * and in CI), so a file copy is enough. */ -publishPlugins(fileURLToPath(new URL('..', import.meta.url)), { force: true }); +publishPlugins(fileURLToPath(new URL('..', import.meta.url))); /** * Two typefaces for the font step to pick and to drop. They are put on the server but not made @@ -84,6 +84,9 @@ const project = { id: projectId, name: 'E2E fixture', activeSceneId: 'scene1', + // The demo plugin is switched on by the project since #119, not by a state file on the server – + // this is what step 38 finds running. + plugins: [DEMO_PLUGIN], assets: [ { id: 'scene1', diff --git a/vite.config.ts b/vite.config.ts index 563e1f08..5ad33c0f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,8 +25,8 @@ 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. A shipped plugin is switched on unless the - * server's state says otherwise (see `tools/lib/plugins.mjs`). + * replaced, anything else in the folder is left alone. Whether one runs is the open project's + * business, not the build's (#119). */ function shipPlugins(): Plugin { return {