User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
game-maker / gate (push) Successful in 2m5s
game-maker / suite (push) Failing after 6m22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 12:50:52 +02:00
1 parent 60ea56a2ce
commit 8e4bb6e1f9
38 files changed
+1513 -122

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
/data/projects/*.json /data/projects/*.json
/data/projects/*.tmp /data/projects/*.tmp
/data/e2e-password.txt
/data/sessions/
/data/users.json
/data/textures/ /data/textures/
/data/fonts/ /data/fonts/
/data/sounds/ /data/sounds/
+5 -1
View File
@@ -21,6 +21,8 @@ way in.
from the template on every read. A **screen** is a container switched on and off. from the template on every read. A **screen** is a container switched on and off.
- A **behavior** is logic as a node graph, placed like anything else and parented to the object it - A **behavior** is logic as a node graph, placed like anything else and parented to the object it
acts on. Play has no built-in behaviour – everything that happens is a graph. acts on. Play has no built-in behaviour – everything that happens is a graph.
- An **account** owns projects: the editor asks for a user name and a password first, and each
account sees only its own projects. Logout sits at the far right of the toolbar.
- **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back - **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back
exactly what was saved. exactly what was saved.
@@ -38,7 +40,8 @@ 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, 1109 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1118 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-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
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
@@ -53,6 +56,7 @@ beside it – so locally take the gate plus the step your change touches and lea
| Page | What it covers | | Page | What it covers |
|---|---| |---|---|
| [Concepts](https://git.nockal.com/meleki/game-maker/wiki/Concepts) | Assets, scene objects, overrides, play mode, the camera, the console | | [Concepts](https://git.nockal.com/meleki/game-maker/wiki/Concepts) | Assets, scene objects, overrides, play mode, the camera, the console |
| [Accounts](https://git.nockal.com/meleki/game-maker/wiki/Accounts) | Signing in and out, the accounts, who a project belongs to, what the check tools need |
| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph | | [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset | | [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing | | [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
+62 -3
View File
@@ -7,6 +7,13 @@ declare(strict_types=1);
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the * Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
* site's nginx configuration does not pass PATH_INFO to PHP. * site's nginx configuration does not pass PATH_INFO to PHP.
* *
* GET ?r=auth/session → {user} (null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user} | 401
* POST ?r=auth/logout → 204
*
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
* of somebody else answers like one that is not there.
*
* GET ?r=projects → [{id,name,revision,updatedAt}] * GET ?r=projects → [{id,name,revision,updatedAt}]
* POST ?r=projects {name} → {id,name,revision,updatedAt,project} * POST ?r=projects {name} → {id,name,revision,updatedAt,project}
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project} * GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
@@ -36,13 +43,16 @@ declare(strict_types=1);
* `data/sounds/` as static files; the API only writes them. * `data/sounds/` as static files; the API only writes them.
*/ */
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/PluginStore.php'; require __DIR__ . '/lib/PluginStore.php';
require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SessionStore.php';
require __DIR__ . '/lib/SoundStore.php'; require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php'; require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php'; require __DIR__ . '/lib/TextureStore.php';
require __DIR__ . '/lib/UserStore.php';
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */ /** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
const MAX_BODY_BYTES = 20 * 1024 * 1024; const MAX_BODY_BYTES = 20 * 1024 * 1024;
@@ -61,9 +71,47 @@ set_error_handler(static function (int $severity, string $message, string $file,
}); });
/** @param list<string> $parts */ /** @param list<string> $parts */
function routeProjects(array $parts, string $method): never function routeAuth(array $parts, string $method, Auth $auth): never
{ {
$store = new Store(dirname(__DIR__) . '/data/projects'); $what = $parts[1] ?? '';
if ($what === 'session') {
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
// Always 200, never 401: "nobody is signed in" is an answer, not a failure, and the
// login screen asks this before it can know either way.
Response::json(['user' => $auth->user()]);
}
if ($what === 'login') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$body = Response::body();
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
if ($user === null) {
// One message for both halves: which of the two was wrong is the attacker's question.
Response::error('Wrong user name or password', 401);
}
Response::json(['user' => $user]);
}
if ($what === 'logout') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$auth->logOut();
Response::noContent();
}
Response::error('Not found', 404);
}
/** @param list<string> $parts */
function routeProjects(array $parts, string $method, string $owner): never
{
$store = new Store(dirname(__DIR__) . '/data/projects', $owner);
if (count($parts) === 1) { if (count($parts) === 1) {
if ($method === 'GET') { if ($method === 'GET') {
@@ -274,8 +322,19 @@ try {
Response::error('Not found', 404); Response::error('Not found', 404);
} }
$auth = new Auth(
new UserStore(dirname(__DIR__) . '/data/users.json'),
new SessionStore(dirname(__DIR__) . '/data/sessions'),
);
if ($parts[0] === 'auth') {
routeAuth($parts, $method, $auth);
}
// Images, fonts, sounds and plugins are shared between accounts; being signed in is still the
// price of admission, so an uploaded file cannot be dropped in by a stranger.
$user = $auth->require();
match ($parts[0]) { match ($parts[0]) {
'projects' => routeProjects($parts, $method), 'projects' => routeProjects($parts, $method, $user),
'textures' => routeTextures($parts, $method), 'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method), 'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method), 'sounds' => routeSounds($parts, $method),
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Who is asking. The browser sends the session cookie; the check tools log in the same way and
* send the same cookie, so there is exactly one way into the API.
*/
final class Auth
{
public function __construct(
private readonly UserStore $users,
private readonly SessionStore $sessions,
) {}
/** The signed-in user, or null when nobody is. */
public function user(): ?string
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
return $token === '' ? null : $this->sessions->user($token);
}
/** The signed-in user, or a 401 answer – every route but `auth/*` goes through here. */
public function require(): string
{
$user = $this->user();
if ($user === null) {
Response::error('Sign in first', 401);
}
return $user;
}
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
public function logIn(string $username, string $password): ?string
{
$name = $this->users->verify($username, $password);
if ($name === null) {
return null;
}
$this->setCookie($this->sessions->create($name), time() + SessionStore::LIFETIME_SECONDS);
return $name;
}
public function logOut(): void
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
if ($token !== '') {
$this->sessions->destroy($token);
}
$this->setCookie('', time() - 3600);
}
private function setCookie(string $value, int $expires): void
{
setcookie(SessionStore::COOKIE, $value, [
'expires' => $expires,
'path' => self::basePath(),
'httponly' => true,
'samesite' => 'Lax',
'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
]);
}
/**
* The path the editor is served under (`/game-maker/`), read off the script rather than
* written down: the same checkout answers under a different prefix in CI.
*/
private static function basePath(): string
{
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/game-maker/api/index.php');
$base = dirname(dirname($script));
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Logins, kept as one file per session in `data/sessions/`, named after the SHA-256 of the token
* the browser holds in its cookie – so the file on disk cannot be replayed as a token.
*
* Deliberately not PHP's own session handler: those expire after `session.gc_maxlifetime`
* (24 minutes by default), and a login here has to outlive a deployment and a night, since
* `npm run build` replaces the editor under the very tab that is signed in.
*/
final class SessionStore
{
/** How long a login lasts without being used; every request that finds it pushes it out again. */
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
public const COOKIE = 'game_maker_session';
public function __construct(private readonly string $dir) {}
/** Starts a session for `$username` and returns the token for the cookie. */
public function create(string $username): string
{
$this->sweep();
$token = bin2hex(random_bytes(32));
$this->write($token, ['username' => $username, 'createdAt' => time(), 'seenAt' => time()]);
return $token;
}
/** The user behind a token, or null when it is unknown or has expired. */
public function user(string $token): ?string
{
$path = $this->path($token);
if ($path === null || !is_file($path)) {
return null;
}
$raw = file_get_contents($path);
$data = $raw === false ? null : json_decode($raw, true);
if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['seenAt'] ?? null)) {
return null;
}
if (time() - $data['seenAt'] > self::LIFETIME_SECONDS) {
@unlink($path);
return null;
}
// Touched at most once an hour: every request rewriting the file would cost a write per
// autosave, and the lifetime is measured in weeks.
if (time() - $data['seenAt'] > 3600) {
$data['seenAt'] = time();
$this->write($token, $data);
}
return $data['username'];
}
public function destroy(string $token): void
{
$path = $this->path($token);
if ($path !== null) {
@unlink($path);
}
}
/** Throws out what has timed out, so the directory does not grow with every login. */
private function sweep(): void
{
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
if (time() - (int) @filemtime($file) > self::LIFETIME_SECONDS) {
@unlink($file);
}
}
}
private function path(string $token): ?string
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
return $this->dir . '/' . hash('sha256', $token) . '.json';
}
private function write(string $token, array $data): void
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create session directory {$this->dir}");
}
$path = $this->path($token);
if ($path === null) {
throw new RuntimeException('Invalid session token');
}
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($json === false || file_put_contents($path, $json, LOCK_EX) === false) {
throw new RuntimeException('Cannot write session file');
}
@chmod($path, 0640);
}
}
+27 -8
View File
@@ -12,14 +12,20 @@ final class StaleRevisionException extends RuntimeException
/** /**
* File-based project store: one JSON document per project in `data/projects/{id}.json`. * File-based project store: one JSON document per project in `data/projects/{id}.json`.
* Each file wraps the project with metadata: { id, name, revision, updatedAt, project }. * Each file wraps the project with metadata: { id, name, owner, revision, updatedAt, project }.
* Writes are atomic (temp file + rename) and guarded by an integer revision so that a * Writes are atomic (temp file + rename) and guarded by an integer revision so that a
* stale client (second tab, old session) cannot silently overwrite newer work. * stale client (second tab, old session) cannot silently overwrite newer work.
*
* A store belongs to one signed-in user: it lists, reads and writes only the projects whose
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
* because "this id exists" is already more than a stranger should learn.
*/ */
final class Store final class Store
{ {
public function __construct(private readonly string $dir) public function __construct(
{ private readonly string $dir,
private readonly string $owner,
) {
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create data directory {$this->dir}"); throw new RuntimeException("Cannot create data directory {$this->dir}");
} }
@@ -41,7 +47,7 @@ final class Store
$items = []; $items = [];
foreach (glob($this->dir . '/*.json') ?: [] as $file) { foreach (glob($this->dir . '/*.json') ?: [] as $file) {
$doc = $this->readFile($file); $doc = $this->readFile($file);
if ($doc === null) { if ($doc === null || $doc['owner'] !== $this->owner) {
continue; continue;
} }
$items[] = [ $items[] = [
@@ -55,13 +61,13 @@ final class Store
return $items; return $items;
} }
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array}|null */ /** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array}|null */
public function load(string $id): ?array public function load(string $id): ?array
{ {
return $this->readFile($this->path($id)); return $this->own($this->readFile($this->path($id)));
} }
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array} */ /** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array} */
public function create(string $name): array public function create(string $name): array
{ {
do { do {
@@ -93,7 +99,7 @@ final class Store
throw new RuntimeException('Cannot acquire project lock'); throw new RuntimeException('Cannot acquire project lock');
} }
try { try {
$current = $this->readFile($path); $current = $this->own($this->readFile($path));
if ($current === null) { if ($current === null) {
return null; return null;
} }
@@ -114,10 +120,19 @@ final class Store
public function delete(string $id): bool public function delete(string $id): bool
{ {
if ($this->load($id) === null) {
return false;
}
$path = $this->path($id); $path = $this->path($id);
return file_exists($path) && unlink($path); return file_exists($path) && unlink($path);
} }
/** The document, or null when it belongs to someone else. */
private function own(?array $doc): ?array
{
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
}
private function path(string $id): string private function path(string $id): string
{ {
return $this->dir . '/' . $id . '.json'; return $this->dir . '/' . $id . '.json';
@@ -128,6 +143,7 @@ final class Store
return [ return [
'id' => $id, 'id' => $id,
'name' => $name, 'name' => $name,
'owner' => $this->owner,
'revision' => $revision, 'revision' => $revision,
'updatedAt' => gmdate('c'), 'updatedAt' => gmdate('c'),
'project' => $project, 'project' => $project,
@@ -145,6 +161,9 @@ final class Store
return null; return null;
} }
$doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled'; $doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled';
// A file without an owner belongs to nobody and is therefore invisible to everybody –
// the safe way round, and the one that shows up straight away if a migration is missed.
$doc['owner'] = is_string($doc['owner'] ?? null) ? $doc['owner'] : '';
$doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : ''; $doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : '';
$doc['revision'] = (int) $doc['revision']; $doc['revision'] = (int) $doc['revision'];
return $doc; return $doc;
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
* where `password` is a hash from `password_hash` – the plain one is never stored.
*
* Accounts are made from the command line (`tools/make-user.php`), not through the API: signing
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
*/
final class UserStore
{
public function __construct(private readonly string $file) {}
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
}
/** The name as stored, or null when the pair does not match an account. */
public function verify(string $username, string $password): ?string
{
$user = $this->find($username);
if ($user === null || !password_verify($password, $user['password'])) {
return null;
}
return $user['username'];
}
public function exists(string $username): bool
{
return $this->find($username) !== null;
}
/** @return list<string> */
public function names(): array
{
return array_map(static fn(array $u): string => $u['username'], $this->all());
}
/** Adds the account or replaces the password of one that is already there. */
public function put(string $username, string $password): void
{
if (!self::isValidName($username)) {
throw new InvalidArgumentException("Invalid user name: {$username}");
}
$users = array_values(
array_filter(
$this->all(),
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
),
);
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
$this->write($users);
}
/** @return array{username:string,password:string}|null */
private function find(string $username): ?array
{
foreach ($this->all() as $user) {
if (strcasecmp($user['username'], $username) === 0) {
return $user;
}
}
return null;
}
/** @return list<array{username:string,password:string}> */
private function all(): array
{
if (!is_file($this->file)) {
return [];
}
$raw = file_get_contents($this->file);
$data = $raw === false ? null : json_decode($raw, true);
if (!is_array($data) || !is_array($data['users'] ?? null)) {
return [];
}
$users = [];
foreach ($data['users'] as $user) {
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
$users[] = ['username' => $user['username'], 'password' => $user['password']];
}
}
return $users;
}
/** @param list<array{username:string,password:string}> $users */
private function write(array $users): void
{
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException("Cannot create data directory {$dir}");
}
$json = json_encode(['users' => $users], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Cannot encode users');
}
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) {
@unlink($tmp);
throw new RuntimeException('Cannot write users file');
}
@chmod($this->file, 0640);
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Signing in and out. The token itself never reaches this code: the server keeps the session and
* hands the browser a cookie, so all that travels here is the name behind it.
*/
export interface AuthStore {
/** The signed-in user, or null while nobody is. */
session(): Promise<string | null>;
/** The user name on success; a rejected promise when the pair is wrong. */
logIn(username: string, password: string): Promise<string>;
logOut(): Promise<void>;
}
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
export class WrongCredentialsError extends Error {
constructor(message = 'Wrong user name or password') {
super(message);
this.name = 'WrongCredentialsError';
}
}
+32
View File
@@ -0,0 +1,32 @@
import { type AuthStore, WrongCredentialsError } from './AuthStore';
import { apiRequest } from './http';
export class HttpAuthStore implements AuthStore {
constructor(private readonly endpoint: string) {}
async session(): Promise<string | null> {
const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session');
return body.user;
}
async logIn(username: string, password: string): Promise<string> {
const body = await apiRequest<{ user: string }>(
this.endpoint,
'POST',
'auth/login',
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
},
// A wrong password is the expected answer of a login screen, not a breakdown: it gets its
// own type so the screen can say one thing about it and another about a server that is down.
(status, data) =>
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
);
return body.user;
}
async logOut(): Promise<void> {
await apiRequest<void>(this.endpoint, 'POST', 'auth/logout');
}
}
+83 -6
View File
@@ -1,4 +1,6 @@
import type { Application } from 'pixi.js'; import type { Application } from 'pixi.js';
import type { AuthStore } from '../core/store/AuthStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore } from '../core/store/HttpFileStore'; import { HttpFileStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
@@ -7,6 +9,9 @@ import { requestRender, type VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary'; import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary'; import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary'; import { textureLibrary } from '../viewport/TextureLibrary';
import { LoginScreen } from './auth/LoginScreen';
import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook'; import { installDebugHook } from './debugHook';
import { Editor } from './Editor'; import { Editor } from './Editor';
import { startPlugins } from './plugins/startPlugins'; import { startPlugins } from './plugins/startPlugins';
@@ -22,12 +27,60 @@ const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts'; const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds'; const SOUND_BASE = '/game-maker/data/sounds';
/** Wires state, stores and the editor composition together and opens the first project. */ /**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* right away when the session from an earlier visit is still good – which is what lets a
* deployment replace the editor under an open tab without asking for the password again.
*/
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<string> {
const existing = await auth.session();
if (existing !== null) return existing;
let done: (user: string) => void = () => {};
const screen = new LoginScreen(auth, (user) => done(user));
const removeHook = installLoginHook(app, screen);
const user = await new Promise<string>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
screen.focusFirstField();
});
removeHook();
root.removeChild(screen);
// The editor sizes `root` itself once it is built, but `resize` lays out only when the size
// changed – and the sign-in screen has already set it to the size of the window. Forgetting the
// size here is what makes that call count again; without it the whole editor sits unlaid-out in
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return user;
}
/**
* Wires state, stores and the editor composition together and opens the first project.
*
* Signing out does not reload the page (#177): it takes the editor down and puts the sign-in
* screen back, in the same canvas with the same renderer. So this is a loop rather than a straight
* line – every round is one account's session. Only the first round is awaited, because the caller
* has more to set up once the editor is up; the rounds after it run on their own.
*/
export async function startEditor(app: Application, root: VStack): Promise<void> { export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState(); const auth = new HttpAuthStore(API_ENDPOINT);
textureLibrary.configure(TEXTURE_BASE); textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE); fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE); soundLibrary.configure(SOUND_BASE);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
// for the page, not per session – the files they hold are shared by every account anyway.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
const openSession = async (): Promise<void> => {
const user = await signedInUser(app, root, auth);
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
// also what tells two tabs on two accounts apart.
document.title = `Game Maker – ${user}`;
const state = new EditorState();
const editor = new Editor( const editor = new Editor(
app, app,
root, root,
@@ -36,13 +89,37 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'), new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'), new HttpFileStore(API_ENDPOINT, 'sounds'),
{
logOut: () =>
void endSession(editor).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
}),
},
); );
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
installDebugHook(app, state, editor); installDebugHook(app, 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); await startPlugins(API_ENDPOINT, state, editor);
};
/**
* Ends the session the Logout button belongs to: the editor writes what is unsaved and lets go
* of everything, then the server drops the session, and the sign-in screen comes back. The
* editor goes first, so its last save still travels with a session the server accepts.
*/
const endSession = async (editor: Editor): Promise<void> => {
await editor.dispose();
document.title = 'Game Maker';
// The console panel is page-wide, the lines in it are not: what one account's project printed
// is none of the next one's business.
consoleLog.clear();
try {
await auth.logOut();
} catch (error) {
console.error('[auth] Signing out did not reach the server:', error);
}
await openSession();
};
await openSession();
} }
+48 -6
View File
@@ -1,4 +1,4 @@
import type { Application } from 'pixi.js'; 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';
@@ -73,6 +73,11 @@ export type PluginTabId = `plugin:${string}`;
export type TabId = CentreTab | PluginTabId; export type TabId = CentreTab | PluginTabId;
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */ /** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
export type DocumentId = `asset:${string}` | 'list'; export type DocumentId = `asset:${string}` | 'list';
/** How to end the session; the button for it sits in the toolbar. */
export interface EditorAccount {
logOut: () => void;
}
/** A button in the tab row. */ /** A button in the tab row. */
export type StripTab = FixedTab | PluginTabId | DocumentId; export type StripTab = FixedTab | PluginTabId | DocumentId;
@@ -188,6 +193,10 @@ export class Editor implements PlayHost {
/** Drawn-frame count at the last measurement, not a counter of its own. */ /** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0; private fpsFrames = 0;
private fpsElapsed = 0; private fpsElapsed = 0;
/** What this editor registered outside itself, as the calls that take it away again (#177). */
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private leftWidth = 250; private leftWidth = 250;
private rightWidth = 350; private rightWidth = 350;
/** Starts out as wide as the inspector, then goes its own way. */ /** Starts out as wide as the inspector, then goes its own way. */
@@ -198,12 +207,13 @@ export class Editor implements PlayHost {
constructor( constructor(
app: Application, app: Application,
root: VStack, private readonly root: VStack,
private readonly state: EditorState, private readonly state: EditorState,
store: ProjectStore, store: ProjectStore,
textures: TextureStore, textures: TextureStore,
fonts: FontStore, fonts: FontStore,
sounds: SoundStore, sounds: SoundStore,
account: EditorAccount,
) { ) {
this.viewport = new SceneViewport(state); this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state); this.gameView = new GameViewport(state);
@@ -249,6 +259,7 @@ export class Editor implements PlayHost {
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor), openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(), togglePause: () => this.play.togglePause(),
logOut: () => account.logOut(),
}); });
root.add(this.toolbar); root.add(this.toolbar);
@@ -256,7 +267,7 @@ export class Editor implements PlayHost {
this.leftColumn = new VStack(); this.leftColumn = new VStack();
this.assets = new AssetPanel(state, textures, fonts, sounds); this.assets = new AssetPanel(state, textures, fonts, sounds);
this.leftColumn.add(this.assets, this.assetsShare); this.leftColumn.add(this.assets, this.assetsShare);
installFileImport(app.canvas, { state, textures, fonts, sounds }); this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds }));
this.leftColumn.add(this.columnSplitter); this.leftColumn.add(this.columnSplitter);
this.leftColumn.add(this.hierarchy, 1 - this.assetsShare); this.leftColumn.add(this.hierarchy, 1 - this.assetsShare);
// A share rather than a height, so a taller or shorter window keeps the split the user chose. // A share rather than a height, so a taller or shorter window keeps the split the user chose.
@@ -357,15 +368,19 @@ export class Editor implements PlayHost {
root.add(this.workspace, 1); root.add(this.workspace, 1);
root.resize(app.screen.width, app.screen.height); root.resize(app.screen.width, app.screen.height);
attachAutosave(state, store); this.autosave = attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, { installShortcuts(state, {
play: () => this.play.play(), play: () => this.play.play(),
stop: () => this.play.stop(), stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(), togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(), copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(), pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
}); }),
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); );
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
app.ticker.add(onTick);
this.cleanups.push(() => app.ticker.remove(onTick));
// A node, a frame and a template object exist only in their own document, so selecting one // A node, a frame and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter, // brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below. // creating it) is for – see the `open` event below.
@@ -590,6 +605,33 @@ export class Editor implements PlayHost {
return this.projects.boot(); return this.projects.boot();
} }
/**
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
*
* Unsaved work is written first, while the session that owns it is still good.
*/
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
await this.autosave.flush();
this.autosave.stop();
if (this.play.isPlaying) this.play.stop();
this.editorRun.shutDown();
await this.plugins?.stopAll();
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// A menu or a picker left open belongs to widgets that are about to be destroyed.
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
ui().focus.blur();
this.root.removeChild(this.toolbar, this.workspace);
this.toolbar.destroy({ children: true });
this.workspace.destroy({ children: true });
// The sign-in screen sets `root` to the size of the window as well, and `resize` lays out only
// when the size changed – the same reason `App.ts` forgets the size after signing in.
this.root.resize(0, 0);
}
showGame(): void { showGame(): void {
this.showTab('game'); this.showTab('game');
} }
+6
View File
@@ -162,6 +162,12 @@ export class EditorRun {
this.writing = false; this.writing = false;
} }
/** Ends the run for good, sounds and world included: the editor is going away (#177). */
shutDown(): void {
this.stop();
this.stale = false;
}
private stop(): void { private stop(): void {
if (!this.world) return; if (!this.world) return;
// Its sounds end with it, for the reason a played run's do – an editor run is restarted by any // Its sounds end with it, for the reason a played run's do – an editor run is restarted by any
+132
View File
@@ -0,0 +1,132 @@
import { Graphics } from 'pixi.js';
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
import { Button, drawControlBox, Label, requestRender, Spacer, TextField, Theme, ui, VStack } from '../../ui';
import { loginError, loginProblem } from './loginRules';
const CARD_WIDTH = 280;
/** The box in the middle of the screen; the screen around it is the plain editor background. */
class Card extends VStack {
private readonly bg = new Graphics();
constructor() {
super({ padding: Theme.padding * 2, gap: Theme.gap });
this.addChildAt(this.bg, 0);
this.minWidth = CARD_WIDTH;
}
override redraw(): void {
this.bg.clear();
drawControlBox(this.bg, this.w, this.h, { fill: Theme.colors.panel, border: Theme.colors.border });
}
}
/**
* Sign-in screen, drawn in the same canvas as the editor: there is no second canvas and no HTML
* form, so it looks and behaves like every other part of the editor.
*
* It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is
* signed in, so there is nothing behind it to hide.
*/
export class LoginScreen extends VStack {
private readonly user: TextField;
private readonly password: TextField;
private readonly message: Label;
private readonly submit: Button;
private busy = false;
constructor(
private readonly auth: AuthStore,
private readonly onSignedIn: (user: string) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
const card = new Card();
card.add(new Label('Game Maker', { style: 'title' }));
card.add(new Label('Sign in to open your projects', { style: 'muted' }));
card.add(new Spacer(Theme.gap));
card.add(new Label('User name', { style: 'small' }));
this.user = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'User name',
onSubmit: () => ui().focus.focus(this.password),
});
card.add(this.user);
card.add(new Label('Password', { style: 'small' }));
this.password = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'Password',
secret: true,
onSubmit: () => void this.signIn(),
});
card.add(this.password);
this.message = new Label('', { style: 'warning' });
card.add(this.message);
this.submit = new Button('Sign in', {
variant: 'primary',
width: CARD_WIDTH - Theme.padding * 4,
onClick: () => void this.signIn(),
});
card.add(this.submit);
this.add(card);
this.add(new Spacer(), 1);
}
/** Puts the caret in the first field, so a keyboard alone can sign in. */
focusFirstField(): void {
ui().focus.focus(this.user);
}
/** What the check scripts read: the message under the form and whether it is waiting. */
info(): { message: string; busy: boolean } {
return { message: this.message.text, busy: this.busy };
}
private async signIn(): Promise<void> {
if (this.busy) return;
const username = this.user.value.trim();
const password = this.password.value;
const problem = loginProblem(username, password);
if (problem) {
this.say(problem);
this.refocus(username === '' ? this.user : this.password);
return;
}
this.setBusy(true);
try {
const user = await this.auth.logIn(username, password);
this.onSignedIn(user);
} catch (error) {
this.say(loginError(error, error instanceof WrongCredentialsError));
this.setBusy(false);
// A second attempt starts where the first one went wrong, not in an empty form.
this.refocus(this.password);
}
}
/**
* Puts the caret back into a field after a refused attempt. A field blurs itself once its Enter
* has been handled, and that happens after this call returns – so the focus has to be set again
* behind it, or the next thing typed goes nowhere.
*/
private refocus(field: TextField): void {
queueMicrotask(() => ui().focus.focus(field));
}
private setBusy(busy: boolean): void {
this.busy = busy;
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
this.submit.setEnabled(!busy);
requestRender();
}
private say(message: string): void {
this.message.setText(message);
requestRender();
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Application } from 'pixi.js';
import { afterFrames, requestRender } from '../../ui';
import { textProbe } from '../textProbe';
import type { LoginScreen } from './LoginScreen';
/**
* A small `window.__gm` for the sign-in screen: the editor's hook cannot be installed yet, and
* without one the check scripts have no way to find the fields they have to fill in.
*
* It carries no `state` – that is how a check tells the sign-in screen from the editor.
* Returns the call that takes it down again.
*/
export function installLoginHook(app: Application, screen: LoginScreen): () => void {
const { locate, locateAll } = textProbe(app);
const global = window as unknown as { __gm?: unknown };
global.__gm = {
locate,
locateAll,
afterFrames,
wake: () => requestRender(),
login: () => screen.info(),
};
return () => {
delete global.__gm;
};
}
+15
View File
@@ -0,0 +1,15 @@
/**
* What a login form may complain about before anything is sent. Kept apart from the screen so it
* can be read without a browser: the screen only shows what this returns.
*/
export function loginProblem(username: string, password: string): string | null {
if (username.trim() === '') return 'Enter your user name';
if (password === '') return 'Enter your password';
return null;
}
/** The message a failed attempt puts under the form. */
export function loginError(error: unknown, wrongCredentials: boolean): string {
if (wrongCredentials) return 'Wrong user name or password';
return `Could not sign in: ${error instanceof Error ? error.message : String(error)}`;
}
+18 -4
View File
@@ -11,12 +11,14 @@ export function attachAutosave(
state: EditorState, state: EditorState,
store: ProjectStore, store: ProjectStore,
delayMs = 1000, delayMs = 1000,
): { flush: () => Promise<void> } { ): { flush: () => Promise<void>; stop: () => void } {
let timer = 0; let timer = 0;
let saving: Promise<void> | null = null; let saving: Promise<void> | null = null;
let pending = false; let pending = false;
let stopped = false;
const save = async (): Promise<void> => { const save = async (): Promise<void> => {
if (stopped) return;
if (saving) { if (saving) {
pending = true; pending = true;
return saving; return saving;
@@ -50,6 +52,7 @@ export function attachAutosave(
}; };
const schedule = (): void => { const schedule = (): void => {
if (stopped) return;
window.clearTimeout(timer); window.clearTimeout(timer);
timer = window.setTimeout(() => void save(), delayMs); timer = window.setTimeout(() => void save(), delayMs);
}; };
@@ -63,12 +66,23 @@ export function attachAutosave(
await save(); await save();
}; };
window.addEventListener('beforeunload', (event) => { const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') { if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') {
void flush(); void flush();
event.preventDefault(); event.preventDefault();
} }
}); };
window.addEventListener('beforeunload', onBeforeUnload);
return { flush }; /**
* Ends the autosave: nothing pending fires afterwards, and the closed-over state is let go of.
* Signing out does this while the page stays up (#177) – it flushes first, then stops.
*/
const stop = (): void => {
stopped = true;
window.clearTimeout(timer);
window.removeEventListener('beforeunload', onBeforeUnload);
};
return { flush, stop };
} }
+18 -2
View File
@@ -1,4 +1,13 @@
import { Button, HStack, Label, Panel, ScrollContainer, type TextStyleToken, VStack } from '../../ui'; import {
Button,
HStack,
Label,
Panel,
ScrollContainer,
type TextStyleToken,
VStack,
type Widget,
} from '../../ui';
import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log'; import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log';
const ROW_HEIGHT = 18; const ROW_HEIGHT = 18;
@@ -25,13 +34,20 @@ export class ConsolePanel extends Panel {
private readonly rows = new VStack(); private readonly rows = new VStack();
private readonly views: Row[] = []; private readonly views: Row[] = [];
private stale = true; private stale = true;
private readonly releaseLog: () => void;
constructor(private readonly log: ConsoleLog = consoleLog) { constructor(private readonly log: ConsoleLog = consoleLog) {
super({ title: 'Console', padding: 0, gap: 0 }); super({ title: 'Console', padding: 0, gap: 0 });
this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() })); this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() }));
this.scroll.setContent(this.rows); this.scroll.setContent(this.rows);
this.content.add(this.scroll, 1); this.content.add(this.scroll, 1);
this.log.onChange(() => (this.stale = true)); this.releaseLog = this.log.onChange(() => (this.stale = true));
}
/** The log outlives the panel – signing out destroys the panel while the page stays up (#177). */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseLog();
super.destroy(options);
} }
refresh(): void { refresh(): void {
+3 -1
View File
@@ -98,8 +98,10 @@ export class ConsoleLog {
for (const listener of this.listeners) listener(); for (const listener of this.listeners) listener();
} }
onChange(listener: () => void): void { /** Returns the call that takes the listener away again – a panel destroyed on sign-out (#177). */
onChange(listener: () => void): () => void {
this.listeners.add(listener); this.listeners.add(listener);
return () => this.listeners.delete(listener);
} }
} }
+2 -66
View File
@@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker'; import { currentImagePicker } from './texture/ImagePicker';
import { currentTilemapWidgets } from './panels/TilemapPanel'; import { currentTilemapWidgets } from './panels/TilemapPanel';
import type { EditorState, TilemapTool } from './state'; import type { EditorState, TilemapTool } from './state';
import { rectOf, textProbe } from './textProbe';
/** /**
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
@@ -45,72 +46,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition(); const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
}; };
/** The overlap of two global rectangles; an empty one when they do not meet. */ const { eachText, locate, locateAll } = textProbe(app);
const intersect = (a: Rectangle, b: Rectangle): Rectangle => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return new Rectangle(
x,
y,
Math.max(0, Math.min(a.right, b.right) - x),
Math.max(0, Math.min(a.bottom, b.bottom) - y),
);
};
const rectOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
};
/**
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
* to `visit` with the centre a click on it would use.
*
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
* centre falls outside the clipping it sits in counts as not being there at all.
*
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
* the one hook and then cannot find it with the other.
*
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
* empty rectangle at the origin – which would throw away every masked text in the editor.
*/
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
if (!node.visible) return;
const holder = node instanceof Widget ? node : owner;
let inner = clip;
if (node.mask && holder) {
const r = rectOf(holder);
const box = new Rectangle(r.x, r.y, r.w, r.h);
inner = clip ? intersect(clip, box) : box;
}
if (node instanceof Text) {
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
return;
}
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
eachText(app.stage, (node, point) => {
if (node.text === text) hits.push(point);
});
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => { const textColor = (text: string): string | null => {
let found: string | null = null; let found: string | null = null;
+9 -2
View File
@@ -1,5 +1,5 @@
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui'; import { Label, Panel, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state'; import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection'; import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
@@ -32,6 +32,7 @@ export class Inspector extends Panel {
private rebuilds = 0; private rebuilds = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: (assetId: string) => PropertyBag | null = () => null; liveVariables: (assetId: string) => PropertyBag | null = () => null;
private readonly releaseFocusHandler: () => void;
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 }); super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -42,12 +43,18 @@ export class Inspector extends Panel {
state.on('load', () => this.rebuild()); state.on('load', () => this.rebuild());
state.on('project', () => this.refreshValues()); state.on('project', () => this.refreshValues());
state.on('runtime', () => this.refreshLive()); state.on('runtime', () => this.refreshLive());
ui().focus.changeHandlers.push(() => { this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild(); if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
}); });
this.rebuild(); this.rebuild();
} }
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseFocusHandler();
super.destroy(options);
}
/** For the check scripts: whether a rebuild is being held back, and how many have run. */ /** For the check scripts: whether a rebuild is being held back, and how many have run. */
debugInfo(): { rebuilds: number; postponed: boolean } { debugInfo(): { rebuilds: number; postponed: boolean } {
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft }; return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
+24 -5
View File
@@ -34,6 +34,7 @@ import {
Theme, Theme,
ui, ui,
VStack, VStack,
type Widget,
} from '../../ui'; } from '../../ui';
import type { EditorState, TilemapFocus, TilemapTool } from '../state'; import type { EditorState, TilemapFocus, TilemapTool } from '../state';
import { TilePalette } from '../tile/TilePalette'; import { TilePalette } from '../tile/TilePalette';
@@ -164,6 +165,8 @@ export class TilemapPanel extends Panel {
/** As set, before the header upper-cases it – for the check scripts. */ /** As set, before the header upper-cases it – for the check scripts. */
title = 'Tilemap'; title = 'Tilemap';
onTargetChange: () => void = () => {}; onTargetChange: () => void = () => {};
/** What this panel registered outside itself, as the calls that take it away again. */
private readonly cleanups: (() => void)[] = [];
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super({ title: 'Tilemap', padding: 0, gap: 0 }); super({ title: 'Tilemap', padding: 0, gap: 0 });
@@ -177,20 +180,36 @@ export class TilemapPanel extends Panel {
if (ui().focus.isWithin(this)) this.rebuildWhenLeft = true; if (ui().focus.isWithin(this)) this.rebuildWhenLeft = true;
else this.sync(); else this.sync();
}); });
ui().focus.changeHandlers.push(() => { this.cleanups.push(
ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync(); if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync();
}); }),
);
// Holding Alt picks (#133). The focus manager only hears presses, so the release has its own // Holding Alt picks (#133). The focus manager only hears presses, so the release has its own
// listener; a handled Alt is kept from the browser, which would otherwise open its menu bar. // listener; a handled Alt is kept from the browser, which would otherwise open its menu bar.
const onAlt = (held: boolean) => (event: KeyboardEvent) => { const onAlt = (held: boolean) => (event: KeyboardEvent) => {
if (event.key === 'Alt' && !event.repeat && holdPick(state, held)) event.preventDefault(); if (event.key === 'Alt' && !event.repeat && holdPick(state, held)) event.preventDefault();
}; };
window.addEventListener('keydown', onAlt(true)); const onKeyDown = onAlt(true);
window.addEventListener('keyup', onAlt(false)); const onKeyUp = onAlt(false);
window.addEventListener('blur', () => holdPick(state, false)); const onBlur = () => holdPick(state, false);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
this.cleanups.push(() => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
this.sync(); this.sync();
} }
/** Signing out destroys the panel while the page stays up (#177): its listeners go with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
for (const cleanup of this.cleanups.splice(0)) cleanup();
super.destroy(options);
}
/** Whether there is a map to paint – the editor adds "and the scene tab is showing". */ /** Whether there is a map to paint – the editor adds "and the scene tab is showing". */
get hasTarget(): boolean { get hasTarget(): boolean {
return built !== null; return built !== null;
+7 -2
View File
@@ -14,6 +14,8 @@ export interface ToolbarActions {
togglePlay(): void; togglePlay(): void;
/** Hold or resume the running world; only reachable in play mode. */ /** Hold or resume the running world; only reachable in play mode. */
togglePause(): void; togglePause(): void;
/** Ends the session and returns to the sign-in screen. */
logOut(): void;
} }
const SAVE_LABELS: Record<SaveStatus, string> = { const SAVE_LABELS: Record<SaveStatus, string> = {
@@ -92,15 +94,18 @@ export class Toolbar extends HStack {
this.add(this.pauseButton); this.add(this.pauseButton);
this.add(new Spacer(), 1); this.add(new Spacer(), 1);
// Flexible, not a fixed strip: it is the one thing here that may shrink, and since the Logout
// button (#177) the row no longer fits a narrow window otherwise.
this.notice = new Label('', { style: 'small', align: 'right' }); this.notice = new Label('', { style: 'small', align: 'right' });
this.notice.minWidth = 260; this.add(this.notice, 1);
this.add(this.notice);
this.status = new Label('', { style: 'small', align: 'right' }); this.status = new Label('', { style: 'small', align: 'right' });
this.status.minWidth = 110; this.status.minWidth = 110;
this.add(this.status); this.add(this.status);
this.fps = new Label('– fps', { style: 'small', align: 'right' }); this.fps = new Label('– fps', { style: 'small', align: 'right' });
this.fps.minWidth = 56; this.fps.minWidth = 56;
this.add(this.fps); this.add(this.fps);
// Far right, where the account lives; who is signed in stands in the window title.
this.add(new Button('Logout', { onClick: () => actions.logOut() }));
state.on('project', () => this.refresh()); state.on('project', () => this.refresh());
state.on('load', () => this.refresh()); state.on('load', () => this.refresh());
+14
View File
@@ -136,6 +136,20 @@ export class PluginManager {
}); });
} }
/**
* Deactivates everything that is running, without touching the switches on the server: the
* editor is going away (signing out, #177) and every plugin has to let go of it first. A later
* editor starts them again from the same files.
*/
stopAll(): Promise<void> {
return this.enqueue(async () => {
for (const file of [...this.loaded.keys()]) this.deactivate(file);
this.started = false;
this.changed();
await Promise.resolve();
});
}
/** Switches a plugin on or off: stored on the server first, and only then applied. */ /** Switches a plugin on or off: stored on the server first, and only then applied. */
setActive(file: string, active: boolean): Promise<void> { setActive(file: string, active: boolean): Promise<void> {
return this.enqueue(async () => { return this.enqueue(async () => {
+4 -3
View File
@@ -15,10 +15,11 @@ export interface ShortcutActions {
/** /**
* Global keyboard shortcuts (see the wiki page "Keyboard"). Text fields keep their own key handling; * Global keyboard shortcuts (see the wiki page "Keyboard"). Text fields keep their own key handling;
* these only run when no focused widget consumed the key. * these only run when no focused widget consumed the key. Returns the call that takes them away
* again – signing out tears the editor down while the page stays up (#177).
*/ */
export function installShortcuts(state: EditorState, actions: ShortcutActions): void { export function installShortcuts(state: EditorState, actions: ShortcutActions): () => void {
ui().focus.globalHandlers.push((event) => { return ui().focus.onGlobalKey((event) => {
const mod = event.ctrlKey || event.metaKey; const mod = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (event.key === 'Escape' && state.mode === 'play') { if (event.key === 'Escape' && state.mode === 'play') {
+88
View File
@@ -0,0 +1,88 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { Widget } from '../ui';
/** The global box of a widget: where it starts, and how large it is. */
export function rectOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
x: number;
y: number;
w: number;
h: number;
} {
const p = widget.getGlobalPosition();
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
}
/** The overlap of two global rectangles; an empty one when they do not meet. */
function intersect(a: Rectangle, b: Rectangle): Rectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return new Rectangle(
x,
y,
Math.max(0, Math.min(a.right, b.right) - x),
Math.max(0, Math.min(a.bottom, b.bottom) - y),
);
}
/**
* Finding widgets by the text they show – the ground floor of the test hook, and the only part of
* it the sign-in screen needs, since it exists before there is an editor to ask.
*/
export function textProbe(app: Application): {
eachText: (root: Container, visit: (node: Text, point: { x: number; y: number }) => void) => void;
locateAll: (text: string) => { x: number; y: number }[];
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
} {
/**
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
* to `visit` with the centre a click on it would use.
*
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
* centre falls outside the clipping it sits in counts as not being there at all.
*
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
* the one hook and then cannot find it with the other.
*
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
* empty rectangle at the origin – which would throw away every masked text in the editor.
*/
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
if (!node.visible) return;
const holder = node instanceof Widget ? node : owner;
let inner = clip;
if (node.mask && holder) {
const r = rectOf(holder);
const box = new Rectangle(r.x, r.y, r.w, r.h);
inner = clip ? intersect(clip, box) : box;
}
if (node instanceof Text) {
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
return;
}
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
eachText(app.stage, (node, point) => {
if (node.text === text) hits.push(point);
});
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
return { eachText, locateAll, locate };
}
+22
View File
@@ -22,6 +22,20 @@ export class FocusManager {
return this.focusedWidget; return this.focusedWidget;
} }
/**
* Registers a handler for keys nobody consumed and returns the call that takes it away again.
* Signing out tears the editor down while the page stays up (#177), so everything it registered
* here has to go with it – a handler left behind would keep typing into a dead editor.
*/
onGlobalKey(handler: (event: KeyboardEvent) => boolean): () => void {
return remover(this.globalHandlers, handler);
}
/** The same for "focus moved", used by panels that postpone work while a field is being typed in. */
onFocusChange(handler: () => void): () => void {
return remover(this.changeHandlers, handler);
}
focus(widget: Widget | null): void { focus(widget: Widget | null): void {
if (widget === this.focusedWidget) return; if (widget === this.focusedWidget) return;
const previous = this.focusedWidget; const previous = this.focusedWidget;
@@ -93,6 +107,14 @@ export class FocusManager {
} }
} }
function remover<T>(list: T[], entry: T): () => void {
list.push(entry);
return () => {
const index = list.indexOf(entry);
if (index >= 0) list.splice(index, 1);
};
}
function isAttached(node: Container, root: Container): boolean { function isAttached(node: Container, root: Container): boolean {
let current: Container | null = node; let current: Container | null = node;
while (current) { while (current) {
+15 -2
View File
@@ -19,8 +19,13 @@ export interface TextFieldOptions {
* semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus. * semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus.
*/ */
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void;
/** A password: drawn as bullets, and never on screen as itself. */
secret?: boolean;
} }
/** What a masked field shows per character. */
const BULLET = '•';
/** /**
* Single-line text input drawn in the canvas. Keyboard handling is delegated to the * Single-line text input drawn in the canvas. Keyboard handling is delegated to the
* KeyboardBridge while focused; this class only mirrors value, selection and caret. * KeyboardBridge while focused; this class only mirrors value, selection and caret.
@@ -42,6 +47,7 @@ export class TextField extends Widget implements TextEditTarget {
private blinkTimer = 0; private blinkTimer = 0;
private dragSelecting = false; private dragSelecting = false;
placeholder: string; placeholder: string;
private readonly secret: boolean;
onInput?: (value: string) => void; onInput?: (value: string) => void;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onSubmit?: (value: string) => void; onSubmit?: (value: string) => void;
@@ -53,6 +59,7 @@ export class TextField extends Widget implements TextEditTarget {
this.onInput = options.onInput; this.onInput = options.onInput;
this.onChange = options.onChange; this.onChange = options.onChange;
this.onSubmit = options.onSubmit; this.onSubmit = options.onSubmit;
this.secret = options.secret ?? false;
this.focusable = true; this.focusable = true;
this.cursor = 'text'; this.cursor = 'text';
this.minHeight = Theme.controlHeight; this.minHeight = Theme.controlHeight;
@@ -207,8 +214,14 @@ export class TextField extends Widget implements TextEditTarget {
if (ui().focus.focused === this) ui().focus.blur(); if (ui().focus.focused === this) ui().focus.blur();
} }
/** What the field puts on screen for `text`: the text itself, or one bullet per character. */
protected shown(text: string): string {
return this.secret ? BULLET.repeat(text.length) : text;
}
// Measured on what is drawn, so caret and selection of a masked field sit on the bullets.
private textWidth(text: string): number { private textWidth(text: string): number {
return CanvasTextMetrics.measureText(text, this.textNode.style).width; return CanvasTextMetrics.measureText(this.shown(text), this.textNode.style).width;
} }
/** Character index closest to a local x coordinate. */ /** Character index closest to a local x coordinate. */
@@ -258,7 +271,7 @@ export class TextField extends Widget implements TextEditTarget {
.rect(1, 1, this.w - 2, this.h - 2) .rect(1, 1, this.w - 2, this.h - 2)
.fill(0xffffff); .fill(0xffffff);
this.textNode.text = this.valueText; this.textNode.text = this.shown(this.valueText);
this.placeholderNode.text = this.valueText ? '' : this.placeholder; this.placeholderNode.text = this.valueText ? '' : this.placeholder;
// Horizontal scroll so the caret stays visible. // Horizontal scroll so the caret stays visible.
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WrongCredentialsError } from '../../src/core/store/AuthStore';
import { HttpAuthStore } from '../../src/core/store/HttpAuthStore';
import { loginError, loginProblem } from '../../src/editor/auth/loginRules';
/** What the firewall in front of the live site answers instead of any non-2xx (measured for #177). */
const FIREWALL_418 = '<html><head><title>Status Code 418</title></head><body></body></html>';
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('HttpAuthStore', () => {
it('reads who is signed in, null included', async () => {
stubFetch(JSON.stringify({ user: 'e2e' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBe('e2e');
stubFetch(JSON.stringify({ user: null }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBeNull();
});
it('sends the pair as JSON and answers with the name the server confirms', async () => {
const mock = stubFetch(JSON.stringify({ user: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toBe('user');
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Flogin');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toEqual({ username: 'user', password: 'secret' });
});
it('tells a wrong pair apart from a server that is not there', async () => {
stubFetch(JSON.stringify({ error: 'Wrong user name or password' }), { status: 401 });
await expect(new HttpAuthStore('/api').logIn('user', 'nope')).rejects.toBeInstanceOf(
WrongCredentialsError,
);
stubFetch(JSON.stringify({ error: 'boom' }), { status: 500 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/boom \(500\)/);
});
it('does not mistake the firewall page for a wrong password', async () => {
stubFetch(FIREWALL_418, { status: 418 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/418/);
});
it('signs out without a body', async () => {
const mock = stubFetch(null, { status: 204 });
await expect(new HttpAuthStore('/api').logOut()).resolves.toBeUndefined();
expect(mock.mock.calls[0]?.[1]?.method).toBe('POST');
});
});
describe('the rules of the sign-in form', () => {
it('asks for what is missing, user name first', () => {
expect(loginProblem('', '')).toBe('Enter your user name');
expect(loginProblem(' ', 'secret')).toBe('Enter your user name');
expect(loginProblem('user', '')).toBe('Enter your password');
expect(loginProblem('user', 'secret')).toBeNull();
});
it('says a wrong pair plainly and names the cause of anything else', () => {
expect(loginError(new WrongCredentialsError(), true)).toBe('Wrong user name or password');
expect(loginError(new Error('Server unavailable (503)'), false)).toBe(
'Could not sign in: Server unavailable (503)',
);
});
});
+19
View File
@@ -349,6 +349,25 @@ describe('switching a plugin on and off', () => {
expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } }); expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } });
}); });
it('signing out stops every plugin without touching the switches on the server', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({
'a.js': () => tabPlugin('a', log),
'b.js': () => tabPlugin('b', log),
'off.js': () => tabPlugin('off', log),
});
await start();
expect(tabs.size).toBe(3);
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).
expect(stored).toEqual([]);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
});
it('switching off runs deactivate and takes the tab and the listeners with it', async () => { it('switching off runs deactivate and takes the tab and the listeners with it', async () => {
const log: string[] = []; const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
+147
View File
@@ -0,0 +1,147 @@
/**
* What has to let go when a session ends (#177): signing out takes the editor down while the page
* stays up, so everything it registered outside itself needs a way back – the autosave's timer and
* its `beforeunload` listener, and the handlers panels push into the focus manager. A handler left
* behind would go on typing into an editor nobody can see.
*
* The editor as a whole is torn down in the browser (e2e step 45); the pieces are here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { createEmptyProject, type Project } from '../../src/core/model/Project';
import type { ProjectStore } from '../../src/core/store/ProjectStore';
import { attachAutosave } from '../../src/editor/autosave';
import { EditorState } from '../../src/editor/state';
/** Just enough `window` for the two modules under test; vitest runs these in node. */
function fakeWindow() {
let next = 1;
const timers = new Map<number, () => void>();
const listeners: [string, unknown][] = [];
return {
listeners,
/** Runs every timer that is still scheduled, newest first, as the browser eventually would. */
fire: () => {
for (const run of [...timers.values()]) run();
timers.clear();
},
setTimeout: (fn: () => void) => {
const id = next++;
timers.set(id, fn);
return id;
},
clearTimeout: (id: number) => void timers.delete(id),
addEventListener: (type: string, fn: unknown) => void listeners.push([type, fn]),
removeEventListener: (type: string, fn: unknown) => {
const index = listeners.findIndex(([t, f]) => t === type && f === fn);
if (index >= 0) listeners.splice(index, 1);
},
};
}
function savingStore(saves: Project[]): ProjectStore {
return {
list: () => Promise.resolve([]),
load: () => Promise.reject(new Error('not used')),
save: (project, revision) => {
saves.push(project);
return Promise.resolve(revision + 1);
},
create: () => Promise.reject(new Error('not used')),
delete: () => Promise.resolve(),
};
}
describe('autosave', () => {
let win: ReturnType<typeof fakeWindow>;
beforeEach(() => {
win = fakeWindow();
vi.stubGlobal('window', win);
});
afterEach(() => vi.unstubAllGlobals());
const dirtyState = () => {
const state = new EditorState();
state.loadProject(createEmptyProject('p1', 'Teardown'), 1);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
return state;
};
it('writes what is pending when it is flushed', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
expect(state.saveStatus).toBe('dirty');
await autosave.flush();
expect(saves).toHaveLength(1);
expect(state.saveStatus).toBe('saved');
expect(state.revision).toBe(2);
});
it('stops for good: no pending save fires, and the page listener is gone', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
expect(win.listeners.map(([type]) => type)).toEqual(['beforeunload']);
autosave.stop();
win.fire();
await Promise.resolve();
expect(saves).toEqual([]);
expect(win.listeners).toEqual([]);
});
it('a change after the stop schedules nothing any more', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
await autosave.flush();
autosave.stop();
state.execute(new CreateAssetCommand('sprite', 'Box'));
win.fire();
await Promise.resolve();
expect(saves).toHaveLength(1);
});
});
describe('focus manager handlers', () => {
beforeEach(() => vi.stubGlobal('window', fakeWindow()));
afterEach(() => vi.unstubAllGlobals());
it('hands back the call that removes a global key handler', async () => {
const { FocusManager } = await import('../../src/ui/FocusManager');
const { Container } = await import('pixi.js');
const focus = new FocusManager(new Container());
const seen: string[] = [];
const remove = focus.onGlobalKey((event) => {
seen.push(event.key);
return false;
});
expect(focus.globalHandlers).toHaveLength(1);
remove();
expect(focus.globalHandlers).toEqual([]);
// A second call is harmless – a widget may be destroyed after the editor already let go.
remove();
expect(seen).toEqual([]);
});
it('hands back the call that removes a focus-change handler', async () => {
const { FocusManager } = await import('../../src/ui/FocusManager');
const { Container } = await import('pixi.js');
const focus = new FocusManager(new Container());
let runs = 0;
const remove = focus.onFocusChange(() => runs++);
const other = focus.onFocusChange(() => runs++);
expect(focus.changeHandlers).toHaveLength(2);
remove();
expect(focus.changeHandlers).toHaveLength(1);
other();
expect(focus.changeHandlers).toEqual([]);
expect(runs).toBe(0);
});
});
+4
View File
@@ -14,6 +14,7 @@
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { E2E_USER } from '../lib/auth.mjs';
import { PROJECTS } from '../lib/projects.mjs'; import { PROJECTS } from '../lib/projects.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -25,6 +26,9 @@ for (const [name, id] of Object.entries(PROJECTS)) {
const doc = { const doc = {
id, id,
name, name,
// The test projects belong to the `e2e` account (#177); a file without an owner is invisible
// to everybody, so the shells have to say whose they are.
owner: E2E_USER,
revision: 0, revision: 0,
updatedAt: '1970-01-01T00:00:00Z', updatedAt: '1970-01-01T00:00:00Z',
project: {}, project: {},
+13 -4
View File
@@ -14,8 +14,17 @@ game_maker_serve() {
mkdir -p "$root/data/projects" "$root/data/textures" mkdir -p "$root/data/projects" "$root/data/textures"
node "$root/tools/ci/project-shells.mjs" node "$root/tools/ci/project-shells.mjs"
# A server from an earlier step of the same job is fine to keep. # Since #177 the API needs a session, and the check tools sign in as `e2e`. A fresh checkout has
if curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1; then # no accounts at all, so the job makes the one it needs, with a password of its own.
if [ ! -f "$root/data/e2e-password.txt" ]; then
head -c 24 /dev/urandom | base64 | tr -d '\n' >"$root/data/e2e-password.txt"
echo >>"$root/data/e2e-password.txt"
fi
php "$root/tools/make-user.php" e2e "$(cat "$root/data/e2e-password.txt")"
# A server from an earlier step of the same job is fine to keep. Asked of the one route that
# answers without a session, so "is it up" does not depend on being signed in.
if curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=auth/session" >/dev/null 2>&1; then
echo "server already up at $GAME_MAKER_BASE_URL" echo "server already up at $GAME_MAKER_BASE_URL"
return 0 return 0
fi fi
@@ -26,10 +35,10 @@ game_maker_serve() {
PHP_CLI_SERVER_WORKERS=8 php -S 0.0.0.0:8080 -t /srv >/tmp/php-server.log 2>&1 & PHP_CLI_SERVER_WORKERS=8 php -S 0.0.0.0:8080 -t /srv >/tmp/php-server.log 2>&1 &
for _ in $(seq 60); do for _ in $(seq 60); do
curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1 && break curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=auth/session" >/dev/null 2>&1 && break
sleep 0.5 sleep 0.5
done done
if ! curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1; then if ! curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=auth/session" >/dev/null 2>&1; then
echo "the server never came up" >&2 echo "the server never came up" >&2
tail -20 /tmp/php-server.log >&2 tail -20 /tmp/php-server.log >&2
return 1 return 1
+164
View File
@@ -0,0 +1,164 @@
import { e2ePassword, E2E_USER } from '../lib/auth.mjs';
/**
* Signing in and out (#177), driven through the real screen: the rest of the suite gets its
* session handed to the browser as a cookie, so this is the only step that sees the sign-in
* screen at all.
*
* It runs last and hands the editor back the way it found it: signed in, with the fixture open.
*/
export async function login(t) {
const {
check,
cdp,
sleep,
evaluate,
json,
locate,
find,
press,
typeText,
mouse,
shot,
baseUrl,
projectId,
} = t;
const password = await e2ePassword();
/** Loads the page without handing the browser a session first. */
const openSignedOut = async () => {
await cdp.send('Network.clearBrowserCookies');
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(3000);
};
/**
* Waits for the page to be showing one of the two screens. Signing in and out each mean a round
* trip to the server – deadlines guessed for that are how a check becomes a coin toss.
*/
const showing =
// The editor counts as up once the project is in it, not when its hook appears: the hook is
// installed before the project is fetched, and in a chained run that fetch is long enough for
// a check in between to read the empty project the editor starts out with.
{
editor: `__gm?.state?.project?.id === ${JSON.stringify(projectId)}`,
login: 'Boolean(window.__gm?.login)',
};
const waitForScreen = async (which) => {
for (let waited = 0; waited < 15000; waited += 250) {
try {
if (await evaluate(`Boolean(${showing[which]})`)) return true;
} catch {
// "Inspected target navigated or closed": only `openSignedOut` navigates here, and asking
// across it is not a reason to give up.
}
await sleep(250);
}
return false;
};
await openSignedOut();
const screenHook = await json(
'({ login: Boolean(window.__gm?.login), editor: Boolean(window.__gm?.state) })',
);
check(
'without a session the sign-in screen stands where the editor would',
screenHook.login && !screenHook.editor && Boolean(await find('Sign in')),
screenHook,
);
// The user name alone: refused by the form itself, before anything is sent.
await typeText(E2E_USER);
await press('Enter');
await press('Enter');
check(
'an empty password is refused without asking the server',
(await json('__gm.login()')).message === 'Enter your password',
await json('__gm.login()'),
);
await typeText('not-the-password');
await press('Enter');
await sleep(600);
// What the message says about a *wrong* password is checked in the unit tests: the live server
// sits behind a firewall that answers every non-2xx with its own 418 page, so the wording of the
// API's 401 only survives where nothing sits in between. That it is refused at all, and that
// nobody gets in, holds everywhere – and that is what this check is about.
check(
'a wrong password is refused and leaves the screen standing',
(await json('__gm.login()')).message !== '' &&
Boolean(await find('Sign in')) &&
!(await evaluate('Boolean(window.__gm?.state)')),
await json('__gm.login()'),
);
// Select-all first: the failed attempt left its text in the field.
await press('a', { ctrl: true });
await typeText(password);
const bullets = '•'.repeat(password.length);
check(
'the password is drawn as bullets, never as itself',
Boolean(await find(bullets)) && (await find(password)) === null,
{ bullets: bullets.length },
);
await shot('45-login');
await press('Enter');
await waitForScreen('editor');
const loaded = await json(
'({ editor: Boolean(window.__gm?.state), project: __gm?.state?.project?.name ?? null, title: document.title })',
);
check(
'the right pair opens the editor, with the account in the window title',
loaded.editor && loaded.project === 'E2E fixture' && loaded.title.endsWith(E2E_USER),
loaded,
);
// A mark on the window and on the canvas element: both survive a teardown and neither survives a
// reload, which is what signing out must not do (#177).
await evaluate("window.__stayedUp = 'mark'; document.querySelector('canvas').dataset.stayedUp = 'mark'");
// Pressed by hand, not with `clickText`: that one waits for a drawn frame afterwards, and the
// screen the button brings up is built before that frame is due.
const logout = await locate('Logout');
await mouse('mouseMoved', logout.x, logout.y);
await mouse('mousePressed', logout.x, logout.y);
await mouse('mouseReleased', logout.x, logout.y);
await waitForScreen('login');
check(
'Logout ends the session and comes back to the sign-in screen',
Boolean(await find('Sign in')) && !(await evaluate('Boolean(window.__gm?.state)')),
await json('({ login: Boolean(window.__gm?.login), editor: Boolean(window.__gm?.state) })'),
);
const stayedUp = await json(
"({ window: window.__stayedUp ?? null, canvas: document.querySelector('canvas').dataset.stayedUp ?? null })",
);
check(
'signing out does not reload the page: the same canvas stays, only what it draws changes',
stayedUp.window === 'mark' && stayedUp.canvas === 'mark',
stayedUp,
);
// Back in, so the suite ends on a signed-in editor like every other step leaves it.
await locate('Sign in');
await typeText(E2E_USER);
await press('Enter');
await typeText(password);
await press('Enter');
await waitForScreen('editor');
check(
'signing in again brings the editor back, with the project in it',
(await json('__gm?.state?.project?.name ?? null')) === 'E2E fixture',
await json('__gm?.state?.project?.name ?? null'),
);
// The rebuilt editor has to be laid out, not sitting unlaid-out in the corner: `resize` lays out
// only when the size changed, and the sign-in screen has set `root` to the size of the window
// already (see `App.ts`). The Logout button is the far right of the toolbar, so it says so.
const placed = await json('({ logout: __gm.locate("Logout")?.x ?? null, width: window.innerWidth })');
check(
'the editor that came back is laid out across the window',
placed.logout !== null && placed.logout > placed.width / 2,
placed,
);
}
+2
View File
@@ -47,6 +47,7 @@ import { physics } from './41-physics.mjs';
import { shapes } from './42-shapes.mjs'; import { shapes } from './42-shapes.mjs';
import { queries } from './43-queries.mjs'; import { queries } from './43-queries.mjs';
import { documents } from './44-documents.mjs'; import { documents } from './44-documents.mjs';
import { login } from './45-login.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -93,4 +94,5 @@ export const STEPS = [
shapes, shapes,
queries, queries,
documents, documents,
login,
]; ];
+65
View File
@@ -0,0 +1,65 @@
/**
* Signing the check tools in. Since #177 every API route but `auth/*` needs a session, so every
* seed, smoke and e2e run logs in first – as `e2e`, the account that owns the four test projects.
*
* The password is never in the repo: it comes from `GAME_MAKER_E2E_PASSWORD` or from
* `data/e2e-password.txt`, which is git-ignored and written once per server (CI writes its own).
*/
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const E2E_USER = 'e2e';
const PASSWORD_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '../../data/e2e-password.txt');
const COOKIE = 'game_maker_session';
/** One session per server for the whole process; logging in per request would be pointless load. */
const cookies = new Map();
/** The password of the `e2e` account; the step that drives the sign-in screen types it in. */
export async function e2ePassword() {
const fromEnv = process.env.GAME_MAKER_E2E_PASSWORD;
if (fromEnv) return fromEnv;
try {
return (await readFile(PASSWORD_FILE, 'utf8')).trim();
} catch {
throw new Error(
`no password for the "${E2E_USER}" account: set GAME_MAKER_E2E_PASSWORD or put it in ${PASSWORD_FILE}`,
);
}
}
/** `game_maker_session=…` for `Cookie` headers, logging in on first use. */
export async function sessionCookie(baseUrl) {
const cached = cookies.get(baseUrl);
if (cached) return cached;
const res = await fetch(`${baseUrl}api/index.php?r=auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: E2E_USER, password: await e2ePassword() }),
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) {
throw new Error(`could not sign in as "${E2E_USER}" at ${baseUrl}: HTTP ${res.status}`);
}
// `getSetCookie` keeps the headers apart; a joined string cannot be split on commas, because a
// cookie's own Expires date carries one.
const set = res.headers.getSetCookie().find((c) => c.startsWith(`${COOKIE}=`));
if (!set) throw new Error(`signing in at ${baseUrl} returned no ${COOKIE} cookie`);
const cookie = set.split(';')[0];
cookies.set(baseUrl, cookie);
return cookie;
}
/** The headers a tool's `fetch` needs on top of its own. */
export async function authHeaders(baseUrl) {
return { Cookie: await sessionCookie(baseUrl) };
}
/** The session token alone – what the browser gets handed over CDP. */
export async function sessionToken(baseUrl) {
return (await sessionCookie(baseUrl)).slice(COOKIE.length + 1);
}
export const SESSION_COOKIE = COOKIE;
+21 -2
View File
@@ -4,6 +4,7 @@
* the summary line is missing, since chromium can die halfway under memory pressure. * the summary line is missing, since chromium can die halfway under memory pressure.
*/ */
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { authHeaders, SESSION_COOKIE, sessionToken } from './auth.mjs';
import { launchBrowser } from './browser.mjs'; import { launchBrowser } from './browser.mjs';
/** /**
@@ -391,7 +392,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const api = async (route) => { const api = async (route) => {
const url = `${baseUrl}api/index.php?r=${route}`; const url = `${baseUrl}api/index.php?r=${route}`;
try { try {
return await (await fetch(url, { signal: AbortSignal.timeout(SERVER_TIMEOUT_MS) })).json(); const headers = await authHeaders(baseUrl);
return await (await fetch(url, { headers, signal: AbortSignal.timeout(SERVER_TIMEOUT_MS) })).json();
} catch (error) { } catch (error) {
throw new Error(`request to ${route} failed: ${error.message}`, { cause: error }); throw new Error(`request to ${route} failed: ${error.message}`, { cause: error });
} }
@@ -399,11 +401,27 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene'); const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene');
const exceptions = () => consoleLog.filter((l) => l.startsWith('EXCEPTION')); const exceptions = () => consoleLog.filter((l) => l.startsWith('EXCEPTION'));
/** Opens the project and asserts the page came up cleanly with the test hook installed. */ /** Opens the project and asserts the page came up cleanly with the test hook installed. */
/**
* Hands the browser the session the tools log in with (#177). Without it the editor would put
* up its sign-in screen instead of the project, and every step after it would find no editor.
* The sign-in screen itself is driven by hand in the step that tests it.
*/
const signIn = async () => {
await cdp.send('Network.enable');
await cdp.send('Network.setCookie', {
name: SESSION_COOKIE,
value: await sessionToken(baseUrl),
url: baseUrl,
});
};
const navigate = async () => { const navigate = async () => {
await signIn();
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000); await sleep(4000);
check('page loaded without exceptions', exceptions().length === 0, exceptions().join(' | ')); check('page loaded without exceptions', exceptions().length === 0, exceptions().join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); // `state` rather than the bare hook: the sign-in screen installs one of its own, and a run
// that stopped there must not look like a loaded editor.
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm?.state)')));
}; };
return { return {
@@ -450,6 +468,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
play, play,
stop, stop,
api, api,
signIn,
sceneOf, sceneOf,
navigate, navigate,
}; };
+7 -5
View File
@@ -12,6 +12,7 @@
*/ */
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path'; import { basename, extname } from 'node:path';
import { authHeaders } from './auth.mjs';
export const PROJECTS = { export const PROJECTS = {
playground: '649188c15414', playground: '649188c15414',
@@ -61,7 +62,7 @@ export async function uploadTexture(baseUrl, filePath) {
} }
const res = await fetch(`${baseUrl}api/index.php?r=textures`, { const res = await fetch(`${baseUrl}api/index.php?r=textures`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': type }, headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) },
body: bytes, body: bytes,
signal: AbortSignal.timeout(20_000), signal: AbortSignal.timeout(20_000),
}); });
@@ -95,7 +96,7 @@ export async function uploadFont(baseUrl, filePath) {
const name = encodeURIComponent(basename(filePath)); const name = encodeURIComponent(basename(filePath));
const res = await fetch(`${baseUrl}api/index.php?r=fonts&name=${name}`, { const res = await fetch(`${baseUrl}api/index.php?r=fonts&name=${name}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': type }, headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) },
body: bytes, body: bytes,
signal: AbortSignal.timeout(20_000), signal: AbortSignal.timeout(20_000),
}); });
@@ -116,7 +117,7 @@ export async function uploadFont(baseUrl, filePath) {
export async function uploadSound(baseUrl, bytes, fileName, type = 'audio/wav') { export async function uploadSound(baseUrl, bytes, fileName, type = 'audio/wav') {
const res = await fetch(`${baseUrl}api/index.php?r=sounds&name=${encodeURIComponent(fileName)}`, { const res = await fetch(`${baseUrl}api/index.php?r=sounds&name=${encodeURIComponent(fileName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': type }, headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) },
body: bytes, body: bytes,
signal: AbortSignal.timeout(20_000), signal: AbortSignal.timeout(20_000),
}); });
@@ -134,7 +135,8 @@ export async function writeProject(projectId, baseUrl, project) {
// A request without a timeout keeps its socket open, so a stalled server hangs the seed // A request without a timeout keeps its socket open, so a stalled server hangs the seed
// instead of failing it – and the run that follows never starts. // instead of failing it – and the run that follows never starts.
const timeout = () => AbortSignal.timeout(20_000); const timeout = () => AbortSignal.timeout(20_000);
const current = await fetch(url, { signal: timeout() }); const headers = await authHeaders(baseUrl);
const current = await fetch(url, { headers, signal: timeout() });
if (!current.ok) { if (!current.ok) {
console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`); console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`);
process.exit(1); process.exit(1);
@@ -146,7 +148,7 @@ export async function writeProject(projectId, baseUrl, project) {
}; };
const res = await fetch(url, { const res = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: timeout(), signal: timeout(),
}); });
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* Creates an account or sets the password of one that exists. The API has no route for it:
* signing up is a follow-up issue, so until then accounts are made here, on the server.
*
* Usage: php tools/make-user.php <username> <password>
* php tools/make-user.php --list
*/
require __DIR__ . '/../api/lib/UserStore.php';
$file = dirname(__DIR__) . '/data/users.json';
$store = new UserStore($file);
$args = array_slice($argv, 1);
if (($args[0] ?? '') === '--list') {
foreach ($store->names() as $name) {
echo $name, "\n";
}
exit(0);
}
if (count($args) !== 2 || $args[0] === '' || $args[1] === '') {
fwrite(STDERR, "Usage: php tools/make-user.php <username> <password>\n");
exit(2);
}
[$username, $password] = $args;
if (!UserStore::isValidName($username)) {
fwrite(STDERR, "Invalid user name: {$username}\n");
exit(2);
}
$existed = $store->exists($username);
$store->put($username, $password);
echo ($existed ? 'password set for ' : 'created '), $username, "\n";