Plugins belong to the project, and they load before it: the project file gained the field plugins: ["demo-plugin.js"] (default empty, no formatVersion bump), the Plugins tab's switch writes into it as an ordinary undo step (SetPluginActiveCommand) instead of into a server-wide plugins.json, and the start order is turned around - startPlugins only fetches the list of files, then editor.boot() runs and ProjectStore.load(id, prepare) hands the raw JSON through before it is deserialized, where pluginsOf(raw) reads the list and PluginManager.syncTo() activates exactly those; that is the point of the issue, because what a plugin registers has to stand there before normalizeGraph would throw it away as unknown (#126, #127), a project switch first stops what the new project does not name and then starts what it does, a file the server does not have is passed over silently, and after signing out the manager is dead so a late event cannot start anything in a torn-down session; on the server plugins.json, PluginStore::setActive, isActive, the route PUT ?r=plugins/{file} and the 403 for inactive modules are gone and GET ?r=plugins only answers {file,bytes,updatedAt}, which makes every plugin file fetchable - the consequence accepted in #117, written down in the wiki for #115 and #116; publishPlugins(root) only copies files now, the seeder switches the demo plugin on in the fixture project, and the Plugins tab carries a puzzle piece as its icon while only a plugin's own tab still goes without one (the knob and the socket are cubic curves, not arcs, which Pixi's parser draws as a spike after an L); 3 new e2e checks in module 38 and 5 new unit tests cover the project as the owner of the switch, its undo, the order, pluginsOf against crude input and an unknown file name (#119)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c830c7a3aa
commit
5ba796a2b6
28 files changed
+425
-269
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+4
-18
@@ -35,9 +35,8 @@ declare(strict_types=1);
|
|||||||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||||||
* DELETE ?r=sounds/{file} → 204
|
* DELETE ?r=sounds/{file} → 204
|
||||||
*
|
*
|
||||||
* GET ?r=plugins → [{file,bytes,updatedAt,active}], sorted by file name
|
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
|
||||||
* GET ?r=plugins/{file} → the module source as text/javascript | 403 while switched off
|
* GET ?r=plugins/{file} → the module source as text/javascript
|
||||||
* 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
|
* `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}`.
|
* 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());
|
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') {
|
if ($method !== 'GET') {
|
||||||
Response::error('Method not allowed', 405);
|
Response::error('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
@@ -318,10 +306,8 @@ function routePlugins(array $parts, string $method): never
|
|||||||
if ($source === null) {
|
if ($source === null) {
|
||||||
Response::error('Plugin not found', 404);
|
Response::error('Plugin not found', 404);
|
||||||
}
|
}
|
||||||
// Switched off means not run: the editor never asks for such a module, and nobody else may.
|
// Every plugin file is handed out: since #119 the project says which of them the editor runs,
|
||||||
if (!$store->isActive($parts[1])) {
|
// and the server has no way of knowing which project the asking tab has open.
|
||||||
Response::error('Plugin not active', 403);
|
|
||||||
}
|
|
||||||
header('Content-Type: text/javascript; charset=utf-8');
|
header('Content-Type: text/javascript; charset=utf-8');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
// A replaced file has to be the one the next editor start runs.
|
// A replaced file has to be the one the next editor start runs.
|
||||||
|
|||||||
+12
-93
@@ -2,16 +2,13 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one is switched on
|
* The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one runs is written
|
||||||
* is kept beside them in `plugins.json` (`{"demo-plugin.js": {"active": true}}`), server-wide; a
|
* in the project that is open, not here (#119) – the server hands out every plugin it has and knows
|
||||||
* file the state does not name is off, so a plugin put there runs only once someone switched it
|
* nothing about switches. The folder is still not served statically: a module is only ever handed
|
||||||
* on (#114). The folder is not served statically: a module is only ever handed out by its bare
|
* out by its bare file name, so no request can reach past it.
|
||||||
* file name, and only while it is active, so no request can reach past it.
|
|
||||||
*/
|
*/
|
||||||
final class PluginStore
|
final class PluginStore
|
||||||
{
|
{
|
||||||
public const STATE_FILE = 'plugins.json';
|
|
||||||
|
|
||||||
public function __construct(private readonly string $dir)
|
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,
|
* Sorted by file name, which is also the order the editor activates them in.
|
||||||
* active or not – the editor's Plugins tab is where one is switched on.
|
|
||||||
*
|
*
|
||||||
* @return list<array{file: string, bytes: int, updatedAt: int, active: bool}>
|
* @return list<array{file: string, bytes: int, updatedAt: int}>
|
||||||
*/
|
*/
|
||||||
public function list(): array
|
public function list(): array
|
||||||
{
|
{
|
||||||
$files = is_dir($this->dir) ? scandir($this->dir) : [];
|
$files = is_dir($this->dir) ? scandir($this->dir) : [];
|
||||||
$state = $this->readState() ?? [];
|
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($files === false ? [] : $files as $file) {
|
foreach ($files === false ? [] : $files as $file) {
|
||||||
if ($this->exists($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']));
|
usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file']));
|
||||||
return $out;
|
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. */
|
/** The module's source, or null when there is no such plugin. */
|
||||||
public function read(string $file): ?string
|
public function read(string $file): ?string
|
||||||
{
|
{
|
||||||
@@ -57,84 +52,8 @@ final class PluginStore
|
|||||||
return $source === false ? null : $source;
|
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
|
private function exists(string $file): bool
|
||||||
{
|
{
|
||||||
return self::isValidName($file) && is_file("{$this->dir}/{$file}");
|
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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
export class SetActiveSceneCommand implements Command {
|
||||||
readonly label = 'Switch scene';
|
readonly label = 'Switch scene';
|
||||||
private previous = '';
|
private previous = '';
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export interface Project {
|
|||||||
* the active scene; no format bump needed for that, since an older project reads as exactly that.
|
* the active scene; no format bump needed for that, since an older project reads as exactly that.
|
||||||
*/
|
*/
|
||||||
startSceneId: string;
|
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 {
|
export function createEmptyProject(id: string, name: string): Project {
|
||||||
@@ -33,6 +39,7 @@ export function createEmptyProject(id: string, name: string): Project {
|
|||||||
assets: [],
|
assets: [],
|
||||||
activeSceneId: '',
|
activeSceneId: '',
|
||||||
startSceneId: '',
|
startSceneId: '',
|
||||||
|
plugins: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ export class HttpProjectStore implements ProjectStore {
|
|||||||
return this.request<ProjectSummary[]>('GET', 'projects');
|
return this.request<ProjectSummary[]>('GET', 'projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(id: string): Promise<LoadedProject> {
|
async load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
|
||||||
const body = await this.request<{ revision: number; project: unknown }>(
|
const body = await this.request<{ revision: number; project: unknown }>(
|
||||||
'GET',
|
'GET',
|
||||||
`projects/${encodeURIComponent(id)}`,
|
`projects/${encodeURIComponent(id)}`,
|
||||||
);
|
);
|
||||||
|
await prepare?.(body.project);
|
||||||
return { project: deserializeProject(body.project, id), revision: body.revision };
|
return { project: deserializeProject(body.project, id), revision: body.revision };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ export class StaleRevisionError extends Error {
|
|||||||
|
|
||||||
export interface ProjectStore {
|
export interface ProjectStore {
|
||||||
list(): Promise<ProjectSummary[]>;
|
list(): Promise<ProjectSummary[]>;
|
||||||
load(id: string): Promise<LoadedProject>;
|
/**
|
||||||
|
* `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<void>): Promise<LoadedProject>;
|
||||||
/** Returns the new revision; rejects with `StaleRevisionError` on conflict. */
|
/** Returns the new revision; rejects with `StaleRevisionError` on conflict. */
|
||||||
save(project: Project, revision: number): Promise<number>;
|
save(project: Project, revision: number): Promise<number>;
|
||||||
create(name: string): Promise<LoadedProject>;
|
create(name: string): Promise<LoadedProject>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
TilemapLayer,
|
TilemapLayer,
|
||||||
} from '../model/Asset';
|
} from '../model/Asset';
|
||||||
import { parseCellKey } from '../model/tilemap';
|
import { parseCellKey } from '../model/tilemap';
|
||||||
|
import { pluginsOf } from './projectPlugins';
|
||||||
import { decodeCells, encodeCells } from './tilemapCells';
|
import { decodeCells, encodeCells } from './tilemapCells';
|
||||||
import { isAssetType } from '../model/AssetTypes';
|
import { isAssetType } from '../model/AssetTypes';
|
||||||
import { isFontFile } from '../model/font';
|
import { isFontFile } from '../model/font';
|
||||||
@@ -218,6 +219,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
assets,
|
assets,
|
||||||
activeSceneId,
|
activeSceneId,
|
||||||
startSceneId,
|
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;
|
return project;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -101,9 +101,11 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
installDebugHook(app, state, editor);
|
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();
|
await editor.boot();
|
||||||
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
|
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
|
||||||
await startPlugins(API_ENDPOINT, state, editor);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-5
@@ -2,6 +2,7 @@ import type { Application, Ticker } from 'pixi.js';
|
|||||||
import type { Asset } from '../core/model/Asset';
|
import type { Asset } from '../core/model/Asset';
|
||||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
|
import { pluginsOf } from '../core/store/projectPlugins';
|
||||||
import type { FontStore } from '../core/store/FontStore';
|
import type { FontStore } from '../core/store/FontStore';
|
||||||
import type { SoundStore } from '../core/store/SoundStore';
|
import type { SoundStore } from '../core/store/SoundStore';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
@@ -39,7 +40,7 @@ import { resolveList } from './list/slot';
|
|||||||
import { AnimationEditor } from './animation/AnimationEditor';
|
import { AnimationEditor } from './animation/AnimationEditor';
|
||||||
import { TileEditor } from './tile/TileEditor';
|
import { TileEditor } from './tile/TileEditor';
|
||||||
import { AssetPanel } from './panels/AssetPanel';
|
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 { HierarchyPanel } from './panels/HierarchyPanel';
|
||||||
import { TilemapPanel } from './panels/TilemapPanel';
|
import { TilemapPanel } from './panels/TilemapPanel';
|
||||||
import { Toolbar } from './panels/Toolbar';
|
import { Toolbar } from './panels/Toolbar';
|
||||||
@@ -125,10 +126,11 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
|||||||
plugins: 'Plugins',
|
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<Record<FixedTab, string>> = {
|
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||||||
scene: ASSET_ICON_SVG.scene,
|
scene: ASSET_ICON_SVG.scene,
|
||||||
game: GAME_ICON_SVG,
|
game: GAME_ICON_SVG,
|
||||||
|
plugins: PLUGINS_ICON_SVG,
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDocument(tab: StripTab): tab is DocumentId {
|
function isDocument(tab: StripTab): tab is DocumentId {
|
||||||
@@ -247,9 +249,16 @@ export class Editor implements PlayHost {
|
|||||||
this.viewport.setEditorWorld(world);
|
this.viewport.setEditorWorld(world);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.projects = new ProjectSession(state, store, () => {
|
this.projects = new ProjectSession(
|
||||||
if (this.play.isPlaying) this.play.stop();
|
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.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export class ProjectSession {
|
|||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
private readonly store: ProjectStore,
|
private readonly store: ProjectStore,
|
||||||
private readonly beforeSwitch: () => void,
|
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<void> = () => Promise.resolve(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
/** 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<void> {
|
private async open(id: string): Promise<void> {
|
||||||
this.beforeSwitch();
|
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);
|
this.state.loadProject(loaded.project, loaded.revision);
|
||||||
const url = new URL(location.href);
|
const url = new URL(location.href);
|
||||||
url.searchParams.set('project', id);
|
url.searchParams.set('project', id);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import font from './icons/font.svg?raw';
|
|||||||
import game from './icons/game.svg?raw';
|
import game from './icons/game.svg?raw';
|
||||||
import list from './icons/list.svg?raw';
|
import list from './icons/list.svg?raw';
|
||||||
import particles from './icons/particles.svg?raw';
|
import particles from './icons/particles.svg?raw';
|
||||||
|
import plugins from './icons/plugins.svg?raw';
|
||||||
import rigidbody from './icons/rigidbody.svg?raw';
|
import rigidbody from './icons/rigidbody.svg?raw';
|
||||||
import scene from './icons/scene.svg?raw';
|
import scene from './icons/scene.svg?raw';
|
||||||
import shader from './icons/shader.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`. */
|
/** 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;
|
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;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<!-- A puzzle piece: the Plugins tab adds pieces to the editor, no asset type is one. The knob and
|
||||||
|
the socket are cubic curves, not arcs: Pixi's parser draws an A after an L as a spike. -->
|
||||||
|
<path
|
||||||
|
d="M-5 -5 L5 -5 L5 -2.2 C7.8 -2.2 7.8 2.2 5 2.2 L5 5 L-5 5 L-5 2.2 C-2.2 2.2 -2.2 -2.2 -5 -2.2 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
@@ -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
|
* The Plugins tab (#114, #119): every file in `data/plugins/` with a switch. Switching one on loads
|
||||||
* activates it there and then, switching it off takes its tabs and listeners away again – the
|
* 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.
|
* 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:
|
* 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.
|
* reading a name out of it would mean running it.
|
||||||
@@ -144,9 +144,7 @@ export class PluginsPane extends VStack {
|
|||||||
this.empty.visible = nothing;
|
this.empty.visible = nothing;
|
||||||
if (nothing && this.manager) this.empty.setText('No plugins in data/plugins');
|
if (nothing && this.manager) this.empty.setText('No plugins in data/plugins');
|
||||||
this.hint.setText(
|
this.hint.setText(
|
||||||
nothing
|
nothing ? '' : 'Loaded from data/plugins. The switch belongs to this project and is an undo step.',
|
||||||
? ''
|
|
||||||
: 'Loaded from data/plugins, for everyone using this server. A new file stays off until switched on here.',
|
|
||||||
);
|
);
|
||||||
this.scroll.relayoutContent();
|
this.scroll.relayoutContent();
|
||||||
this.relayout();
|
this.relayout();
|
||||||
|
|||||||
@@ -20,11 +20,10 @@ export interface PluginRecord {
|
|||||||
error?: string;
|
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 {
|
export interface PluginEntry {
|
||||||
file: string;
|
file: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
active: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,8 +57,11 @@ export interface PluginLoaderDeps {
|
|||||||
load(file: string, updatedAt: number): Promise<unknown>;
|
load(file: string, updatedAt: number): Promise<unknown>;
|
||||||
/** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */
|
/** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */
|
||||||
api(meta: PluginMeta, scope: PluginScope): unknown;
|
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>;
|
* 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. */
|
/** One line for the user about a plugin that could not be switched. */
|
||||||
report(message: string): void;
|
report(message: string): void;
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,11 @@ function messageOf(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
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. */
|
/** The module's `meta` as the loader needs it, or why it cannot be used. */
|
||||||
export function readMeta(module: unknown): PluginMeta | string {
|
export function readMeta(module: unknown): PluginMeta | string {
|
||||||
const exports = (typeof module === 'object' && module !== null ? module : {}) as Record<string, unknown>;
|
const exports = (typeof module === 'object' && module !== null ? module : {}) as Record<string, unknown>;
|
||||||
@@ -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
|
* The plugins of this editor and their switches (#113, #114, #119). Which ones run is the open
|
||||||
* activated, one after the other in file-name order, so that what an earlier one registers (a tab
|
* project's business: `syncTo` takes the list out of the project file and makes the editor match
|
||||||
* id, say) is settled before the next runs; one switched on later comes after all of them.
|
* 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
|
* 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
|
* 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<string, PluginEntry>();
|
private readonly entries = new Map<string, PluginEntry>();
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private queue: Promise<void> = Promise.resolve();
|
private queue: Promise<void> = 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. */
|
/** False until `start` has been through the list – the tab says "Loading…" until then. */
|
||||||
started = false;
|
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) {}
|
constructor(private readonly deps: PluginLoaderDeps) {}
|
||||||
|
|
||||||
@@ -125,14 +138,41 @@ export class PluginManager {
|
|||||||
return () => this.listeners.delete(listener);
|
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<void> {
|
start(entries: readonly PluginEntry[]): Promise<void> {
|
||||||
return this.enqueue(async () => {
|
return this.enqueue(async () => {
|
||||||
const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
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);
|
for (const entry of sorted) this.entries.set(entry.file, entry);
|
||||||
this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null }));
|
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.started = true;
|
||||||
this.changed();
|
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<void> {
|
||||||
|
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<void> {
|
stopAll(): Promise<void> {
|
||||||
return this.enqueue(async () => {
|
return this.enqueue(async () => {
|
||||||
for (const file of [...this.loaded.keys()]) this.deactivate(file);
|
for (const file of [...this.loaded.keys()]) this.deactivate(file);
|
||||||
|
this.wanted = [];
|
||||||
this.started = false;
|
this.started = false;
|
||||||
|
this.stopped = true;
|
||||||
this.changed();
|
this.changed();
|
||||||
await Promise.resolve();
|
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<void> {
|
setActive(file: string, active: boolean): Promise<void> {
|
||||||
return this.enqueue(async () => {
|
// Against the list asked for, not against the record: a second click while the first switch is
|
||||||
const record = this.record(file);
|
// still running has to count, and the record only catches up when it is done.
|
||||||
if (!record || (record.status !== 'inactive') === active) return;
|
if (this.stopped || !this.record(file) || this.wanted.includes(file) === active) return this.queue;
|
||||||
try {
|
const rest = this.wanted.filter((f) => f !== file);
|
||||||
await this.deps.store(file, active);
|
const next = active ? [...rest, file].sort() : rest;
|
||||||
} catch (error) {
|
try {
|
||||||
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
|
this.deps.store(file, active);
|
||||||
this.changed();
|
} catch (error) {
|
||||||
return;
|
this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`);
|
||||||
}
|
|
||||||
if (active) await this.activate(file);
|
|
||||||
else this.deactivate(file);
|
|
||||||
this.changed();
|
this.changed();
|
||||||
});
|
return this.queue;
|
||||||
|
}
|
||||||
|
return this.syncTo(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async activate(file: string): Promise<void> {
|
private async activate(file: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SetPluginActiveCommand } from '../../core/commands/assetCommands';
|
||||||
import { apiRequest } from '../../core/store/http';
|
import { apiRequest } from '../../core/store/http';
|
||||||
import type { Editor } from '../Editor';
|
import type { Editor } from '../Editor';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
@@ -5,10 +6,14 @@ import { type PluginEntry, PluginManager } from './loader';
|
|||||||
import { createPluginApi, type PluginHost, scopedHost } from './pluginApi';
|
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
|
* 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
|
* – 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.
|
* 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<void> {
|
export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise<void> {
|
||||||
const report = (message: string) => {
|
const report = (message: string) => {
|
||||||
@@ -37,15 +42,18 @@ export async function startPlugins(endpoint: string, state: EditorState, editor:
|
|||||||
load: (file, updatedAt) =>
|
load: (file, updatedAt) =>
|
||||||
import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`),
|
import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`),
|
||||||
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
||||||
store: async (file, active) => {
|
// A refused command (a notice of its own) must not leave the plugin running against what the
|
||||||
await apiRequest(endpoint, 'PUT', `plugins/${file}`, {
|
// project says, so it counts as "not stored".
|
||||||
headers: { 'Content-Type': 'application/json' },
|
store: (file, active) => {
|
||||||
body: JSON.stringify({ active }),
|
if (!state.execute(new SetPluginActiveCommand(file, active)))
|
||||||
});
|
throw new Error('the project did not take the switch');
|
||||||
},
|
},
|
||||||
report,
|
report,
|
||||||
});
|
});
|
||||||
editor.plugins = manager;
|
editor.plugins = manager;
|
||||||
editor.pluginsPane.setManager(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);
|
await manager.start(entries);
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,7 @@ describe('CommandStack', () => {
|
|||||||
assets: [],
|
assets: [],
|
||||||
activeSceneId: '',
|
activeSceneId: '',
|
||||||
startSceneId: '',
|
startSceneId: '',
|
||||||
|
plugins: [],
|
||||||
};
|
};
|
||||||
const stack = new CommandStack(project);
|
const stack = new CommandStack(project);
|
||||||
let failing = false;
|
let failing = false;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
||||||
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
|
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
|
||||||
|
import { pluginsOf } from '../../src/core/store/projectPlugins';
|
||||||
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
||||||
import { makeProject } from '../helpers';
|
import { makeProject } from '../helpers';
|
||||||
|
|
||||||
@@ -39,6 +40,27 @@ describe('HttpProjectStore', () => {
|
|||||||
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
|
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 () => {
|
it('turns a 409 into a StaleRevisionError', async () => {
|
||||||
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
|
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
|
||||||
const { project } = makeProject();
|
const { project } = makeProject();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '../../src/core/commands/assetCommands';
|
} from '../../src/core/commands/assetCommands';
|
||||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
|
import { pluginsOf } from '../../src/core/store/projectPlugins';
|
||||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||||
|
|
||||||
@@ -17,6 +18,21 @@ describe('serialize', () => {
|
|||||||
expect(roundTrip(f.project)).toEqual(f.project);
|
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. */
|
/** 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', () => {
|
it('keeps a behavior running in the editor across a save', () => {
|
||||||
const f = makeSpriteScene();
|
const f = makeSpriteScene();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CreateAssetCommand,
|
CreateAssetCommand,
|
||||||
RenameAssetCommand,
|
RenameAssetCommand,
|
||||||
SetAssetPropertyCommand,
|
SetAssetPropertyCommand,
|
||||||
|
SetPluginActiveCommand,
|
||||||
} from '../../src/core/commands/assetCommands';
|
} from '../../src/core/commands/assetCommands';
|
||||||
import { createEmptyProject } from '../../src/core/model/Project';
|
import { createEmptyProject } from '../../src/core/model/Project';
|
||||||
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
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
|
* A manager on made-up modules. `start` hands it the files the server has and then the list the
|
||||||
* the server's `plugins.json` says in the real thing.
|
* open project switches on – everything but `off`, which stands for what the project leaves out.
|
||||||
*/
|
*/
|
||||||
function loaderDeps(modules: Record<string, () => unknown>, off: string[] = []) {
|
function loaderDeps(modules: Record<string, () => unknown>, off: string[] = []) {
|
||||||
const reports: string[] = [];
|
const reports: string[] = [];
|
||||||
@@ -43,15 +44,14 @@ function loaderDeps(modules: Record<string, () => unknown>, off: string[] = [])
|
|||||||
activated.push(meta.id);
|
activated.push(meta.id);
|
||||||
return { for: meta.id };
|
return { for: meta.id };
|
||||||
},
|
},
|
||||||
store: (file: string, active: boolean) => {
|
store: (file: string, active: boolean) => void stored.push([file, active]),
|
||||||
stored.push([file, active]);
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
report: (message: string) => reports.push(message),
|
report: (message: string) => reports.push(message),
|
||||||
};
|
};
|
||||||
const manager = new PluginManager(deps);
|
const manager = new PluginManager(deps);
|
||||||
const start = (files = Object.keys(modules)) =>
|
const start = async (files = Object.keys(modules)) => {
|
||||||
manager.start(files.map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })));
|
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 };
|
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
|
* Switching plugins on and off (#114, #119). The switch belongs to the open project: it is written
|
||||||
* applied; applying it means loading and activating, or running `deactivate` and taking back
|
* 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.
|
* everything the plugin registered through its API.
|
||||||
*/
|
*/
|
||||||
describe('switching a plugin on and off', () => {
|
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<string, () => unknown>, off: string[] = []) {
|
function switchable(modules: Record<string, () => unknown>, off: string[] = []) {
|
||||||
const { state, host, tabs } = editorHost();
|
const { state, host, tabs } = editorHost();
|
||||||
const reports: string[] = [];
|
const reports: string[] = [];
|
||||||
@@ -297,16 +301,19 @@ describe('switching a plugin on and off', () => {
|
|||||||
},
|
},
|
||||||
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
|
||||||
store: (file, active) => {
|
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]);
|
stored.push([file, active]);
|
||||||
return Promise.resolve();
|
state.execute(new SetPluginActiveCommand(file, active));
|
||||||
},
|
},
|
||||||
report: (message) => reports.push(message),
|
report: (message) => reports.push(message),
|
||||||
});
|
});
|
||||||
const start = () =>
|
state.on('project', () => void manager.syncTo(state.project.plugins));
|
||||||
manager.start(
|
const on = Object.keys(modules).filter((file) => !off.includes(file));
|
||||||
Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !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) };
|
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`),
|
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(
|
const { manager, start, loads } = switchable(
|
||||||
{ 'on.js': () => plugin('on'), 'off.js': () => plugin('off') },
|
{ 'on.js': () => plugin('on'), 'off.js': () => plugin('off') },
|
||||||
['off.js'],
|
['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 log: string[] = [];
|
||||||
const { manager, start, stored, loads, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }, [
|
const { manager, start, state, stored, loads, tabs } = switchable(
|
||||||
'demo.js',
|
{ 'demo.js': () => tabPlugin('demo', log) },
|
||||||
]);
|
['demo.js'],
|
||||||
|
);
|
||||||
await start();
|
await start();
|
||||||
expect(tabs.size).toBe(0);
|
expect(tabs.size).toBe(0);
|
||||||
|
|
||||||
await manager.setActive('demo.js', true);
|
await manager.setActive('demo.js', true);
|
||||||
expect(stored).toEqual([['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.
|
// 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(loads).toEqual([['demo.js', 1]]);
|
||||||
expect(tabs.has('demo')).toBe(true);
|
expect(tabs.has('demo')).toBe(true);
|
||||||
expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } });
|
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 () => {
|
it('signing out stops every plugin without touching the switches on the server', async () => {
|
||||||
const log: string[] = [];
|
const log: string[] = [];
|
||||||
const { manager, start, state, tabs, stored } = switchable({
|
const { manager, start, state, tabs, stored } = switchable({
|
||||||
@@ -362,8 +425,9 @@ describe('switching a plugin on and off', () => {
|
|||||||
await manager.stopAll();
|
await manager.stopAll();
|
||||||
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
|
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
|
||||||
expect(tabs.size).toBe(0);
|
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(stored).toEqual([]);
|
||||||
|
expect(state.project.plugins).toEqual(['a.js', 'b.js', 'off.js']);
|
||||||
state.execute(new CreateAssetCommand('sprite', 'Crate'));
|
state.execute(new CreateAssetCommand('sprite', 'Crate'));
|
||||||
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
|
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);
|
expect(tabs.has('demo')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the plugin as it was when the server does not store the switch', async () => {
|
it('leaves the plugin as it was when the project does not take the switch', async () => {
|
||||||
const { manager, start, reports, tabs, refuse } = switchable({ 'demo.js': () => tabPlugin('demo') });
|
const { manager, start, state, reports, tabs, refuse } = switchable({
|
||||||
|
'demo.js': () => tabPlugin('demo'),
|
||||||
|
});
|
||||||
await start();
|
await start();
|
||||||
refuse(true);
|
refuse(true);
|
||||||
await manager.setActive('demo.js', false);
|
await manager.setActive('demo.js', false);
|
||||||
expect(manager.records[0]?.status).toBe('active');
|
expect(manager.records[0]?.status).toBe('active');
|
||||||
|
expect(state.project.plugins).toEqual(['demo.js']);
|
||||||
expect(tabs.has('demo')).toBe(true);
|
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 () => {
|
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 { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') });
|
||||||
const seen = vi.fn();
|
const seen = vi.fn();
|
||||||
const off = manager.onChange(seen);
|
const off = manager.onChange(seen);
|
||||||
|
// The list of files and the first sync against the project are two changes.
|
||||||
await start();
|
await start();
|
||||||
expect(seen).toHaveBeenCalledTimes(1);
|
|
||||||
await manager.setActive('demo.js', false);
|
|
||||||
expect(seen).toHaveBeenCalledTimes(2);
|
expect(seen).toHaveBeenCalledTimes(2);
|
||||||
|
await manager.setActive('demo.js', false);
|
||||||
|
expect(seen).toHaveBeenCalledTimes(3);
|
||||||
off();
|
off();
|
||||||
await manager.setActive('demo.js', true);
|
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', () => {
|
it('runs a scope’s cleanups newest first, all of them even when one throws', () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
|
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 { tintSvg } from '../../src/ui/svg';
|
||||||
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
|
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]),
|
...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]),
|
||||||
['list tab', LIST_ICON_SVG],
|
['list tab', LIST_ICON_SVG],
|
||||||
['game tab', GAME_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
|
// 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', () => {
|
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');
|
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)', () => {
|
it('gives the List, Game and Plugins tabs a picture no asset type has (#176, #119)', () => {
|
||||||
expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG);
|
const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG];
|
||||||
expect(Object.values(ASSET_ICON_SVG)).not.toContain(GAME_ICON_SVG);
|
for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg);
|
||||||
expect(GAME_ICON_SVG).not.toBe(LIST_ICON_SVG);
|
expect(new Set(tabs).size).toBe(tabs.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
|
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
|
||||||
|
|||||||
@@ -85,12 +85,12 @@ export async function overflow(t) {
|
|||||||
.filter((b) => ['scene', 'game', 'plugins'].includes(b.tab))
|
.filter((b) => ['scene', 'game', 'plugins'].includes(b.tab))
|
||||||
.map((b) => [b.tab, b.icon]);
|
.map((b) => [b.tab, b.icon]);
|
||||||
check(
|
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(fixedIcons) ===
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
['scene', true],
|
['scene', true],
|
||||||
['game', true],
|
['game', true],
|
||||||
['plugins', false],
|
['plugins', true],
|
||||||
]) && sceneInk >= 20,
|
]) && sceneInk >= 20,
|
||||||
JSON.stringify({ fixedIcons, sceneInk }),
|
JSON.stringify({ fixedIcons, sceneInk }),
|
||||||
);
|
);
|
||||||
@@ -141,8 +141,8 @@ export async function overflow(t) {
|
|||||||
await clickText('»');
|
await clickText('»');
|
||||||
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
||||||
const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null);
|
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).
|
// Every hidden tab brings its icon along; only a plugin's own tab has none (#176, #119).
|
||||||
const withIcon = (tab) => tab !== 'plugins' && !tab.startsWith('plugin:');
|
const withIcon = (tab) => !tab.startsWith('plugin:');
|
||||||
check(
|
check(
|
||||||
'the overflow button offers every tab that had to go, each with the icon its tab shows',
|
'the overflow button offers every tab that had to go, each with the icon its tab shows',
|
||||||
offered.length === narrow.hidden.length &&
|
offered.length === narrow.hidden.length &&
|
||||||
|
|||||||
+49
-20
@@ -1,12 +1,12 @@
|
|||||||
import { openTab, pickAssetType } from './lib/scaffold.mjs';
|
import { openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugins (#113, #114). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and
|
* Plugins (#113, #114, #119). The demo plugin in `plugins/` is copied to `data/plugins/` by the
|
||||||
* the seeder, switched on; the editor loads it at start-up through the API. What only a running
|
* build and the seeder; the fixture project switches it on, and the editor loads it before the
|
||||||
* editor can show: that the module really loads and activates, that its tab joins the row, that its
|
* project itself. What only a running editor can show: that the module really loads and activates,
|
||||||
* node board draws one node per asset and follows the asset list – created through the + menu,
|
* that its tab joins the row, that its node board draws one node per asset and follows the asset
|
||||||
* renamed, and taken away again by undo – and that the switch in the Plugins tab stops and starts
|
* list – created through the + menu, renamed, and taken away again by undo – and that the switch in
|
||||||
* it while the editor runs.
|
* the Plugins tab stops and starts it while the editor runs, as an undo step of the project.
|
||||||
*/
|
*/
|
||||||
export async function plugins(t) {
|
export async function plugins(t) {
|
||||||
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } =
|
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,
|
demo?.status === 'active' && demo.meta?.id === 'demo' && demo.meta.api === 1,
|
||||||
JSON.stringify(records),
|
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(
|
check(
|
||||||
'no plugin failed',
|
'no plugin failed',
|
||||||
records.every((r) => r.status === 'active'),
|
records.every((r) => r.status === 'active'),
|
||||||
@@ -158,7 +163,8 @@ export async function plugins(t) {
|
|||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
check('switching to another tab hides the board', (await board()).visible === false);
|
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 openTab(t, 'plugins');
|
||||||
await settle();
|
await settle();
|
||||||
const pane = () => json('__gm.plugins.pane()');
|
const pane = () => json('__gm.plugins.pane()');
|
||||||
@@ -173,17 +179,20 @@ export async function plugins(t) {
|
|||||||
JSON.stringify(rows),
|
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 toggle = async () => {
|
||||||
const at = await json('__gm.plugins.switchAt("demo-plugin.js")');
|
const at = await json('__gm.plugins.switchAt("demo-plugin.js")');
|
||||||
await t.click(at.x, at.y);
|
await t.click(at.x, at.y);
|
||||||
await settle();
|
await settle();
|
||||||
// Switching goes to the server and back before it is applied.
|
return settled(rows);
|
||||||
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();
|
rows = await toggle();
|
||||||
@@ -199,16 +208,36 @@ export async function plugins(t) {
|
|||||||
(await board()) === null,
|
(await board()) === null,
|
||||||
JSON.stringify(rowAfterOff),
|
JSON.stringify(rowAfterOff),
|
||||||
);
|
);
|
||||||
// The switch is the server's, not this browser's: the list says so for everyone. (That a
|
// The switch is the project's (#119): it stands in the project file and travels with it. The
|
||||||
// switched-off module is not handed out either is the API's own check, not one for here – the
|
// server's own list knows nothing about it any more – it only says which files there are.
|
||||||
// refusal never reaches the page, the site's firewall answers it with a page of its own.)
|
|
||||||
const listed = await evaluate(
|
const listed = await evaluate(
|
||||||
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
|
`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(
|
check(
|
||||||
'the server stores the switch: its list says the plugin is off',
|
'the project says it is off, while the server still lists the file itself',
|
||||||
JSON.parse(listed).find((e) => e.file === 'demo-plugin.js')?.active === false,
|
JSON.stringify(await json('__gm.state.project.plugins')) === '[]' &&
|
||||||
listed,
|
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();
|
rows = await toggle();
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export function publishPlugins(root: string, options?: { force?: boolean }): void;
|
export const DEMO_PLUGIN: string;
|
||||||
|
export function publishPlugins(root: string): void;
|
||||||
+11
-46
@@ -1,64 +1,29 @@
|
|||||||
import {
|
import { chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||||
chownSync,
|
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
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,
|
* 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
|
* `data/plugins/`. Used by the build and by the e2e seeder.
|
||||||
* 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
|
* Which of them run is not decided here any more (#119): a plugin is switched on by the project
|
||||||
* switch. The build only switches on what the state does not name yet, so a plugin switched off
|
* that wants it, so the seeders name it in the projects they write. What lies in `data/plugins/`
|
||||||
* on the server stays off across deployments.
|
* 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.
|
* 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');
|
const target = resolve(root, 'data/plugins');
|
||||||
mkdirSync(target, { recursive: true });
|
mkdirSync(target, { recursive: true });
|
||||||
const written = [target];
|
const written = [target];
|
||||||
const shipped = readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js'));
|
for (const file of readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js'))) {
|
||||||
for (const file of shipped) {
|
|
||||||
copyFileSync(resolve(root, 'plugins', file), resolve(target, file));
|
copyFileSync(resolve(root, 'plugins', file), resolve(target, file));
|
||||||
written.push(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) {
|
if (process.getuid?.() === 0) {
|
||||||
const { uid, gid } = statSync(resolve(root, 'data'));
|
const { uid, gid } = statSync(resolve(root, 'data'));
|
||||||
for (const path of written) if (existsSync(path)) chownSync(path, uid, gid);
|
for (const path of written) if (existsSync(path)) chownSync(path, uid, gid);
|
||||||
|
|||||||
+9
-6
@@ -9,19 +9,19 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { sineWav } from './lib/audio.mjs';
|
import { sineWav } from './lib/audio.mjs';
|
||||||
import { edge, node, port, sceneObject, variable } from './lib/builders.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 { PROJECTS, cliArgs, uploadFont, uploadSound, writeProject } from './lib/projects.mjs';
|
||||||
import { adjustColorsShader } from './lib/shaders.mjs';
|
import { adjustColorsShader } from './lib/shaders.mjs';
|
||||||
|
|
||||||
const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
|
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 plugins that ship with the repository, where the editor loads them from – the build does the
|
||||||
* the build does the same, this makes a run after a plain checkout independent of it, and puts the
|
* same, this makes a run after a plain checkout independent of it. That the demo plugin is also
|
||||||
* demo plugin back on after a step (or a person) switched it off. The server is this checkout
|
* switched on is part of the fixture project below (#119). The server is this checkout (locally
|
||||||
* (locally and in CI), so a file copy is enough.
|
* 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
|
* 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,
|
id: projectId,
|
||||||
name: 'E2E fixture',
|
name: 'E2E fixture',
|
||||||
activeSceneId: 'scene1',
|
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: [
|
assets: [
|
||||||
{
|
{
|
||||||
id: 'scene1',
|
id: 'scene1',
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@ function publishIndexHtml(): Plugin {
|
|||||||
/**
|
/**
|
||||||
* Editor plugins are loaded from `data/plugins/`, which is not in the repository; the ones that
|
* 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
|
* 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
|
* replaced, anything else in the folder is left alone. Whether one runs is the open project's
|
||||||
* server's state says otherwise (see `tools/lib/plugins.mjs`).
|
* business, not the build's (#119).
|
||||||
*/
|
*/
|
||||||
function shipPlugins(): Plugin {
|
function shipPlugins(): Plugin {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in new issue
Block a user