Let guests try a demo project without an account (#428)
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s

The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.

- Demo copies are frozen copies like a release, in data/demos/. An
  administrator makes, updates and removes one with "Demo copy..." in
  the project's main menu. GET demos and GET demos/{token} need no
  session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
  as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
  list carries it with demo: true, for accounts and guests alike.
  Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
  copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
  would write refuses in the tab before a request is made, and the
  autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
  pickers drop Upload and Delete, a dropped or pasted file is refused,
  and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
  "Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
  sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
  on. It only reads the server's own modules.

Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
  autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
  rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:58:30 +02:00
1 parent ce1f25f2eb
commit 925a39a276
30 files changed
+1397 -128

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { apiRequest } from './http';
import type { ReleasedGame, ReleaseStore, ReleaseSummary } from './ReleaseStore';
/** A demo copy as a guest picks it (#428). */
export interface DemoSummary {
token: string;
name: string;
/** ISO time, UTC: when the copy was made or last updated. */
publishedAt: string;
}
/**
* Demo copies (#428): frozen copies of projects a guest opens in the editor without an account,
* stored like a release. Listing and loading one needs no sign-in; making, updating and removing the
* copy of a project (the `ReleaseStore` half) is for administrators.
*/
export interface DemoStore extends ReleaseStore {
list(): Promise<DemoSummary[]>;
/** The copy, under its token as the project id. */
load(token: string): Promise<ReleasedGame>;
}
export class HttpDemoStore implements DemoStore {
constructor(private readonly endpoint: string) {}
list(): Promise<DemoSummary[]> {
return apiRequest(this.endpoint, 'GET', 'demos');
}
load(token: string): Promise<ReleasedGame> {
return apiRequest(this.endpoint, 'GET', `demos/${token}`);
}
of(projectId: string): Promise<ReleaseSummary | null> {
return apiRequest(this.endpoint, 'GET', route(projectId));
}
publish(projectId: string): Promise<ReleaseSummary> {
return apiRequest(this.endpoint, 'POST', route(projectId));
}
takeDown(projectId: string): Promise<void> {
return apiRequest(this.endpoint, 'DELETE', route(projectId));
}
}
function route(projectId: string): string {
return `projects/${encodeURIComponent(projectId)}/demo`;
}
+7
View File
@@ -16,6 +16,8 @@ export interface StoredFile extends UploadedFile {
owner: string | null;
/** Whether the one asking may delete it – whether a project still uses it, only the delete finds out. */
deletable: boolean;
/** Offered to everybody as demo material by an administrator (#428); absent from an older server. */
demo?: boolean;
}
/**
@@ -30,4 +32,9 @@ export interface FileStore<U extends UploadedFile = UploadedFile, S extends Stor
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
list(): Promise<S[]>;
delete(file: string): Promise<void>;
/**
* Offers a file to everybody as demo material, or takes that back (#428). Only there for an
* administrator; the pickers show the switch when it is.
*/
setDemo?: (file: string, demo: boolean) => Promise<void>;
}
+14 -4
View File
@@ -1,5 +1,5 @@
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
import { apiRequest } from './http';
import { apiRequest, jsonBody } from './http';
import type { StoredVideo, UploadedVideo, VideoStore, VideoUploadOptions } from './VideoStore';
/**
@@ -13,10 +13,20 @@ export class HttpFileStore<
U extends UploadedFile = UploadedFile,
S extends StoredFile = StoredFile,
> implements FileStore<U, S> {
readonly setDemo?: (file: string, demo: boolean) => Promise<void>;
/** `offersDemos` for an administrator's session: the server refuses everybody else anyway (#428). */
constructor(
protected readonly endpoint: string,
private readonly route: FileRoute,
) {}
offersDemos = false,
) {
if (offersDemos) {
this.setDemo = async (file, demo) => {
await apiRequest<void>(this.endpoint, 'PATCH', `${this.route}/${file}`, jsonBody({ demo }), refusal);
};
}
}
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<U> {
return this.send(bytes, type, { name });
@@ -42,8 +52,8 @@ export class HttpFileStore<
/** Videos: an upload may be kept without converting it, and converted later. */
export class HttpVideoStore extends HttpFileStore<UploadedVideo, StoredVideo> implements VideoStore {
constructor(endpoint: string) {
super(endpoint, 'videos');
constructor(endpoint: string, offersDemos = false) {
super(endpoint, 'videos', offersDemos);
}
override async upload(
+53
View File
@@ -0,0 +1,53 @@
import type { Project } from '../model/Project';
import {
type LoadedProject,
type ProjectStore,
type ProjectSummary,
StaleRevisionError,
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
* A guest's project (#428): one demo copy, held in this tab and nowhere else. Saving keeps it here,
* so nothing a guest changes reaches the server, and a reload starts from the copy again. There is
* no second project to create, open or delete.
*/
export class MemoryProjectStore implements ProjectStore {
private revision = 1;
private readonly updatedAt = new Date().toISOString();
constructor(
private readonly id: string,
private readonly name: string,
private raw: unknown,
private readonly refusal: string,
) {}
list(): Promise<ProjectSummary[]> {
return Promise.resolve([
{ id: this.id, name: this.name, revision: this.revision, updatedAt: this.updatedAt },
]);
}
async load(id: string, prepare?: (raw: unknown) => Promise<void>): Promise<LoadedProject> {
if (id !== this.id) throw new Error(`Project not found: ${id}`);
await prepare?.(this.raw);
// Saves are matched by id, so the copy answers to the token whatever its file said.
const raw = typeof this.raw === 'object' && this.raw !== null ? { ...this.raw, id: this.id } : this.raw;
return { project: deserializeProject(raw, this.id), revision: this.revision };
}
save(project: Project, revision: number): Promise<number> {
if (revision !== this.revision) return Promise.reject(new StaleRevisionError(this.revision));
this.raw = serializeProject(project);
return Promise.resolve(++this.revision);
}
create(_name: string): Promise<LoadedProject> {
return Promise.reject(new Error(this.refusal));
}
delete(_id: string): Promise<void> {
return Promise.reject(new Error(this.refusal));
}
}
+110 -41
View File
@@ -1,11 +1,13 @@
import type { Application } from 'pixi.js';
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpReleaseStore } from '../core/store/HttpReleaseStore';
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
import { HttpUsersStore } from '../core/store/HttpUsersStore';
import { MemoryProjectStore } from '../core/store/MemoryProjectStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
import { loadPhysics } from '../runtime/physics/engine';
import { projectPictureFactory } from '../viewport/generatedPictures';
@@ -18,25 +20,40 @@ import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { guestFiles, guestReleases, guestSnapshots, guestUsers, guestVideos } from './guestStores';
import { isDragFrame } from './framePreview';
import { startPlugins } from './plugins/startPlugins';
import { CelUploadStore } from './pixelart/celUploadStore';
import { API_ENDPOINT } from '../core/site';
import { EditorState } from './state';
import { GUEST_REFUSAL, setSession } from './session';
/** Who the sign-in screen let in: an account, or a guest with the demo copy they picked (#428). */
type Entry = { account: SignedIn } | { demo: DemoSummary };
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* Shows the sign-in screen until somebody is signed in or picks a demo, and answers with who. 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<SignedIn> {
async function signedInUser(
app: Application,
root: VStack,
auth: AuthStore,
demos: DemoStore,
): Promise<Entry> {
const existing = await auth.session();
if (existing !== null) return existing;
if (existing !== null) return { account: existing };
let done: (account: SignedIn) => void = () => {};
const screen = new LoginScreen(auth, (account) => done(account));
let done: (entry: Entry) => void = () => {};
const screen = new LoginScreen(
auth,
demos,
(account) => done({ account }),
(demo) => done({ demo }),
);
const removeHook = installLoginHook(app, screen);
const account = await new Promise<SignedIn>((resolve) => {
const entry = await new Promise<Entry>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
@@ -50,7 +67,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return account;
return entry;
}
/**
@@ -65,8 +82,13 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
const auth = new HttpAuthStore(API_ENDPOINT);
setUpProjectFiles(app.renderer);
const demos = new HttpDemoStore(API_ENDPOINT);
const openSession = async (): Promise<void> => {
const { user, role } = await signedInUser(app, root, auth);
const entry = await signedInUser(app, root, auth, demos);
const guest = 'demo' in entry;
const { user, role } = guest ? { user: 'Guest', role: 'user' as const } : entry.account;
setSession({ guest, role });
// 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 = `Feuerwerk – ${user}`;
@@ -79,38 +101,70 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
state.on('project', () => {
if (!isDragFrame()) preloadProjectFiles(state.project);
});
const editor = new Editor(
app,
root,
state,
new CelUploadStore(
new HttpProjectStore(API_ENDPOINT),
pixelCels,
new HttpFileStore(API_ENDPOINT, 'pixels'),
),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
new HttpVideoStore(API_ENDPOINT),
new HttpSnapshotStore(API_ENDPOINT),
new HttpReleaseStore(API_ENDPOINT),
{
user,
role,
users: new HttpUsersStore(API_ENDPOINT),
logOut: () =>
void endSession(editor).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
}),
// Straight through to the store: the server checks the old password, and the session this
// tab holds is the one it keeps – so there is nothing to do here afterwards.
changePassword: (current, next) => auth.changePassword(current, next),
},
);
const admin = role === 'admin';
const textures = new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures', admin);
const fonts = new HttpFileStore(API_ENDPOINT, 'fonts', admin);
const sounds = new HttpFileStore(API_ENDPOINT, 'sounds', admin);
const videos = new HttpVideoStore(API_ENDPOINT, admin);
const logOut = (): void =>
void endSession(editor, guest).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
});
// A guest (#428) gets the demo copy in a store of the tab's own, and every store that would
// write refuses before it asks: nothing a guest does reaches the server's writing routes.
const editor = guest
? new Editor(
app,
root,
state,
await guestProject(demos, entry.demo),
guestFiles(textures),
guestFiles(fonts),
guestFiles(sounds),
guestVideos(videos),
guestSnapshots,
guestReleases,
{
user,
role,
guest: true,
users: guestUsers,
logOut,
changePassword: () => Promise.reject(new Error(GUEST_REFUSAL)),
},
)
: new Editor(
app,
root,
state,
new CelUploadStore(
new HttpProjectStore(API_ENDPOINT),
pixelCels,
new HttpFileStore(API_ENDPOINT, 'pixels'),
),
textures,
fonts,
sounds,
videos,
new HttpSnapshotStore(API_ENDPOINT),
new HttpReleaseStore(API_ENDPOINT),
{
user,
role,
users: new HttpUsersStore(API_ENDPOINT),
demos: admin ? demos : undefined,
logOut,
// Straight through to the store: the server checks the old password, and the session
// this tab holds is the one it keeps – so there is nothing to do here afterwards.
changePassword: (current, next) => auth.changePassword(current, next),
},
);
installDebugHook(app, state, editor);
// Before the project, not after it (#119): which plugins run is written in the project file,
// and `boot()` hands that list over before it reads the file into the model.
await startPlugins(API_ENDPOINT, state, editor);
// A guest has one project; a `?project=` of an account's would only fail to open.
if (guest) forgetProjectInAddress();
await editor.boot();
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
};
@@ -120,19 +174,34 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
* 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> => {
const endSession = async (editor: Editor, guest: boolean): Promise<void> => {
await editor.dispose();
document.title = 'Feuerwerk';
// 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);
// A guest has no session on the server to end.
if (!guest) {
try {
await auth.logOut();
} catch (error) {
console.error('[auth] Signing out did not reach the server:', error);
}
}
await openSession();
};
await openSession();
}
/** The demo copy a guest picked, in a store of the tab's own (#428). */
async function guestProject(demos: DemoStore, demo: DemoSummary): Promise<MemoryProjectStore> {
const copy = await demos.load(demo.token);
return new MemoryProjectStore(demo.token, copy.name, copy.project, GUEST_REFUSAL);
}
function forgetProjectInAddress(): void {
const url = new URL(location.href);
url.searchParams.delete('project');
history.replaceState(null, '', url);
}
+42 -14
View File
@@ -4,6 +4,7 @@ import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
import { assetTitle } from '../core/music/embedded';
import { mixOf, videoBusesOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
import type { DemoStore } from '../core/store/DemoStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
@@ -158,7 +159,11 @@ export interface EditorAccount {
user: string;
/** What the editor offers: an administrator gets the user management (#180, #179). */
role: Role;
/** Somebody trying a demo copy without an account (#428): nothing they do is kept. */
guest?: boolean;
users: UsersStore;
/** An administrator's: making the demo copy of the open project (#428). */
demos?: DemoStore;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
@@ -365,6 +370,7 @@ export class Editor implements PlayHost {
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private readonly guest: boolean;
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
@@ -448,11 +454,13 @@ export class Editor implements PlayHost {
() => {
if (this.play.isPlaying) this.play.stop();
},
{ snapshots, releases, flush: () => this.autosave.flush() },
{ snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() },
{ guest: account.guest === true, role: account.role },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
);
this.guest = account.guest === true;
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
@@ -476,16 +484,20 @@ export class Editor implements PlayHost {
: null;
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
]),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
step: () => this.play.step(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
});
this.toolbar = new Toolbar(
state,
{
openMainMenu: (anchor) =>
this.projects.openMainMenu(anchor, this.managementEntries(), [
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
]),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
step: () => this.play.step(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
},
account.guest === true,
);
root.add(this.toolbar);
this.workspace = new HStack();
@@ -614,7 +626,10 @@ export class Editor implements PlayHost {
// A press in the editor takes the keys back from a window (#371).
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
this.autosave = attachAutosave(state, store);
// A guest's project lives in the tab alone (#428): there is nowhere to save it to.
this.autosave = this.guest
? { flush: () => Promise.resolve(), stop: () => {} }
: attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, {
play: () => this.play.play(),
@@ -863,6 +878,14 @@ export class Editor implements PlayHost {
* picked: it says who is signed in without costing the crowded toolbar another label.
*/
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
if (this.guest) {
new Menu([
{ label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} },
{ separator: true },
{ label: 'Sign in…', onSelect: () => account.logOut() },
]).openBelow(anchor);
return;
}
new Menu([
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
{ separator: true },
@@ -1348,8 +1371,13 @@ export class Editor implements PlayHost {
};
}
boot(): Promise<void> {
return this.projects.boot();
async boot(): Promise<void> {
await this.projects.boot();
if (this.guest) {
this.state.setNotice(
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
);
}
}
/**
+78 -12
View File
@@ -26,6 +26,7 @@ import {
crispText,
} from '../ui';
import { dialogButtons } from './FormDialog';
import { allows } from './session';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
export const PICKER_WIDTH = 560;
@@ -55,10 +56,15 @@ function ownerText(owner: string | null): string {
/** The line under the list for a picked file: its upload name and size, whose it is, and who wears it. */
function describeStored(item: PickerItem): string {
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
const owned = `${size} – ${ownerText(item.owner)}`;
const owned = `${size} – ${ownerText(item.owner)}${demoText(item)}`;
return item.assetName ? `${owned} – already in the project as "${item.assetName}"` : owned;
}
/** Said after what a file is when an administrator offers it to everybody (#428). */
export function demoText(item: StoredFile): string {
return item.demo ? ' – demo material' : '';
}
export interface PickerRow {
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
box: Container;
@@ -116,6 +122,10 @@ export abstract class PickerList<Item extends PickerItem, Row extends PickerRow>
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
get demoFiles(): string[] {
return this.items.filter((item) => item.demo === true).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
@@ -203,6 +213,16 @@ export abstract class RowList<Item extends PickerItem, Row extends PickerRow> ex
/** What the Delete button says while it waits for the second press that really deletes. */
const ARMED_LABEL = 'Delete for good';
const OFFER_LABEL = 'Offer as demo';
const WITHDRAW_LABEL = 'Stop offering';
interface ButtonInfo {
enabled: boolean;
label: string;
shown: string;
x: number;
y: number;
}
export interface PickerWords {
title: string;
@@ -243,6 +263,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
private readonly detail: Label;
private readonly use: Button;
private readonly remove: Button;
/** An administrator's switch for offering the picked file as demo material (#428). */
private readonly offer: Button | null;
private readonly buttons: HStack;
/** The file the Delete button was pressed for once; the second press deletes it. */
private armed: string | null = null;
@@ -254,6 +276,8 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
private readonly deleteFile: (file: string) => Promise<void>,
/** Buttons of a kind of picker of its own, after Delete; `picked` says what is picked. */
protected readonly extras: readonly Button[] = [],
/** There for an administrator only, see `FileStore.setDemo`. */
private readonly setDemo?: (file: string, demo: boolean) => Promise<void>,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label(words.title, { style: 'bodyBold' }));
@@ -280,36 +304,52 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.remove = new Button('Delete', { variant: 'danger' });
this.remove.onClick = () => void this.pressDelete();
this.remove.setEnabled(false);
this.buttons = dialogButtons(this.closeThen(), this.use, [uploadButton, this.remove, ...extras]);
this.offer = setDemo ? new Button(OFFER_LABEL) : null;
if (this.offer) {
this.offer.onClick = () => void this.toggleDemo();
this.offer.setEnabled(false);
}
// A guest (#428) uploads and deletes nothing: the buttons are not there rather than refused.
const writes = allows('upload') ? [uploadButton, this.remove] : [];
this.buttons = dialogButtons(this.closeThen(), this.use, [
...writes,
...extras,
...(this.offer ? [this.offer] : []),
]);
this.content.add(this.buttons);
}
/**
* What the dialog offers, which of it the project already wears, what is picked, the line under
* the list and the Delete button with its centre (test hook).
* the list, the Delete button with its centre and, for an administrator, the demo switch; a
* button that is not in the dialog is null (test hook).
*/
debugInfo(): {
files: string[];
used: string[];
demo: string[];
captions: string[];
selected: string | null;
detail: string;
deleteButton: { enabled: boolean; label: string; shown: string; x: number; y: number };
deleteButton: ButtonInfo | null;
uploadShown: boolean;
offerButton: ButtonInfo | null;
} {
const at = this.remove.getGlobalPosition();
const info = (button: Button): ButtonInfo => {
const at = button.getGlobalPosition();
const centre = { x: at.x + button.w / 2, y: at.y + button.h / 2 };
return { enabled: button.enabled, label: button.text, shown: button.shownText, ...centre };
};
return {
files: this.list.fileList,
used: this.list.usedFiles,
demo: this.list.demoFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
detail: this.detail.text,
deleteButton: {
enabled: this.remove.enabled,
label: this.remove.text,
shown: this.remove.shownText,
x: at.x + this.remove.w / 2,
y: at.y + this.remove.h / 2,
},
deleteButton: this.remove.parent ? info(this.remove) : null,
uploadShown: this.remove.parent !== null,
offerButton: this.offer ? info(this.offer) : null,
};
}
@@ -352,6 +392,11 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.setDeleteLabel('Delete');
this.use.setEnabled(item !== null && this.canUse(item));
this.remove.setEnabled(item?.deletable ?? false);
if (this.offer) {
this.offer.setEnabled(item !== null);
this.offer.setText(item?.demo ? WITHDRAW_LABEL : OFFER_LABEL);
this.buttons.layout();
}
this.picked(item);
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
this.refreshLayout();
@@ -401,6 +446,27 @@ export abstract class FilePicker<Item extends PickerItem> extends Popup {
this.refreshLayout();
}
/** Offers the picked file to everybody as demo material, or takes that back (#428). */
private async toggleDemo(): Promise<void> {
const item = this.list.selected;
if (!item || !this.setDemo) return;
const offered = !item.demo;
try {
await this.setDemo(item.file, offered);
} catch (error) {
this.say(`Not changed: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (this.list.destroyed) return;
await this.load();
this.list.select(item.file);
this.say(
offered
? `Every account and every guest can use ${item.name || `this ${this.words.noun}`} now.`
: `${item.name || `This ${this.words.noun}`} is no longer offered as demo material.`,
);
}
private confirm(): void {
const item = this.list.selected;
if (!item || !this.canUse(item)) return;
+89 -8
View File
@@ -12,6 +12,7 @@ import {
SetWhilePlayingCommand,
} from '../core/commands/projectCommands';
import { assetRefChoices } from '../core/properties/assetRef';
import type { DemoStore } from '../core/store/DemoStore';
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
@@ -24,6 +25,7 @@ import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'
import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
import { SnapshotsDialog } from './SnapshotsDialog';
import { type Session, sessionAllows } from './session';
import type { EditorState } from './state';
export class ProjectSession {
@@ -41,10 +43,18 @@ export class ProjectSession {
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
/**
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
* what the autosave still holds, so a copy is of the stand on screen.
* What the server copies of the project: snapshots (#231), the release (#266) and, for an
* administrator, the demo copy (#428). `flush` writes what the autosave still holds, so a copy is
* of the stand on screen.
*/
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
private readonly copies: {
snapshots: SnapshotStore;
releases: ReleaseStore;
demos?: DemoStore;
flush: () => Promise<void>;
},
/** What the menu offers: a guest (#428) neither switches projects nor has copies made. */
private readonly session: Session,
/**
* Runs on the project as it came off the server, before it is read into the model: the plugins
* this project switches on are loaded there (#119), so that what they register is in place
@@ -79,6 +89,8 @@ export class ProjectSession {
this.beforeSwitch();
const loaded = await this.store.load(id, this.prepare);
this.state.loadProject(loaded.project, loaded.revision);
// A guest's demo is nobody's project: a reload brings the sign-in screen, not the copy (#428).
if (this.session.guest) return;
const url = new URL(location.href);
url.searchParams.set('project', id);
history.replaceState(null, '', url);
@@ -109,12 +121,20 @@ export class ProjectSession {
* the editor adds that is not about the project (#179).
*/
openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void {
const may = (action: Parameters<typeof sessionAllows>[1]) => sessionAllows(this.session, action);
const items: MenuEntry[] = [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() },
...(may('projects')
? [
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true } as const,
]
: []),
...(may('snapshots') ? [{ label: 'Snapshots…', onSelect: () => this.openSnapshots() }] : []),
...(may('release') ? [{ label: 'Release…', onSelect: () => this.openRelease() }] : []),
...(may('demoCopy') && this.copies.demos
? [{ label: 'Demo copy…', onSelect: () => this.openDemoCopy() }]
: []),
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
...project,
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
@@ -123,6 +143,67 @@ export class ProjectSession {
new Menu(items).openBelow(anchor);
}
/**
* The demo copy of the open project (#428): what a guest gets when they pick it on the sign-in
* screen. It is the project as stored, like a release, so the dialog says how old it is and
* whether the project has moved on since.
*/
openDemoCopy(): void {
const demos = this.copies.demos;
if (!demos) return;
const projectId = this.state.project.id;
void this.run(
(async () => {
const copy = await demos.of(projectId);
const cancel = { label: 'Close', onClick: () => {} };
const update = {
label: copy ? 'Update demo copy' : 'Make demo copy',
primary: true,
onClick: () => void this.run(this.publishDemo(projectId, copy !== null)),
};
if (!copy) {
new Dialog(
'Demo copy',
'Guests can try a copy of this project without an account. It is the project as it is saved now; later changes reach them when you update the copy.',
[cancel, update],
).openCentered();
return;
}
const behind = copy.revision !== this.state.revision ? ' The project has changed since.' : '';
new Dialog(
'Demo copy',
`Guests can pick this project. Their copy is from ${new Date(copy.publishedAt).toLocaleString()}.${behind}`,
[
{
label: 'Remove',
danger: true,
onClick: () =>
void this.run(
(async () => {
await demos.takeDown(projectId);
this.state.setNotice('Demo copy removed – guests no longer see this project');
})(),
),
},
cancel,
update,
],
).openCentered();
})(),
);
}
private async publishDemo(projectId: string, again: boolean): Promise<void> {
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus, 'demo');
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
await this.copies.demos?.publish(projectId);
this.state.setNotice(again ? 'Demo copy updated' : 'Demo copy made – guests can pick this project now');
}
/** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */
private clearSavedData(): void {
const id = this.state.project.id;
+34
View File
@@ -1,11 +1,13 @@
import { Graphics } from 'pixi.js';
import logoSvg from '../../../public/brand/feuerwerk-logo-stacked-dark.svg?raw';
import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore';
import type { DemoStore, DemoSummary } from '../../core/store/DemoStore';
import {
Button,
drawFloatingBox,
drawSvg,
Label,
Menu,
Spacer,
TextField,
Theme,
@@ -77,20 +79,26 @@ class Card extends VStack {
*
* 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.
*
* Below the form a guest picks a demo copy to try the editor without an account (#428).
*/
export class LoginScreen extends VStack {
private readonly logo = new Logo();
private readonly user: TextField;
private readonly password: TextField;
private readonly submit: Button;
private readonly tryButton: Button;
private readonly status = new FormStatus((busy) => {
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
this.submit.setEnabled(!busy);
this.tryButton.setEnabled(!busy);
});
constructor(
private readonly auth: AuthStore,
private readonly demos: DemoStore,
private readonly onSignedIn: (account: SignedIn) => void,
private readonly onGuest: (demo: DemoSummary) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
@@ -126,6 +134,13 @@ export class LoginScreen extends VStack {
onClick: () => void this.signIn(),
});
card.add(this.submit);
card.add(new Spacer(Theme.padding));
card.add(new Label('No account? Try a demo project – nothing is saved.', { style: 'small' }));
this.tryButton = new Button('Try without an account', {
width: FIELD_WIDTH,
onClick: () => void this.pickDemo(),
});
card.add(this.tryButton);
this.add(card);
// Twice the space below as above, so logo and card sit a little above the middle.
@@ -142,6 +157,25 @@ export class LoginScreen extends VStack {
return this.status.info();
}
/** The demo copies under the button, one entry each; picking one starts the guest session. */
private async pickDemo(): Promise<void> {
if (this.status.busy) return;
let demos: DemoSummary[];
try {
demos = await this.demos.list();
} catch (error) {
this.status.say(`Could not list the demos: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (demos.length === 0) {
this.status.say('There are no demo projects yet');
return;
}
new Menu(demos.map((demo) => ({ label: demo.name, onSelect: () => this.onGuest(demo) }))).openBelow(
this.tryButton,
);
}
private async signIn(): Promise<void> {
if (this.status.busy) return;
const username = this.user.value.trim();
+6 -1
View File
@@ -3,6 +3,7 @@ import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImpo
import { importImages, type TextureImportDeps } from './textureImport';
import { openVideoPicker } from './video/VideoPicker';
import { isVideoFileName, uploadVideos, type VideoImportDeps } from './videoImport';
import { allows, GUEST_REFUSAL } from './session';
export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps & VideoImportDeps;
@@ -28,12 +29,16 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): ()
return;
}
event.preventDefault();
if (!allows('upload')) {
deps.state.setNotice(GUEST_REFUSAL);
return;
}
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = sortFiles(event.clipboardData);
if (files.none) return;
if (files.none || !allows('upload')) return;
event.preventDefault();
run(files, deps);
};
+37
View File
@@ -0,0 +1,37 @@
import type { FileStore, StoredFile, UploadedFile } from '../core/store/FileStore';
import type { ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore } from '../core/store/SnapshotStore';
import type { UsersStore } from '../core/store/UsersStore';
import type { VideoStore } from '../core/store/VideoStore';
import { GUEST_REFUSAL } from './session';
/**
* What a guest's editor gets instead of the server's stores (#428). Files are listed as the server
* offers them to somebody signed out, which is the demo material; everything that would write is
* refused here in the tab, before a request is made. The rest of the editor works on these as on
* any store, so no path through it can reach the server's writing routes.
*/
function refuse(): Promise<never> {
return Promise.reject(new Error(GUEST_REFUSAL));
}
export function guestFiles<U extends UploadedFile, S extends StoredFile>(
server: FileStore<U, S>,
): FileStore<U, S> {
return { list: () => server.list(), upload: refuse, delete: refuse };
}
export function guestVideos(server: VideoStore): VideoStore {
return { list: () => server.list(), upload: refuse, delete: refuse, convert: refuse };
}
export const guestSnapshots: SnapshotStore = {
list: refuse,
create: refuse,
restore: refuse,
delete: refuse,
};
export const guestReleases: ReleaseStore = { of: refuse, publish: refuse, takeDown: refuse };
export const guestUsers: UsersStore = { list: refuse, create: refuse, update: refuse, remove: refuse };
+8 -2
View File
@@ -26,6 +26,8 @@ const SAVE_LABELS: Record<SaveStatus, string> = {
conflict: 'Conflict – reload',
};
const GUEST_LABEL = 'Guest – not saved';
/** Square, because an icon has no reading direction that would ask for a wider box. */
const ICON_BUTTON = Theme.controlHeight;
@@ -68,6 +70,8 @@ export class Toolbar extends HStack {
constructor(
private readonly state: EditorState,
actions: ToolbarActions,
/** A guest's project is never saved (#428), and the place of the save status says so. */
private readonly guest = false,
) {
super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' });
this.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
@@ -195,8 +199,10 @@ export class Toolbar extends HStack {
this.pauseButton.invalidate();
this.stepButton.setEnabled(!editing && state.paused);
this.stepButton.invalidate();
this.status.setText(SAVE_LABELS[state.saveStatus]);
this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small');
this.status.setText(this.guest ? GUEST_LABEL : SAVE_LABELS[state.saveStatus]);
this.status.setStyle(
this.guest || state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small',
);
}
/**
+18 -2
View File
@@ -1,13 +1,19 @@
import type { UploadedVideo } from '../core/store/VideoStore';
import type { MadeRecording } from '../viewport/RecordingTake';
import type { RecordingUploadOptions } from '../core/recording/recording';
import { downloadBlob } from '../ui';
import { allows } from './session';
import { adoptVideo, type VideoImportDeps } from './videoImport';
/** How often the server is asked whether a recording is converted, as the video picker does. */
const POLL_MS = 1000;
/** `kept`: uploaded without converting it; `converted`: converted, but not to be imported. */
type UploadState = 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed';
/**
* `kept`: uploaded without converting it; `converted`: converted, but not to be imported;
* `downloaded`: a guest's, handed to the browser instead (#428).
*/
type UploadState =
'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed' | 'downloaded';
/** What became of a recording on its way into the project (test hook). */
export interface RecordingUpload {
@@ -26,6 +32,8 @@ export interface RecordingUpload {
* while the editor is not playing: a run plays a copy of the project that is thrown away, and so
* would the asset be. A recording may stop short of either step, as "Start recording…" or the node
* asked: it is always uploaded, and the video picker has it.
*
* A guest keeps nothing on the server (#428): their recording is saved as a download instead.
*/
export class RecordingUploads {
private readonly uploads: RecordingUpload[] = [];
@@ -42,6 +50,14 @@ export class RecordingUploads {
error: '',
};
this.uploads.push(upload);
if (!allows('upload')) {
downloadBlob(recording.blob, `${recording.name}.mkv`);
upload.state = 'downloaded';
this.deps.state.setNotice(
`Saved the recording "${recording.name}" as a download – guests keep nothing on the server.`,
);
return;
}
void this.bring(recording, upload, options).catch((error: unknown) => {
upload.state = 'failed';
upload.error = error instanceof Error ? error.message : String(error);
+52
View File
@@ -0,0 +1,52 @@
import type { Role } from '../core/store/AuthStore';
/**
* Who sits at the editor (#428): somebody signed in, with their role, or a guest trying a demo
* copy without an account.
*/
export interface Session {
guest: boolean;
role: Role;
}
/**
* Everything the editor does that writes to the server or manages it. A guest's project lives in
* the tab alone, so a guest does none of these; an account does all of them but the
* administrators' own.
*/
export type SessionAction =
| 'save'
| 'upload'
| 'deleteFile'
| 'projects'
| 'snapshots'
| 'release'
| 'password'
| 'users'
| 'demoCopy'
| 'offerDemo';
const ADMINISTRATORS_ONLY: ReadonlySet<SessionAction> = new Set(['users', 'demoCopy', 'offerDemo']);
/** Whether `session` may do `action`. The server decides again; this is what the editor offers. */
export function sessionAllows(session: Session, action: SessionAction): boolean {
if (session.guest) return false;
return !ADMINISTRATORS_ONLY.has(action) || session.role === 'admin';
}
/** What a guest is told when something would need the server. */
export const GUEST_REFUSAL = 'Guests keep nothing on the server – sign in to keep your work';
let current: Session = { guest: false, role: 'user' };
/**
* The session of this page, for the places that are not handed the account: the pickers, a file
* dropped on the editor, a finished recording. One page holds one session at a time.
*/
export function setSession(session: Session): void {
current = session;
}
export function allows(action: SessionAction): boolean {
return sessionAllows(current, action);
}
+6 -3
View File
@@ -6,14 +6,17 @@ export function snapshotTime(at: Date): string {
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
const COPY_WORDS = { snapshot: 'take a snapshot of', release: 'release', demo: 'make a demo copy of' };
/**
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
* what it has stored, so the editor has to have saved everything first: a snapshot without the
* last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same.
* last edits would be a stand nobody ever saw. A release (#266) and a demo copy (#428) are copies as
* well and ask the same.
*/
export function snapshotBlocker(
status: SaveStatus,
copy: 'snapshot' | 'release' = 'snapshot',
copy: 'snapshot' | 'release' | 'demo' = 'snapshot',
): string | null {
switch (status) {
case 'saved':
@@ -21,7 +24,7 @@ export function snapshotBlocker(
case 'conflict':
return 'This project was changed elsewhere. Reload to continue.';
case 'error':
return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`;
return `The project could not be saved, so there is nothing current to ${COPY_WORDS[copy]}`;
default:
return 'The latest changes are not saved yet – stop the game or wait a moment';
}
+2
View File
@@ -99,6 +99,8 @@ class SoundPicker extends FilePicker<SoundItem> {
},
() => pickAndImportSounds(deps),
(file) => deps.sounds.delete(file),
[],
deps.sounds.setDemo,
);
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { formatBytes, type Size, textStyle, truncate, crispText } from '../../ui
import { textureLibrary } from '../../viewport/TextureLibrary';
import {
currentPicker,
demoText,
FilePicker,
openPicker,
PICKER_WIDTH,
@@ -112,6 +113,8 @@ class ImagePicker extends FilePicker<ImageItem> {
},
() => pickAndImportImages(deps),
(file) => deps.textures.delete(file),
[],
deps.textures.setDemo,
);
}
@@ -136,7 +139,7 @@ class ImagePicker extends FilePicker<ImageItem> {
protected override describe(item: ImageItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
const size = `${item.name ? `${item.name} – ${measurements}` : measurements}${demoText(item)}`;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { posterLibrary, videoFileUrl } from '../../viewport/VideoLibrary';
import { pickFiles } from '../assetImport';
import {
currentPicker,
demoText,
FilePicker,
openPicker,
type PickerItem,
@@ -108,6 +109,7 @@ class VideoPicker extends FilePicker<VideoItem> {
new Button('Download', { tooltip: 'Save the video as it was uploaded' }),
new Button('Convert', { tooltip: 'Convert it on the server, so a project can use it' }),
],
deps.videos.setDemo,
);
const [download, convert] = this.extras;
download!.onClick = () => this.download();
@@ -214,7 +216,7 @@ class VideoPicker extends FilePicker<VideoItem> {
protected override describe(item: VideoItem): string {
const size =
item.status === 'ready' || item.status === 'unconverted' ? ` – ${formatBytes(item.bytes)}` : '';
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}`;
return `${item.name || 'Video'}${size} – ${videoStatusText(item)}${demoText(item)}`;
}
private itemOf(file: string): VideoItem | null {