diff --git a/.gitignore b/.gitignore index a2cbe9d4..1b5fc228 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /data/projects/*.json /data/projects/*.tmp +/data/e2e-password.txt +/data/sessions/ +/data/users.json /data/textures/ /data/fonts/ /data/sounds/ diff --git a/README.md b/README.md index 2528be91..3b99d053 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ way in. 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 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 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: ```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-tetris.mjs && node tools/smoke-tetris.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 | |---|---| | [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 | | [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 | diff --git a/api/index.php b/api/index.php index e880f79c..47385042 100644 --- a/api/index.php +++ b/api/index.php @@ -7,6 +7,13 @@ declare(strict_types=1); * 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. * + * 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}] * POST ?r=projects {name} → {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. */ +require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/PluginStore.php'; require __DIR__ . '/lib/Response.php'; +require __DIR__ . '/lib/SessionStore.php'; require __DIR__ . '/lib/SoundStore.php'; require __DIR__ . '/lib/Store.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. */ const MAX_BODY_BYTES = 20 * 1024 * 1024; @@ -61,9 +71,47 @@ set_error_handler(static function (int $severity, string $message, string $file, }); /** @param list $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 $parts */ +function routeProjects(array $parts, string $method, string $owner): never +{ + $store = new Store(dirname(__DIR__) . '/data/projects', $owner); if (count($parts) === 1) { if ($method === 'GET') { @@ -274,8 +322,19 @@ try { 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]) { - 'projects' => routeProjects($parts, $method), + 'projects' => routeProjects($parts, $method, $user), 'textures' => routeTextures($parts, $method), 'fonts' => routeFonts($parts, $method), 'sounds' => routeSounds($parts, $method), diff --git a/api/lib/Auth.php b/api/lib/Auth.php new file mode 100644 index 00000000..fded2eaf --- /dev/null +++ b/api/lib/Auth.php @@ -0,0 +1,73 @@ +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 . '/'; + } +} diff --git a/api/lib/SessionStore.php b/api/lib/SessionStore.php new file mode 100644 index 00000000..acc1a8ba --- /dev/null +++ b/api/lib/SessionStore.php @@ -0,0 +1,96 @@ +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); + } +} diff --git a/api/lib/Store.php b/api/lib/Store.php index 53c1efd9..13c2f4ef 100644 --- a/api/lib/Store.php +++ b/api/lib/Store.php @@ -12,14 +12,20 @@ final class StaleRevisionException extends RuntimeException /** * 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 * 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 { - 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)) { throw new RuntimeException("Cannot create data directory {$this->dir}"); } @@ -41,7 +47,7 @@ final class Store $items = []; foreach (glob($this->dir . '/*.json') ?: [] as $file) { $doc = $this->readFile($file); - if ($doc === null) { + if ($doc === null || $doc['owner'] !== $this->owner) { continue; } $items[] = [ @@ -55,13 +61,13 @@ final class Store 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 { - 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 { do { @@ -93,7 +99,7 @@ final class Store throw new RuntimeException('Cannot acquire project lock'); } try { - $current = $this->readFile($path); + $current = $this->own($this->readFile($path)); if ($current === null) { return null; } @@ -114,10 +120,19 @@ final class Store public function delete(string $id): bool { + if ($this->load($id) === null) { + return false; + } $path = $this->path($id); 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 { return $this->dir . '/' . $id . '.json'; @@ -128,6 +143,7 @@ final class Store return [ 'id' => $id, 'name' => $name, + 'owner' => $this->owner, 'revision' => $revision, 'updatedAt' => gmdate('c'), 'project' => $project, @@ -145,6 +161,9 @@ final class Store return null; } $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['revision'] = (int) $doc['revision']; return $doc; diff --git a/api/lib/UserStore.php b/api/lib/UserStore.php new file mode 100644 index 00000000..b0c3cb31 --- /dev/null +++ b/api/lib/UserStore.php @@ -0,0 +1,107 @@ +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 */ + 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 */ + 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 $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); + } +} diff --git a/src/core/store/AuthStore.ts b/src/core/store/AuthStore.ts new file mode 100644 index 00000000..5fbcfc91 --- /dev/null +++ b/src/core/store/AuthStore.ts @@ -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; + /** The user name on success; a rejected promise when the pair is wrong. */ + logIn(username: string, password: string): Promise; + logOut(): Promise; +} + +/** 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'; + } +} diff --git a/src/core/store/HttpAuthStore.ts b/src/core/store/HttpAuthStore.ts new file mode 100644 index 00000000..05cd0280 --- /dev/null +++ b/src/core/store/HttpAuthStore.ts @@ -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 { + const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session'); + return body.user; + } + + async logIn(username: string, password: string): Promise { + 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 { + await apiRequest(this.endpoint, 'POST', 'auth/logout'); + } +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 1ffc41b7..98b331a6 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,4 +1,6 @@ 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 { HttpProjectStore } from '../core/store/HttpProjectStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; @@ -7,6 +9,9 @@ import { requestRender, type VStack } from '../ui'; import { fontLibrary } from '../viewport/FontLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; 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 { Editor } from './Editor'; import { startPlugins } from './plugins/startPlugins'; @@ -22,27 +27,99 @@ const TEXTURE_BASE = '/game-maker/data/textures'; const FONT_BASE = '/game-maker/data/fonts'; 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 { + 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((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 { - const state = new EditorState(); + const auth = new HttpAuthStore(API_ENDPOINT); textureLibrary.configure(TEXTURE_BASE); fontLibrary.configure(FONT_BASE); soundLibrary.configure(SOUND_BASE); - const editor = new Editor( - app, - root, - state, - new HttpProjectStore(API_ENDPOINT), - new HttpFileStore(API_ENDPOINT, 'textures'), - new HttpFileStore(API_ENDPOINT, 'fonts'), - new HttpFileStore(API_ENDPOINT, 'sounds'), - ); // 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. + // 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()); - installDebugHook(app, state, editor); - await editor.boot(); - loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error)); - await startPlugins(API_ENDPOINT, state, editor); + + const openSession = async (): Promise => { + 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( + app, + root, + state, + new HttpProjectStore(API_ENDPOINT), + new HttpFileStore(API_ENDPOINT, 'textures'), + new HttpFileStore(API_ENDPOINT, 'fonts'), + new HttpFileStore(API_ENDPOINT, 'sounds'), + { + logOut: () => + void endSession(editor).catch((error: unknown) => { + console.error('[auth] Signing out failed:', error); + }), + }, + ); + installDebugHook(app, state, editor); + await editor.boot(); + loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error)); + 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 => { + 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(); } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c88a2f92..a7b23d5d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; @@ -73,6 +73,11 @@ export type PluginTabId = `plugin:${string}`; export type TabId = CentreTab | PluginTabId; /** 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'; +/** How to end the session; the button for it sits in the toolbar. */ +export interface EditorAccount { + logOut: () => void; +} + /** A button in the tab row. */ 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. */ private fpsFrames = 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; stop: () => void }; + private disposed = false; private leftWidth = 250; private rightWidth = 350; /** Starts out as wide as the inspector, then goes its own way. */ @@ -198,12 +207,13 @@ export class Editor implements PlayHost { constructor( app: Application, - root: VStack, + private readonly root: VStack, private readonly state: EditorState, store: ProjectStore, textures: TextureStore, fonts: FontStore, sounds: SoundStore, + account: EditorAccount, ) { this.viewport = new SceneViewport(state); this.gameView = new GameViewport(state); @@ -249,6 +259,7 @@ export class Editor implements PlayHost { openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePause: () => this.play.togglePause(), + logOut: () => account.logOut(), }); root.add(this.toolbar); @@ -256,7 +267,7 @@ export class Editor implements PlayHost { this.leftColumn = new VStack(); this.assets = new AssetPanel(state, textures, fonts, sounds); 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.hierarchy, 1 - this.assetsShare); // 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.resize(app.screen.width, app.screen.height); - attachAutosave(state, store); - installShortcuts(state, { - play: () => this.play.play(), - stop: () => this.play.stop(), - togglePause: () => this.play.togglePause(), - copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(), - pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(), - }); - app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); + this.autosave = attachAutosave(state, store); + this.cleanups.push( + installShortcuts(state, { + play: () => this.play.play(), + stop: () => this.play.stop(), + togglePause: () => this.play.togglePause(), + copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(), + pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(), + }), + ); + 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 // 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. @@ -590,6 +605,33 @@ export class Editor implements PlayHost { 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 { + 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 { this.showTab('game'); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index bdda2c87..2fe08404 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -162,6 +162,12 @@ export class EditorRun { 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 { if (!this.world) return; // Its sounds end with it, for the reason a played run's do – an editor run is restarted by any diff --git a/src/editor/auth/LoginScreen.ts b/src/editor/auth/LoginScreen.ts new file mode 100644 index 00000000..34bb50c7 --- /dev/null +++ b/src/editor/auth/LoginScreen.ts @@ -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 { + 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(); + } +} diff --git a/src/editor/auth/loginHook.ts b/src/editor/auth/loginHook.ts new file mode 100644 index 00000000..85e2b227 --- /dev/null +++ b/src/editor/auth/loginHook.ts @@ -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; + }; +} diff --git a/src/editor/auth/loginRules.ts b/src/editor/auth/loginRules.ts new file mode 100644 index 00000000..88f3b324 --- /dev/null +++ b/src/editor/auth/loginRules.ts @@ -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)}`; +} diff --git a/src/editor/autosave.ts b/src/editor/autosave.ts index a990e1b2..2174886e 100644 --- a/src/editor/autosave.ts +++ b/src/editor/autosave.ts @@ -11,12 +11,14 @@ export function attachAutosave( state: EditorState, store: ProjectStore, delayMs = 1000, -): { flush: () => Promise } { +): { flush: () => Promise; stop: () => void } { let timer = 0; let saving: Promise | null = null; let pending = false; + let stopped = false; const save = async (): Promise => { + if (stopped) return; if (saving) { pending = true; return saving; @@ -50,6 +52,7 @@ export function attachAutosave( }; const schedule = (): void => { + if (stopped) return; window.clearTimeout(timer); timer = window.setTimeout(() => void save(), delayMs); }; @@ -63,12 +66,23 @@ export function attachAutosave( await save(); }; - window.addEventListener('beforeunload', (event) => { + const onBeforeUnload = (event: BeforeUnloadEvent) => { if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') { void flush(); 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 }; } diff --git a/src/editor/console/ConsolePanel.ts b/src/editor/console/ConsolePanel.ts index 16aa19d6..85d1b6a9 100644 --- a/src/editor/console/ConsolePanel.ts +++ b/src/editor/console/ConsolePanel.ts @@ -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'; const ROW_HEIGHT = 18; @@ -25,13 +34,20 @@ export class ConsolePanel extends Panel { private readonly rows = new VStack(); private readonly views: Row[] = []; private stale = true; + private readonly releaseLog: () => void; constructor(private readonly log: ConsoleLog = consoleLog) { super({ title: 'Console', padding: 0, gap: 0 }); this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() })); this.scroll.setContent(this.rows); 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[0]): void { + this.releaseLog(); + super.destroy(options); } refresh(): void { diff --git a/src/editor/console/log.ts b/src/editor/console/log.ts index e7f63dc2..2c149e20 100644 --- a/src/editor/console/log.ts +++ b/src/editor/console/log.ts @@ -98,8 +98,10 @@ export class ConsoleLog { 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); + return () => this.listeners.delete(listener); } } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 69e25bd4..1d61abff 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; import { currentTilemapWidgets } from './panels/TilemapPanel'; 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 @@ -45,72 +46,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const p = widget.getGlobalPosition(); 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 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; - }; + const { eachText, locate, locateAll } = textProbe(app); /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ const textColor = (text: string): string | null => { let found: string | null = null; diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index e8d4f654..72f86c0b 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -1,5 +1,5 @@ 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 { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection'; @@ -32,6 +32,7 @@ export class Inspector extends Panel { private rebuilds = 0; /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ liveVariables: (assetId: string) => PropertyBag | null = () => null; + private readonly releaseFocusHandler: () => void; constructor(private readonly state: EditorState) { super({ title: 'Inspector', padding: 0, gap: 0 }); @@ -42,12 +43,18 @@ export class Inspector extends Panel { state.on('load', () => this.rebuild()); state.on('project', () => this.refreshValues()); state.on('runtime', () => this.refreshLive()); - ui().focus.changeHandlers.push(() => { + this.releaseFocusHandler = ui().focus.onFocusChange(() => { if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) 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[0]): void { + this.releaseFocusHandler(); + super.destroy(options); + } + /** For the check scripts: whether a rebuild is being held back, and how many have run. */ debugInfo(): { rebuilds: number; postponed: boolean } { return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft }; diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index ff1c8d9e..0c965a84 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -34,6 +34,7 @@ import { Theme, ui, VStack, + type Widget, } from '../../ui'; import type { EditorState, TilemapFocus, TilemapTool } from '../state'; 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. */ title = 'Tilemap'; 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) { super({ title: 'Tilemap', padding: 0, gap: 0 }); @@ -177,20 +180,36 @@ export class TilemapPanel extends Panel { if (ui().focus.isWithin(this)) this.rebuildWhenLeft = true; else this.sync(); }); - ui().focus.changeHandlers.push(() => { - if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync(); - }); + this.cleanups.push( + ui().focus.onFocusChange(() => { + 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 // listener; a handled Alt is kept from the browser, which would otherwise open its menu bar. const onAlt = (held: boolean) => (event: KeyboardEvent) => { if (event.key === 'Alt' && !event.repeat && holdPick(state, held)) event.preventDefault(); }; - window.addEventListener('keydown', onAlt(true)); - window.addEventListener('keyup', onAlt(false)); - window.addEventListener('blur', () => holdPick(state, false)); + const onKeyDown = onAlt(true); + const onKeyUp = onAlt(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(); } + /** Signing out destroys the panel while the page stays up (#177): its listeners go with it. */ + override destroy(options?: Parameters[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". */ get hasTarget(): boolean { return built !== null; diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 702fcce7..9c700023 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -14,6 +14,8 @@ export interface ToolbarActions { togglePlay(): void; /** Hold or resume the running world; only reachable in play mode. */ togglePause(): void; + /** Ends the session and returns to the sign-in screen. */ + logOut(): void; } const SAVE_LABELS: Record = { @@ -92,15 +94,18 @@ export class Toolbar extends HStack { this.add(this.pauseButton); 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.minWidth = 260; - this.add(this.notice); + this.add(this.notice, 1); this.status = new Label('', { style: 'small', align: 'right' }); this.status.minWidth = 110; this.add(this.status); this.fps = new Label('– fps', { style: 'small', align: 'right' }); this.fps.minWidth = 56; 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('load', () => this.refresh()); diff --git a/src/editor/plugins/loader.ts b/src/editor/plugins/loader.ts index 1ad69754..9b0158ec 100644 --- a/src/editor/plugins/loader.ts +++ b/src/editor/plugins/loader.ts @@ -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 { + 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. */ setActive(file: string, active: boolean): Promise { return this.enqueue(async () => { diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 1c1c9905..05a5bd63 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -15,10 +15,11 @@ export interface ShortcutActions { /** * 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 { - ui().focus.globalHandlers.push((event) => { +export function installShortcuts(state: EditorState, actions: ShortcutActions): () => void { + return ui().focus.onGlobalKey((event) => { const mod = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); if (event.key === 'Escape' && state.mode === 'play') { diff --git a/src/editor/textProbe.ts b/src/editor/textProbe.ts new file mode 100644 index 00000000..6f05d033 --- /dev/null +++ b/src/editor/textProbe.ts @@ -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 }; +} diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index 0de4e619..01f7ac8f 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -22,6 +22,20 @@ export class FocusManager { 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 { if (widget === this.focusedWidget) return; const previous = this.focusedWidget; @@ -93,6 +107,14 @@ export class FocusManager { } } +function remover(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 { let current: Container | null = node; while (current) { diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index 8314dd5b..b2f48b38 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -19,8 +19,13 @@ export interface TextFieldOptions { * semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus. */ 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 * 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 dragSelecting = false; placeholder: string; + private readonly secret: boolean; onInput?: (value: string) => void; onChange?: (value: string) => void; onSubmit?: (value: string) => void; @@ -53,6 +59,7 @@ export class TextField extends Widget implements TextEditTarget { this.onInput = options.onInput; this.onChange = options.onChange; this.onSubmit = options.onSubmit; + this.secret = options.secret ?? false; this.focusable = true; this.cursor = 'text'; this.minHeight = Theme.controlHeight; @@ -207,8 +214,14 @@ export class TextField extends Widget implements TextEditTarget { 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 { - 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. */ @@ -258,7 +271,7 @@ export class TextField extends Widget implements TextEditTarget { .rect(1, 1, this.w - 2, this.h - 2) .fill(0xffffff); - this.textNode.text = this.valueText; + this.textNode.text = this.shown(this.valueText); this.placeholderNode.text = this.valueText ? '' : this.placeholder; // Horizontal scroll so the caret stays visible. diff --git a/tests/core/auth.test.ts b/tests/core/auth.test.ts new file mode 100644 index 00000000..e946c644 --- /dev/null +++ b/tests/core/auth.test.ts @@ -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 = 'Status Code 418'; + +function stubFetch(body: BodyInit | null, init: ResponseInit) { + const mock = vi.fn(() => 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)', + ); + }); +}); diff --git a/tests/editor/plugins.test.ts b/tests/editor/plugins.test.ts index 54c7fe7f..809c8165 100644 --- a/tests/editor/plugins.test.ts +++ b/tests/editor/plugins.test.ts @@ -349,6 +349,25 @@ describe('switching a plugin on and off', () => { 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 () => { const log: string[] = []; const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); diff --git a/tests/editor/session-teardown.test.ts b/tests/editor/session-teardown.test.ts new file mode 100644 index 00000000..19cb0bf2 --- /dev/null +++ b/tests/editor/session-teardown.test.ts @@ -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 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; + + 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); + }); +}); diff --git a/tools/ci/project-shells.mjs b/tools/ci/project-shells.mjs index 6dcdb972..731b4347 100755 --- a/tools/ci/project-shells.mjs +++ b/tools/ci/project-shells.mjs @@ -14,6 +14,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { E2E_USER } from '../lib/auth.mjs'; import { PROJECTS } from '../lib/projects.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); @@ -25,6 +26,9 @@ for (const [name, id] of Object.entries(PROJECTS)) { const doc = { id, 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, updatedAt: '1970-01-01T00:00:00Z', project: {}, diff --git a/tools/ci/serve.sh b/tools/ci/serve.sh index 34230219..f0a57925 100644 --- a/tools/ci/serve.sh +++ b/tools/ci/serve.sh @@ -14,8 +14,17 @@ game_maker_serve() { mkdir -p "$root/data/projects" "$root/data/textures" node "$root/tools/ci/project-shells.mjs" - # A server from an earlier step of the same job is fine to keep. - if curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1; then + # Since #177 the API needs a session, and the check tools sign in as `e2e`. A fresh checkout has + # 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" return 0 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 & 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 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 tail -20 /tmp/php-server.log >&2 return 1 diff --git a/tools/e2e/45-login.mjs b/tools/e2e/45-login.mjs new file mode 100644 index 00000000..14fa0e4d --- /dev/null +++ b/tools/e2e/45-login.mjs @@ -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, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 09e64de7..42e0a9a1 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -47,6 +47,7 @@ import { physics } from './41-physics.mjs'; import { shapes } from './42-shapes.mjs'; import { queries } from './43-queries.mjs'; import { documents } from './44-documents.mjs'; +import { login } from './45-login.mjs'; export const STEPS = [ hierarchy, @@ -93,4 +94,5 @@ export const STEPS = [ shapes, queries, documents, + login, ]; diff --git a/tools/lib/auth.mjs b/tools/lib/auth.mjs new file mode 100644 index 00000000..0753a92c --- /dev/null +++ b/tools/lib/auth.mjs @@ -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; diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 0530aa8c..f73a9d71 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -4,6 +4,7 @@ * the summary line is missing, since chromium can die halfway under memory pressure. */ import { writeFileSync } from 'node:fs'; +import { authHeaders, SESSION_COOKIE, sessionToken } from './auth.mjs'; import { launchBrowser } from './browser.mjs'; /** @@ -391,7 +392,8 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { const api = async (route) => { const url = `${baseUrl}api/index.php?r=${route}`; 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) { 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 exceptions = () => consoleLog.filter((l) => l.startsWith('EXCEPTION')); /** 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 () => { + await signIn(); await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); await sleep(4000); 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 { @@ -450,6 +468,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { play, stop, api, + signIn, sceneOf, navigate, }; diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index 334008d2..deb8a77d 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -12,6 +12,7 @@ */ import { readFile } from 'node:fs/promises'; import { basename, extname } from 'node:path'; +import { authHeaders } from './auth.mjs'; export const PROJECTS = { playground: '649188c15414', @@ -61,7 +62,7 @@ export async function uploadTexture(baseUrl, filePath) { } const res = await fetch(`${baseUrl}api/index.php?r=textures`, { method: 'POST', - headers: { 'Content-Type': type }, + headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, signal: AbortSignal.timeout(20_000), }); @@ -95,7 +96,7 @@ export async function uploadFont(baseUrl, filePath) { const name = encodeURIComponent(basename(filePath)); const res = await fetch(`${baseUrl}api/index.php?r=fonts&name=${name}`, { method: 'POST', - headers: { 'Content-Type': type }, + headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, 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') { const res = await fetch(`${baseUrl}api/index.php?r=sounds&name=${encodeURIComponent(fileName)}`, { method: 'POST', - headers: { 'Content-Type': type }, + headers: { 'Content-Type': type, ...(await authHeaders(baseUrl)) }, body: bytes, 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 // instead of failing it – and the run that follows never starts. 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) { console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`); process.exit(1); @@ -146,7 +148,7 @@ export async function writeProject(projectId, baseUrl, project) { }; const res = await fetch(url, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal: timeout(), }); diff --git a/tools/make-user.php b/tools/make-user.php new file mode 100644 index 00000000..e5623829 --- /dev/null +++ b/tools/make-user.php @@ -0,0 +1,36 @@ + + * 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 \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";