Public: anyone can read this; the crew can change it.

A display launches into the arcade host, playable by keyboard and on a phone #9570

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9468-a-display-launches-into-the-arcade-host → main 8 commits fork 2 threads · 2 unresolved

  • Merged. mason/9468-a-display-launches-into-the-arcade-host was merged into main as 0b4277a.

Review

Sign in to review this change.
14 files changed +1089 −15
docs/design/areas/display.md +4 −2
@@ -1,5 +1,7 @@
11 # Display
22
3 A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`): the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player, editing the title waits for `pin_demos.ts` and the Arcade host.
3 A repository's display, `/:owner/:name/display` (`src/web/pages/display.ts`): the builds its `display.json` holds, as a dense table (`.table`, `.table-scroll`) under the repo header, as whoever looks may read them. It adds no component class. Covers, the player and editing the title wait for `pin_demos.ts`.
44
5 Writers also get a form of `.field` fieldset, `.check` rows, a `btn` and a `.hint` status line: no new component class.↵
5 Writers also get a form of `.field` fieldset, `.check` rows, a `btn` and a `.hint` status line: no new component class.
6
7 On the display titled `arcade-builds` in the shelf layout the page head carries **Launch**, a plain link (`.btn`) to the Arcade host (`arcade.<app domain>`, `src/arcade/host.ts`). The host page is its own document, outside the forge's design system: pixel look, no third-party fonts, focus to `#back` when a cart opens and back to its cart when it closes, prev/next buttons for carts off the front, a touch hint under `(hover: none)`, and a `<noscript>` line.
docs/features/display.md +3 −1
@@ -9,4 +9,6 @@ One repository's display: the builds its `display.json` (on the default branch)
99
1010 - **Choosing builds**: a person who writes the repository (never an agent) ticks builds and saves, on the page, in the terminal, or over the API. The page form posts to the same page (works without script; with script it sends the PUT); the terminal's Display screen shows `[x]`/`[ ]` rows for a writer, `space` ticks and `s` saves; the API is `PUT /api/v1/repos/:owner/:name/display/builds` with `{ "builds": ["owner/name:label", …], "tip"?: commit }` (rw scope, same-origin for a session). All three call `chooseBuilds` (`src/actions/displays.ts`), which commits `builds` into `display.json`. Each build must be a pin in a repository the writer reads (422 otherwise); a `tip` that is no longer the default branch's commit is a 409. Order is the order shown; reordering waits.
1111
12 Not done: editing the title, covers, the player and the Arcade host. See `docs/features/displays.md` for the list.
12 - **Launch**: the Arcade's display (the `arcade-builds` shelf) offers Launch, which opens the Arcade host at `arcade.<app domain>`: its carts as a carousel, playable by keyboard (◀ ▶, Z X, Enter) or by tap, with prev/next buttons, and an offline snapshot to download. On the page it is the "Launch" button beside the title; in the terminal the Display screen's first line ends "Launch: <url>/" (from `launchUrl`, `src/arcade/host.ts`). The host answers reads only, with the security floor and a policy that allows no third-party origin; carts play in sandboxed frames. Nothing is offered where apps do not run.
13
14 Not done: editing the title and covers. See `docs/features/displays.md` for the list.
scripts/unreached-allow.json −2
@@ -1,7 +1,5 @@
11 [
22 "src/apps/cloud.ts",
3 "src/arcade/cover.ts",
4 "src/arcade/shelf.ts",
53 "src/boards/machine.ts",
64 "src/crew/governor_log.ts",
75 "src/crew/tries.ts",
src/arcade/arcade.html added +345
@@ -0,0 +1,345 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>Browser Arcade</title>
7 <link rel="icon" href="data:,">
8 <style>
9 :root {
10 --night: #120a26;
11 --ink: #fff6e0;
12 --sun: #ffd200;
13 --lime: #9bbc0f;
14 --line: #08040f;
15 --pixel: "Press Start 2P", ui-monospace, Menlo, monospace;
16 }
17 * { box-sizing: border-box; }
18 html, body { margin: 0; min-height: 100%; color: var(--ink); font: 11px/1.5 var(--pixel); overflow-x: hidden;
19 background-color: #4a2a16; }
20 /* A woodgrain tabletop: planks, with darker grain running along them. */
21 body { background-image:
22 repeating-linear-gradient(0deg, rgba(0,0,0,.35) 0 3px, transparent 3px 96px),
23 repeating-linear-gradient(0deg, rgba(255,210,150,.05) 0 2px, transparent 2px 9px, rgba(0,0,0,.08) 9px 11px, transparent 11px 23px),
24 linear-gradient(90deg, #4a2a16, #5a3420 30%, #4e2c18 55%, #5e3822 80%, #4a2a16); }
25 /* CRT scanlines over the whole shelf. */
26 body::after { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 5; background: repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px); }
27 button, a { font: inherit; color: inherit; }
28 /* One line across the top: the producer, and for a snapshot file, its date. */
29 header { position: relative; padding: 10px 16px; background: rgba(8,4,15,.7); border-bottom: 4px solid var(--line); font-size: 10px; color: var(--sun);
30 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
31
32 /* Carts: chunky plastic with a black outline, bevels, screws, a deep grip and a big sticker. */
33 /* Carts turned to the sides must not widen the page. */
34 main { overflow-x: clip; }
35 .stage { position: relative; height: 330px; perspective: 1100px; margin-top: 22px; }
36 .cart { position: absolute; left: 50%; top: 14px; width: 210px; margin-left: -105px; cursor: pointer; border: 0; padding: 0; background: none;
37 transition: transform .45s cubic-bezier(.2,.9,.3,1.1), filter .45s, top .35s ease-in; }
38 .shell { position: relative; padding: 14px 14px 66px; background: var(--body); border: 5px solid var(--line);
39 box-shadow: inset 6px 6px 0 var(--hi), inset -6px -6px 0 var(--lo), 8px 8px 0 var(--line); }
40 .shell::before { content: ""; position: absolute; left: 50%; top: 4px; width: 46px; height: 5px; margin-left: -23px; background: var(--lo); box-shadow: 0 0 0 2px var(--line); }
41 .screw { position: absolute; bottom: 52px; width: 9px; height: 9px; border-radius: 50%; background: var(--hi); box-shadow: 0 0 0 2px var(--line), inset -2px -2px 0 var(--lo); }
42 .screw.l { left: 9px; } .screw.r { right: 9px; }
43 .grip { position: absolute; left: 14px; right: 14px; bottom: 10px; height: 38px; border: 3px solid var(--line);
44 background: repeating-linear-gradient(0deg, var(--lo) 0 5px, var(--hi) 5px 7px, var(--body) 7px 10px); }
45 .sticker { position: relative; aspect-ratio: 1; background: #000; border: 5px solid #fff; box-shadow: 0 0 0 4px var(--line); overflow: hidden; }
46 .sticker img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; display: block; }
47 .sticker .logo { position: absolute; left: 0; right: 0; bottom: 0; padding: 20px 6px 6px; font: 15px/1.05 "Bungee", sans-serif; color: #fff;
48 text-shadow: 2px 2px 0 var(--line), -1px -1px 0 var(--line); background: linear-gradient(transparent, rgba(0,0,0,.75)); text-align: left; }
49 .cart.loading { top: 230px; transform: translateZ(0) scale(.92) !important; }
50
51 /* The console: a heavy slab with a slot, switches, a readout, and big buttons. */
52 .console { position: relative; z-index: 2; max-width: 760px; margin: -6px auto 0; padding: 30px 26px 24px;
53 background: linear-gradient(#45405a, #2c283c); border: 6px solid var(--line);
54 box-shadow: inset 0 8px 0 #6a6488, inset 0 -10px 0 #1c1828, 10px 10px 0 var(--line); }
55 .console .slot { position: absolute; left: 50%; top: -6px; width: 250px; height: 22px; margin-left: -125px; background: #05030a; border: 6px solid var(--line); border-top: 0; }
56 .row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
outdated

The disabled pager buttons at opacity .5 and the 9px footer hint need AA contrast. The .empty rule is also declared twice, and --dim and --pink are unused.

57 .row + .row { margin-top: 16px; }
58 .badge { font: 16px "Bungee", sans-serif; letter-spacing: 5px; color: #b8b0d8; text-shadow: 2px 2px 0 var(--line); }
59 .led { width: 14px; height: 14px; border-radius: 50%; background: #5a1010; box-shadow: 0 0 0 3px var(--line); }
60 .led.on { background: #ff3a3a; box-shadow: 0 0 0 3px var(--line), 0 0 14px #ff3a3a; }
61 .led.go { background: #3aff6a; box-shadow: 0 0 0 3px var(--line), 0 0 14px #3aff6a; }
62 .grille { flex: 1; min-width: 80px; height: 24px; background: repeating-linear-gradient(90deg, #1c1828 0 6px, #55507a 6px 9px); border: 3px solid var(--line); }
63 .lcd { flex: 1; min-width: 220px; background: #9bbc0f; color: #0f380f; border: 5px solid var(--line); box-shadow: inset 3px 3px 0 #306230; padding: 10px 12px; font-size: 10px; line-height: 1.7; }
64 .lcd b { display: block; font-size: 12px; }
65 .switches { display: flex; gap: 8px; flex-wrap: wrap; }
66 .switch { cursor: pointer; padding: 10px 10px; font-size: 9px; background: #2c283c; border: 4px solid var(--line); box-shadow: inset 3px 3px 0 #55507a, 4px 4px 0 var(--line); }
67 .switch[aria-pressed="true"] { background: var(--sun); color: var(--line); box-shadow: inset 3px 3px 0 #fff2a0, 2px 2px 0 var(--line); transform: translate(2px, 2px); }
68 .big { cursor: pointer; padding: 14px 18px; font-size: 12px; border: 5px solid var(--line); text-decoration: none; display: inline-block; }
69 .power { background: #ff3a3a; color: #fff; box-shadow: inset 4px 4px 0 #ff8a8a, inset -4px -4px 0 #9a1010, 5px 5px 0 var(--line); }
70 .eject { background: #c8c0e0; color: var(--line); box-shadow: inset 4px 4px 0 #fff, inset -4px -4px 0 #8a80a8, 5px 5px 0 var(--line); }
71 .big:active { transform: translate(4px, 4px); box-shadow: none; }
72 .switch:focus-visible, .big:focus-visible, .cart:focus-visible { outline: 4px solid var(--sun); outline-offset: 4px; }
73 footer { position: relative; text-align: center; color: var(--ink); font-size: 10px; padding: 22px 16px 30px; }
74 .hint-touch { display: none; }
75 @media (hover: none) { .hint-keys { display: none; } .hint-touch { display: inline; } }
76 .pager { display: flex; justify-content: center; gap: 12px; padding: 4px 0 10px; position: relative; z-index: 20; }
77 .pager button { cursor: pointer; padding: 10px 14px; font-size: 10px; background: #2c283c; color: var(--ink); border: 4px solid var(--line); box-shadow: 3px 3px 0 var(--line); }
78 .pager button:disabled { color: #b8b0d8; border-color: #55507a; cursor: default; }
79 .pager button:focus-visible { outline: 4px solid var(--sun); outline-offset: 4px; }
80 footer a { color: var(--sun); text-decoration: none; margin-left: 14px; }
81 footer a:hover, footer a:focus-visible { text-decoration: underline; }
82 .empty { color: var(--ink); text-align: center; padding-top: 80px; }
83 @media (max-width: 600px) { .stage { height: 290px; } .cart { width: 170px; margin-left: -85px; } .cart.loading { top: 200px; } }
84
85 /* The player: the game fills the screen, with a way back. */
86 #player { position: fixed; inset: 0; background: #000; display: none; z-index: 10; }
87 #player.on { display: block; }
88 #player iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
89 #back { position: absolute; top: 10px; left: 10px; z-index: 4; cursor: pointer; font-size: 10px; color: var(--ink);
90 background: #2c283c; border: 4px solid var(--line); box-shadow: 3px 3px 0 var(--line); padding: 6px 9px; opacity: 1; }
91 #back:hover, #back:focus-visible { opacity: 1; }
92 /* The jbox boot tag plays over the screen while a cartridge loads. */
93 #boot { position: absolute; inset: 0; display: none; place-items: center; background: #1b1b25; z-index: 1; transition: opacity .25s; }
94 #boot.on { display: grid; }
95 #boot.out { opacity: 0; }
96 #boot canvas { display: block; image-rendering: pixelated; }
97 /* Opened from a link, the cartridge waits for a press so its power-on chord can play. */
98 #power { position: absolute; inset: 0; display: none; place-items: center; z-index: 3; background: #1b1b25; color: var(--lime); cursor: pointer; text-align: center; font-size: 11px; }
99 #power.on { display: grid; }
100 #power b { display: block; font-size: 32px; margin-bottom: 14px; }
101 </style>
102 </head>
103 <body>
104 <header id="topline">JBOX • BROWSER ARCADE</header>
105 <main id="games"><noscript><p class="empty">THE ARCADE NEEDS JAVASCRIPT TO SHOW ITS SHELF AND PLAY CARTS.</p></noscript></main>
106 <footer><span class="hint-keys">◀ ▶ CHOOSE · Z X BUILD · ENTER POWER</span><span class="hint-touch">TAP A CART TO CHOOSE · TAP AGAIN OR POWER TO PLAY · TAP A BUILD NAME TO SWITCH</span><a id="export" href="snapshot.html" download>⤓ EXPORT SNAPSHOT</a></footer>
107
108 <div id="player" aria-hidden="true">
109 <button id="back" type="button">◀ ARCADE</button>
110 <div id="boot"><canvas id="tag"></canvas></div>
111 <div id="power"><div><b>⏻</b>PRESS ANY KEY TO POWER ON</div></div>
112 <iframe id="screen" title="Game" allow="autoplay *; fullscreen *; gamepad *"></iframe>
113 </div>
114
115 <script type="module">
116 import { jboxScale, playJboxSpin } from './boot/jbox-spin.js';
117 const shelfEl = document.getElementById('games');
118 const player = document.getElementById('player');
119 const screen = document.getElementById('screen');
120 const bootEl = document.getElementById('boot');
121 const tag = document.getElementById('tag');
122 const powerEl = document.getElementById('power');
123 /** Bytes as a short size: 38 KB, 1.2 MB. */
124 const size = (n) => (!n ? '' : n < 1e6 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
125 // A single-file bundle (a snapshot) carries the shelf, carts and covers inside the
126 // page as window.__ARCADE__; the forge's own display page carries the shelf with
127 // absolute addresses. Served as a folder, the arcade reads its files instead.
128 const BUNDLE = window.__ARCADE__;
129 const HOME = document.title;
130 const urls = new Map();
131 /** Where a cart or label lives: its path, or a blob or data URL from the bundle. */
132 const url = (path) => {
133 if (!BUNDLE || !path || /^(data:|https?:|\/)/.test(path)) return path;
134 if (!urls.has(path)) {
135 const label = BUNDLE.labels[path];
136 urls.set(path, label ?? URL.createObjectURL(new Blob([BUNDLE.carts[path] ?? ''], { type: 'text/html' })));
137 }
138 return urls.get(path);
139 };
140 if (BUNDLE?.snapshot) document.getElementById('topline').textContent = BUNDLE.snapshot;
141 // A snapshot is the export: it has nothing further to export.
142 if (BUNDLE?.exportHref) document.getElementById('export').href = BUNDLE.exportHref;
143 else if (BUNDLE) document.getElementById('export').remove();
144 const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
145
146 /** Cart plastic by game: body, highlight and shadow. Unknown games get grey. */
147 const PLASTIC = {
148 'krazy-karts': ['#e8322a', '#ff7a6a', '#8a1410'],
149 'nova-lantern': ['#5a3ab8', '#8a6ae8', '#2a1a68'],
150 ballistics: ['#f0a020', '#ffd870', '#9a5a0a'],
151 'krazy-karts-fiesta': ['#ff7a1a', '#ffb060', '#a83e00'],
152 'krazy-karts-london': ['#4a6a8a', '#7a9aba', '#24384c'],
153 'krazy-karts-chicago': ['#2a8ad8', '#6ac0ff', '#124a7a'],
154 'nova-moves': ['#1f8a6a', '#4ac89a', '#0e4a38'],
155 };
156 const plastic = (id) => PLASTIC[id] ?? ['#8a8a96', '#c0c0cc', '#4a4a56'];
157
158 /** Wait for a key or click: browsers only play sound after one. */
159 function powerPress() {
160 powerEl.classList.add('on');
161 return new Promise((done) => {
162 const go = (e) => {
163 e.preventDefault();
164 removeEventListener('keydown', go);
165 powerEl.removeEventListener('pointerdown', go);
166 powerEl.classList.remove('on');
167 done();
168 };
169 addEventListener('keydown', go);
170 powerEl.addEventListener('pointerdown', go);
171 });
172 }
173
174 /** The jbox tag spins up while the cartridge loads behind it. `sound` needs a click. */
175 async function bootCartridge(sound) {
176 bootEl.classList.remove('out');
177 bootEl.classList.add('on');
178 const s = jboxScale(innerWidth * 0.8, innerHeight * 0.8);
179 tag.style.width = `${160 * s}px`;
180 tag.style.height = `${144 * s}px`;
181 await playJboxSpin(tag, { sound });
182 }
183
184 async function play(game, build, push = true) {
185 if (push) history.pushState({ game: game.id, build: build.id }, '', `#${game.id}/${build.id}`);
186 player.classList.add('on');
187 player.setAttribute('aria-hidden', 'false');
188 document.getElementById('back').focus();
189 document.title = `${game.name} · ${build.name}`;
190 const ready = new Promise((r) => (screen.onload = r));
191 // Replace, so the frame's own loads never land in the browser's history.
192 screen.contentWindow.location.replace(BUNDLE ? url(build.file) : new URL(build.file, location.href).href);
193 // A click on a cartridge already counts as the press. A link has to ask for one.
194 if (!push) await powerPress();
195 await Promise.all([ready, bootCartridge(true)]);
196 bootEl.classList.add('out');
197 screen.focus();
198 setTimeout(() => bootEl.classList.remove('on', 'out'), 260);
199 }
200
201 function stop() {
202 const wasOn = player.classList.contains('on');
203 player.classList.remove('on');
204 player.setAttribute('aria-hidden', 'true');
205 screen.contentWindow.location.replace('about:blank');
206 document.title = HOME;
207 shelfEl.querySelector('.loading')?.classList.remove('loading');
208 shelfEl.querySelector('.led')?.classList.replace('go', 'on');
209 if (wasOn) shelfEl.querySelector(`.cart[data-i="${gi}"]`)?.focus();
210 }
211
212 document.getElementById('back').addEventListener('click', () => {
213 history.pushState(null, '', location.pathname);
214 stop();
215 });
216
217 const games = BUNDLE ? BUNDLE.games : await fetch('games.json').then((r) => r.json()).catch(() => []);
218 // Builds stand in the display's order.
219 // A build with no cover of its own gets the house cover, drawn from its page's picture.
220 /*COVER_JS*/
221 if (games.some((g) => g.builds.some((b) => !b.label))) {
222 const tpl = await loadImage(url('cover-template.png'));
223 for (const g of games) for (const b of g.builds) {
224 if (b.label) continue;
225 const c = document.createElement('canvas');
226 drawCover(c, b.art ? await loadImage(url(b.art)) : null, tpl, g.name, b.name);
227 b.label = c.toDataURL('image/png');
228 b.titled = true;
229 }
230 }
231 let gi = 0;
232 const bi = games.map(() => 0);
233 const find = (gid, bid) => {
234 const g = games.find((x) => x.id === gid);
235 const b = g?.builds.find((x) => x.id === bid);
236 return g && b ? [g, b] : null;
237 };
238
239 // The carousel of carts is built once; `draw` turns it and refreshes the console.
240 if (!games.length) shelfEl.innerHTML = '<p class="empty">THE SHELF IS EMPTY</p>';
241 else {
242 shelfEl.innerHTML = `
243 <div class="stage">${games.map((g, i) => {
244 const [body, hi, lo] = plastic(g.id);
245 return `<button type="button" class="cart" data-i="${i}" style="--body:${body};--hi:${hi};--lo:${lo}" aria-label="${esc(g.name)}">
246 <div class="shell"><div class="sticker"><img alt=""><div class="logo">${esc(g.name).toUpperCase()}</div></div>
247 <span class="screw l"></span><span class="screw r"></span><div class="grip"></div></div></button>`;
248 }).join('')}</div>
249 <nav class="pager" aria-label="Carts"><button type="button" id="prev">◀ PREV</button><button type="button" id="next">NEXT ▶</button></nav>
250 <section class="console" aria-label="Console">
251 <div class="slot"></div>
252 <div class="row"><span class="led on"></span><span class="badge">JBOX</span><span class="grille"></span></div>
253 <div class="row"><div class="lcd" id="lcd"></div><div class="switches" id="switches"></div></div>
254 <div class="row"><button type="button" class="big power" id="go">⏻ POWER</button><a class="big eject" id="save">⏏ SAVE</a><span class="grille"></span></div>
255 </section>`;
256 }
257
258 function draw() {
259 if (!games.length) return;
260 const g = games[gi];
261 const b = g.builds[bi[gi]];
262 shelfEl.querySelectorAll('.cart').forEach((c, i) => {
263 const d = i - gi;
264 const x = games[i];
265 const xb = x.builds[bi[i]];
266 const img = c.querySelector('img');
267 if (xb.label && img.dataset.label !== xb.label) {
268 img.dataset.label = xb.label;
269 img.src = url(xb.label);
270 }
271 // Cover art carries its own title; a plain snapshot gets the game's name.
272 c.querySelector('.logo').hidden = Boolean(xb.titled);
273 // Far carts turn no more than 70°, so none shows its back.
274 c.style.transform = `translateX(${d * 220}px) translateZ(${-Math.abs(d) * 230}px) rotateY(${-Math.sign(d) * Math.min(Math.abs(d) * 36, 70)}deg)`;
275 c.style.filter = d ? 'brightness(.5)' : 'none';
276 c.style.zIndex = 10 - Math.abs(d);
277 c.tabIndex = 0;
278 });
279 document.getElementById('prev').disabled = gi === 0;
280 document.getElementById('next').disabled = gi === games.length - 1;
281 document.getElementById('lcd').innerHTML = `<b>${esc(g.name).toUpperCase()}</b>${esc(b.name).toUpperCase()}${b.size ? ` · ${size(b.size)}` : ''}${g.producer ? `<br>BY ${esc(g.producer).toUpperCase()}` : ''}`;
282 document.getElementById('switches').innerHTML = g.builds.map((x, k) => `<button type="button" class="switch" data-build="${k}" aria-pressed="${k === bi[gi]}">${esc(x.name).toUpperCase()}</button>`).join('');
283 const save = document.getElementById('save');
284 // A page served elsewhere names where it downloads from.
285 save.href = url(b.save ?? b.file);
286 save.download = b.download ?? `${g.id}-${b.id}.html`;
287 }
288
289 /** Drop the front cart into the slot, light the LED, then boot it. */
290 function insert() {
291 const g = games[gi];
292 shelfEl.querySelector(`.cart[data-i="${gi}"]`).classList.add('loading');
293 shelfEl.querySelector('.led').classList.replace('on', 'go');
294 setTimeout(() => play(g, g.builds[bi[gi]]), 380);
295 }
296
297 shelfEl.addEventListener('click', (e) => {
298 const cart = e.target.closest('.cart');
299 const sw = e.target.closest('.switch');
300 if (cart) {
301 const i = Number(cart.dataset.i);
302 if (i === gi) insert();
303 else { gi = i; draw(); }
304 } else if (sw) {
305 bi[gi] = Number(sw.dataset.build);
306 draw();
307 } else if (e.target.closest('#prev')) { gi = Math.max(0, gi - 1); draw(); }
308 else if (e.target.closest('#next')) { gi = Math.min(games.length - 1, gi + 1); draw(); }
309 else if (e.target.closest('#go')) insert();
310 });
311
312 // Pad controls: left and right turn the carousel, Z and X flip the build, Enter powers on.
313 addEventListener('keydown', (e) => {
314 if (player.classList.contains('on') || !games.length) return;
315 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
316 e.preventDefault();
317 gi = Math.min(games.length - 1, Math.max(0, gi + (e.key === 'ArrowLeft' ? -1 : 1)));
318 draw();
319 } else if (e.key === 'z' || e.key === 'x') {
320 const n = games[gi].builds.length;
321 bi[gi] = (bi[gi] + (e.key === 'x' ? 1 : n - 1)) % n;
322 draw();
323 } else if (e.key === 'Enter' && !e.target.closest?.('a, button')) {
324 e.preventDefault();
325 insert();
326 }
327 });
328 draw();
329
330 // Links like #krazy-karts/alpha-1025 open a cartridge directly. Back returns to the shelf.
331 const route = () => {
332 const [gid, bid] = location.hash.slice(1).split('/');
333 const hit = gid && find(gid, bid);
334 if (hit) {
335 gi = games.indexOf(hit[0]);
336 bi[gi] = hit[0].builds.indexOf(hit[1]);
337 draw();
338 play(...hit, false);
339 } else stop();
340 };
341 addEventListener('popstate', route);
342 route();
343 </script>
344 </body>
345 </html>
src/arcade/host.ts added +268
@@ -0,0 +1,268 @@
1 /**
2 * The Arcade: one repository's display (`display/config.ts`), served at
3 * `arcade.<app domain>`: the display titled `arcade-builds` in a repository
4 * a site admin owns, the newest of several (`display/known.ts`); with none,
5 * the host answers 404. A display in the shelf layout is browser-arcade's page as
6 * it was — the chunky carousel, the console and the jbox boot tag — fed by
7 * the display's items (`shelf.ts`); any other layout is drawn by the
8 * forge, so the host's root sends a person there.
9 *
10 * The host answers on an app's kind of hostname, its own origin, for two
11 * reasons: a cart is a stranger's page, and must never run as the forge;
12 * and a cart keeps its saves in that origin's storage, which a sandboxed
13 * frame would refuse it. `arcade` is a reserved app name, so no app can
14 * stand in its place. Nobody is signed in on this origin: the display is
15 * read as a stranger reads it: a showcased demo from a private source is
16 * on it, published, by its opaque id and never its repository. It is also
17 * where every display's `file` demos play (`display/launch.ts`), with an
18 * Arcade display or not: a page in a public repository at its plain
19 * address, a private one with a grant, and a published one by its display
20 * and id (`displays/…`), plain when the display is public.
21 */
22
23 import { readFileSync } from "node:fs";
24 import type { App } from "../app.ts";
25 import { appHostSuffix } from "../apps/host.ts";
26 import { parseAppHost } from "../apps/proxy.ts";
27 import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
28 import { canRead, getRepo, ownerInStanding, type Repo } from "../core/repos.ts";
29 import { arcadeRepoKnown, arcadeRepoOf } from "../display/known.ts";
30 import { readDisplay, type DisplayConfig } from "../display/config.ts";
31 import { displayItem, displayItems } from "../display/items.ts";
32 import { verifyCart } from "../display/launch.ts";
33 import { coverType, listBuildPins, readCover } from "../git/builds.ts";
34 import { repoDir } from "../git/repos.ts";
35 import type { Result } from "../kernel/result.ts";
36 import { pageFile, readArt, readCapture } from "./carts.ts";
37 import { COVER_JS } from "./cover.ts";
38 import { shelfOf, type Shelf } from "./shelf.ts";
39 import { snapshotFile } from "./snapshot.ts";
40
41 /** The app name the Arcade answers at. */
42 export const ARCADE_SLUG = "arcade";
43
44 /** Whether a request's `Host` is the Arcade's. */
45 export function isArcadeHost(host: string | null, domain: string): boolean {
46 if (host === null) return false;
47 const parsed = parseAppHost(host, domain);
48 return parsed !== undefined && parsed.slug === ARCADE_SLUG && parsed.env === "production" && !parsed.nested;
49 }
50
51 /** Where the Arcade is reached, as the runtime addresses an app. */
52 export function arcadeUrl(runtime: AppsRuntime): string {
53 const { scheme, publicPort, domain } = runtime.options;
54 const port =
55 publicPort === undefined || (scheme === "http" && publicPort === 80) || (scheme === "https" && publicPort === 443)
56 ? ""
57 : `:${publicPort}`;
58 return `${scheme}://${ARCADE_SLUG}${appHostSuffix(domain)}${port}`;
59 }
60
61 /** Whether the forge has an Arcade: apps run and a site admin's display is titled `arcade-builds`. */
62 export function arcadeOn(app: App): boolean {
63 return appsRuntime(app) !== undefined && arcadeRepoKnown(app) !== undefined;
64 }
65
66 /** The Arcade's address when the forge has one. */
67 export function arcadeHome(app: App): string | undefined {
68 const runtime = appsRuntime(app);
69 return runtime !== undefined && arcadeRepoKnown(app) !== undefined ? arcadeUrl(runtime) : undefined;
70 }
71
72 /** Where Launch goes for this display: its host, when it is the Arcade's shelf and apps run. */
73 export async function launchUrl(app: App, repo: Repo, config: Result<DisplayConfig, string> | undefined): Promise<string | undefined> {
74 if (config === undefined) return undefined;
75 if (!config.ok || config.value.layout !== "shelf") return undefined;
76 return (await arcadeRepoOf(app))?.id === repo.id ? arcadeHome(app) : undefined;
77 }
78
79 const file = (name: string): string => readFileSync(new URL(name, import.meta.url), "utf8");
80 let page: string | undefined;
81 let boot: string | undefined;
82 let template: Uint8Array | undefined;
83
84 /** The Arcade's page, with the house cover drawing spliced in. */
85 export function arcadePage(): string {
86 page ??= file("./arcade.html").replace("/*COVER_JS*/", () => COVER_JS);
87 return page;
88 }
89 export const bootScript = (): string => (boot ??= file("./jbox-spin.js"));
90 export const coverTemplateBytes = (): Uint8Array =>
91 (template ??= new Uint8Array(readFileSync(new URL("./cover-template.png", import.meta.url))));
92
93 const IMPORT_LINE = "import { jboxScale, playJboxSpin } from './boot/jbox-spin.js';";
94 const escapeText = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
95
96 /**
97 * The shelf page for a display: its title, its producer, and — for a page
98 * that carries its shelf inside it — the data, with the boot tag inline.
99 */
100 export function shelfPage(o: { title: string; producer: string | undefined; data?: unknown; tabTitle?: string; bar?: string }): string {
101 let out = arcadePage();
102 // The forge's own bar above the shelf (a writer's Edit display), outside the arcade's frame.
103 if (o.bar !== undefined) out = out.replace('<body>\n <header id="topline">', () => `<body>\n ${o.bar}\n <header id="topline">`);
104 const topline = o.producer === undefined ? o.title : `${o.producer} • ${o.title}`;
105 out = out
106 .replace("<title>Browser Arcade</title>", () => `<title>${escapeText(o.tabTitle ?? o.title)}</title>`)
107 .replace('<header id="topline">JBOX • BROWSER ARCADE</header>', () => `<header id="topline">${escapeText(topline.toUpperCase())}</header>`)
108 .replace('<button id="back" type="button">◀ ARCADE</button>', () => `<button id="back" type="button">◀ ${escapeText(o.title.toUpperCase())}</button>`);
109 if (o.data === undefined) return out;
110 if (!out.includes(IMPORT_LINE)) throw new Error("the Arcade page no longer imports the boot tag as expected");
111 // The boot tag goes inline, as plain script in the page's module.
112 out = out.replace(IMPORT_LINE, () => bootScript().replace(/^export /gm, ""));
113 // "<" escaped, so a cart's own </script> cannot end this script early.
114 const data = JSON.stringify(o.data).replace(/</g, "\\u003c");
115 return out.replace('<script type="module">', () => `<script>window.__ARCADE__ = ${data};</script>\n <script type="module">`);
116 }
117
118 /** The page's own policy: no third-party fonts, its inline module, carts in frames from itself. */
119 const PAGE_POLICY =
120 "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
121 "font-src 'none'; img-src 'self' data: blob:; frame-src 'self' blob:; media-src 'self' blob: data:; " +
122 "base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
123
124 const answer = (body: string | Uint8Array, type: string, extra: Record<string, string> = {}): Response =>
125 new Response(body, {
126 headers: {
127 "content-type": type,
128 "cache-control": "no-cache",
129 "x-content-type-options": "nosniff",
130 "referrer-policy": "no-referrer",
131 ...extra,
132 },
133 });
134
135 const missing = (): Response =>
136 new Response("not found", {
137 status: 404,
138 headers: { "content-type": "text/plain; charset=utf-8", "content-security-policy": "default-src 'none'", "x-content-type-options": "nosniff" },
139 });
140
141 export interface ArcadeDisplay {
142 readonly repo: Repo;
143 readonly config: Result<DisplayConfig, string>;
144 }
145
146 /**
147 * The Arcade's display, read as a stranger reads it; undefined when there is
148 * none to show. A site admin titling a display `arcade-builds` publishes it,
149 * as Showcase publishes a demo: a private display repository is served all
150 * the same, and its private sources stay unnamed.
151 */
152 export async function arcadeDisplay(app: App): Promise<ArcadeDisplay | undefined> {
153 const repo = await arcadeRepoOf(app);
154 if (repo === undefined) return undefined;
155 const config = await readDisplay(app, repo);
156 return config === undefined ? undefined : { repo, config };
157 }
158
159 /**
160 * A captured page any display may play: the pin at `oid` in that
161 * repository has its demo in the showcase and the page is captured, and
162 * the repository is public or the request carries a grant for this page.
163 */
164 async function servePlayable(app: App, path: string, grant: string | null): Promise<Response> {
165 const m = /^carts\/([^/]+)\/([^/]+)\/([0-9a-f]{40,64})\/([A-Za-z0-9._-]+)\.html$/.exec(path);
166 if (m === null) return missing();
167 const [, owner, name, oid, page] = m as unknown as [string, string, string, string, string];
168 const repo = getRepo(app.db, owner, name);
169 if (repo === undefined || !ownerInStanding(app.db, repo)) return missing();
170 if (!canRead(app.db, repo, undefined) && (grant === null || !verifyCart(app.config.dataDir, path, grant))) return missing();
171 const pins = await listBuildPins(repoDir(app.reposRoot, repo.ownerName, repo.name));
172 const pin = pins.find((p) => p.oid === oid && p.demo?.showcase === true && pageFile(p.demo.page) === page);
173 if (pin?.demo === undefined) return missing();
174 const html = readCapture(app.config.dataDir, repo, oid, pin.demo.page);
175 return html === undefined ? missing() : answer(html, "text/html; charset=utf-8", { "content-security-policy": cartAncestors(app) });
176 }
177
178 /**
179 * A published demo, by the display that shows it and its id there: its
180 * captured page, its cover or its page's picture. The display must be
181 * public or the Arcade's named display, or the request carry a grant for
182 * this path; the demo must be in the display's showcase now.
183 */
184 async function serveShown(app: App, path: string, grant: string | null): Promise<Response> {
185 const m = /^displays\/([^/]+)\/([^/]+)\/([A-Za-z0-9_-]{22})(\.html|\/cover\.png|\/art\.png)$/.exec(path);
186 if (m === null) return missing();
187 const [, owner, name, id, kind] = m as unknown as [string, string, string, string, string];
188 const display = getRepo(app.db, owner, name);
189 if (display === undefined || !ownerInStanding(app.db, display)) return missing();
190 const published = canRead(app.db, display, undefined) || (await arcadeRepoOf(app))?.id === display.id;
191 if (!published && (grant === null || !verifyCart(app.config.dataDir, path, grant))) return missing();
192 const config = await readDisplay(app, display);
193 if (config === undefined || !config.ok) return missing();
194 const item = await displayItem(app, display, config.value, id);
195 if (item === undefined) return missing();
196 const { repo, pin, demo } = item;
197 if (kind === "/cover.png") {
198 const bytes = demo.cover === undefined ? undefined : await readCover(repoDir(app.reposRoot, repo.ownerName, repo.name), demo.cover);
199 return bytes === undefined ? missing() : answer(bytes, coverType(bytes) ?? "application/octet-stream");
200 }
201 if (kind === "/art.png") {
202 const bytes = readArt(app.config.dataDir, repo, pin.oid, demo.page);
203 return bytes === undefined ? missing() : answer(bytes, "image/png");
204 }
205 const html = readCapture(app.config.dataDir, repo, pin.oid, demo.page);
206 return html === undefined ? missing() : answer(html, "text/html; charset=utf-8", { "content-security-policy": cartAncestors(app) });
207 }
208
209 /** A cart may frame only inside a display: this origin's or the forge's own. */
210 function cartAncestors(app: App): string {
211 const forge = appsRuntime(app)?.options.forgeUrl;
212 return `frame-ancestors 'self'${forge === undefined ? "" : ` ${new URL(forge).origin}`}`;
213 }
214
215 const errorPage = (message: string): Response =>
216 new Response(`<!doctype html><meta charset="utf-8"><title>Display</title><p>${escapeText(message)}</p>`, {
217 status: 422,
218 headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": "default-src 'none'", "x-content-type-options": "nosniff" },
outdated

decodeURIComponent throws on a malformed escape, which gives a 500 on a public host. Wrap it in try/catch and return missing().

219 });
220
221 /** Answer one request to the Arcade's host. */
222 export async function serveArcade(app: App, request: Request): Promise<Response> {
223 if (request.method !== "GET" && request.method !== "HEAD")
224 return new Response("method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
225 const url = new URL(request.url);
226 let path: string;
227 try {
228 path = decodeURIComponent(url.pathname.slice(1));
229 } catch {
230 return missing();
231 }
232 // Every display's carts play here, Arcade or not.
233 if (path.startsWith("carts/")) return servePlayable(app, path, url.searchParams.get("g"));
234 if (path.startsWith("displays/")) return serveShown(app, path, url.searchParams.get("g"));
235 const display = await arcadeDisplay(app);
236 if (display === undefined) return missing();
237 if (!display.config.ok) return errorPage(display.config.error);
238 const config = display.config.value;
239 // Grid and graph are drawn by the forge.
240 if (config.layout !== "shelf") {
241 const forge = appsRuntime(app)?.options.forgeUrl;
242 if (forge === undefined || (path !== "" && path !== "index.html")) return missing();
243 return new Response(null, { status: 303, headers: { location: `${forge.replace(/\/$/, "")}/${display.repo.ownerName}/${display.repo.name}/display` } });
244 }
245 if (path === "" || path === "index.html")
246 return answer(shelfPage({ title: config.title, producer: config.shelf.producer }), "text/html; charset=utf-8", { "content-security-policy": PAGE_POLICY });
247 if (path === "boot/jbox-spin.js") return answer(bootScript(), "text/javascript; charset=utf-8");
248 if (path === "cover-template.png") return answer(coverTemplateBytes(), "image/png");
249 const shelf: Shelf = shelfOf(await displayItems(app, display.repo, config, undefined), config.shelf.producer);
250 if (path === "games.json") return answer(JSON.stringify(shelf.games), "application/json");
251 // Export snapshot: the display's shelf, in one file that plays offline.
252 if (path === "snapshot.html") {
253 const made = await snapshotFile(app, config, shelf, new Date());
254 return answer(made.html, "text/html; charset=utf-8", { "content-disposition": `attachment; filename="${made.name}"` });
255 }
256 const entry = shelf.entries.get(path);
257 if (entry === undefined) return missing();
258 const { repo, pin, cart } = entry;
259 if (path.startsWith("labels/") && cart.cover !== undefined) {
260 const bytes = await readCover(repoDir(app.reposRoot, repo.ownerName, repo.name), cart.cover);
261 return bytes === undefined ? missing() : answer(bytes, coverType(bytes) ?? "application/octet-stream");
262 }
263 if (path.startsWith("art/")) {
264 const bytes = readArt(app.config.dataDir, repo, pin.oid, cart.page);
265 return bytes === undefined ? missing() : answer(bytes, "image/png");
266 }
267 return missing();
268 }
src/arcade/jbox-spin.js added +151
@@ -0,0 +1,151 @@
1 // The jbox boot tag: "L · jbox green, spin", confirmed in
2 // Arcade records/design/1026-loading-tags. A Game Boy green cube spins two and three
3 // quarter turns counterclockwise and settles with the J in front, lighting its groove in
4 // three beats to a rising power-on chord. Drawn at 160x144 and scaled by whole pixels.
5 //
6 // playJboxSpin(canvas) runs it once and resolves when the chord has rung out.
7
8 // The tag: a 160x144 buffer, ray-cast per pixel, scaled up by whole pixels.
9 const VW = 160, VH = 144, SC = 28;
10 let x, img, buf;
11 const mask = new Uint8Array(VW * VH);
12 const rgb = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4), 16)];
13 const pack = ([r, g, b], k = 1) => 0xff000000 | Math.min(255, b * k) << 16 | Math.min(255, g * k) << 8 | Math.min(255, r * k);
14 // Game Boy greens. Faces pick a shade by how much light they catch.
15 const BG = pack(rgb("9bbc0f")), INK = pack(rgb("0f380f")), LIP = INK, LIT = pack(rgb("e0f8a0"));
16 const SHADES = [["8bac0f", "c4e050", "306230"], ["6a8c1c", "a0c030", "306230"], ["306230", "5a8a28", "1d4a1d"]].map((r) => r.map((h) => pack(rgb(h))));
17
18 // M's line: one unbroken groove from mid-face on the right, across the
19 // left face and the next side round, over the top and down into the J.
20 // Beat one lights the first two sides, beat two the third side and the
21 // top, beat three the J. Cells are [col, row].
22 const PATHS = [
23 ["z-", [[5, 5], [7, 5], [7, 2], [3, 2], [3, 7], [0, 7]]],
24 ["x-", [[9, 7], [6, 7], [6, 3], [2, 3], [2, 6], [0, 6]]],
25 ["z+", [[9, 6], [7, 6], [7, 8], [3, 8], [3, 4], [6, 4], [6, 0]]],
26 ["y+", [[3, 0], [3, 5], [7, 5], [7, 2], [9, 2]]],
27 ["x+", [[7, 0], [7, 8], [2, 8], [2, 5]]],
28 ];
29 const cells = {}, SEC = { "z-": 0, "x-": 0, "z+": 1, "y+": 1, "x+": 2 };
30 for (const [f, pts] of PATHS) for (let i = 1; i < pts.length; i++) {
31 let [a, b] = pts[i - 1]; const [ea, eb] = pts[i];
32 for (;;) { cells[f + a + "," + b] = 1; if (a === ea && b === eb) break; a += Math.sign(ea - a); b += Math.sign(eb - b); }
33 }
34 const on = (f, a, b) => a < 0 || b < 0 || f + a + "," + b in cells;
35
36 const PITCH = Math.PI / 6, SP = Math.sin(PITCH), CP = Math.cos(PITCH), L = [-0.5, 0.83, -0.17];
37 function toObj([a, b, z], yaw) {
38 const y1 = b * CP - z * SP, z1 = b * SP + z * CP, cs = Math.cos(-yaw), sn = Math.sin(-yaw);
39 return [a * cs - z1 * sn, y1, a * sn + z1 * cs];
40 }
41 const toWorld = ([a, b, z], yaw) => [a * Math.cos(yaw) - z * Math.sin(yaw), b, a * Math.sin(yaw) + z * Math.cos(yaw)];
42 function hitBox(o, d) {
43 let tn = -1e9, tf = 1e9, ax = 0, sg = 0;
44 for (let i = 0; i < 3; i++) {
45 if (Math.abs(d[i]) < 1e-9) { if (Math.abs(o[i]) > 1) return null; continue; }
46 let t1 = (-1 - o[i]) / d[i], t2 = (1 - o[i]) / d[i], s = -1;
47 if (t1 > t2) { const q = t1; t1 = t2; t2 = q; s = 1; }
48 if (t1 > tn) { tn = t1; ax = i; sg = s; }
49 if (t2 < tf) tf = t2;
50 if (tn > tf) return null;
51 }
52 return { ax, sg, p: [o[0] + d[0] * tn, o[1] + d[1] * tn, o[2] + d[2] * tn] };
53 }
54 function uv(ax, sg, p) {
55 const cl = (q) => Math.min(0.9999, Math.max(0, q));
56 if (ax === 1) return [cl((p[0] + 1) / 2), cl((1 - p[2]) / 2)];
57 return [cl(((ax === 0 ? p[2] * sg : -p[0] * sg) + 1) / 2), cl((1 - p[1]) / 2)];
58 }
59
60 // Shade row by light: [face, flashed face, empty groove].
61 function paint(f, u, v, b, lit, flash) {
62 const row = SHADES[b >= 0.95 ? 0 : b >= 0.7 ? 1 : 2], iu = u * 10 | 0, iv = v * 10 | 0;
63 if (!cells[f + iu + "," + iv]) return row[SEC[f] === flash ? 1 : 0];
64 if ((!on(f, iu, iv - 1) && v * 10 - iv < 0.3) || (!on(f, iu - 1, iv) && u * 10 - iu < 0.3)) return LIP;
65 return SEC[f] < lit ? LIT : row[2];
66 }
67 function render(yaw, rev, flash) {
68 buf.fill(BG); mask.fill(0);
69 const ex = toObj([1, 0, 0], yaw), ey = toObj([0, 1, 0], yaw), d = toObj([0, 0, 1], yaw), shade = {};
70 for (const [f, w] of [["x+", [1, 0, 0]], ["x-", [-1, 0, 0]], ["z+", [0, 0, 1]], ["z-", [0, 0, -1]], ["y+", [0, 1, 0]]]) {
71 const q = toWorld(w, yaw);
72 shade[f] = Math.min(1, Math.round((0.4 + 0.7 * Math.max(0, q[0] * L[0] + q[1] * L[1] + q[2] * L[2])) * 8) / 8);
73 }
74 for (let py = 0; py < VH; py++) for (let px = 0; px < VW; px++) {
75 const X = (px + 0.5 - VW / 2) / SC, Y = -(py + 0.5 - VH / 2) / SC, i = py * VW + px;
76 const h = hitBox([ex[0] * X + ey[0] * Y - d[0] * 10, ex[1] * X + ey[1] * Y - d[1] * 10, ex[2] * X + ey[2] * Y - d[2] * 10], d);
77 if (!h) continue;
78 const f = "xyz"[h.ax] + (h.sg > 0 ? "+" : "-"), [u, v] = uv(h.ax, h.sg, h.p);
79 mask[i] = 1; buf[i] = paint(f, u, v, shade[f] ?? 0.4, rev, flash);
80 }
81 for (let i = 0; i < mask.length; i++) if (!mask[i] && (mask[i - 1] || mask[i + 1] || mask[i - VW] || mask[i + VW])) buf[i] = INK;
82 x.putImageData(img, 0, 0);
83 }
84
85 // A gentle three quarter turn counterclockwise, eased in and out, that stops
86 // with the J face in front on the right. It settles before the first beat,
87 // so each beat lights a face that is in view.
88 const FPS = 20, SPIN = 0.9, BEATS = [0.95, 1.25, 1.55];
89 const yawAt = (t) => { const k = Math.min(t / SPIN, 1); return Math.PI / 4 + 1.5 * Math.PI * k * k * (3 - 2 * k); };
90 function frame(t) {
91 const lit = BEATS.filter((b) => t >= b).length;
92 render(yawAt(t), lit, lit && t < BEATS[lit - 1] + 0.1 ? lit - 1 : -1);
93 }
94
95 let A;
96 function tone(f, at, dur, gain, type = "triangle", f2) {
97 const o = A.createOscillator(), g = A.createGain(), s = A.currentTime + at;
98 o.type = type; o.frequency.setValueAtTime(f, s);
99 if (f2) o.frequency.exponentialRampToValueAtTime(f2, s + dur);
100 g.gain.setValueAtTime(0.001, s); g.gain.exponentialRampToValueAtTime(gain, s + 0.02);
101 g.gain.exponentialRampToValueAtTime(0.001, s + dur);
102 o.connect(g).connect(A.destination); o.start(s); o.stop(s + dur);
103 }
104 function sound() {
105 // I's power-on, the standard: a low hum that rises, then each beat stacks
106 // one more held note into a chord that rings out together.
107 tone(55, 0, 0.55, 0.24, "triangle", 110);
108 const END = BEATS[2] + 0.9;
109 tone(131, BEATS[0], END - BEATS[0], 0.1);
110 tone(196, BEATS[1], END - BEATS[1], 0.08);
111 tone(262, BEATS[2], END - BEATS[2], 0.07); tone(392, BEATS[2], END - BEATS[2], 0.025, "square");
112 }
113
114
115 /** Seconds from start until the chord has rung out. */
116 export const JBOX_SPIN_LENGTH = BEATS[2] + 0.9;
117
118 /**
119 * Play the tag once on `canvas`. Call it from a click or key press, so the browser lets
120 * the sound play. Resolves when the tag is done.
121 */
122 export function playJboxSpin(canvas, { sound: withSound = true } = {}) {
123 canvas.width = VW;
124 canvas.height = VH;
125 x = canvas.getContext("2d");
126 img = x.createImageData(VW, VH);
127 buf = new Uint32Array(img.data.buffer);
128 if (withSound) {
129 try {
130 A = A || new AudioContext();
131 A.resume();
132 sound();
133 } catch {}
134 }
135 const t0 = performance.now();
136 let last = -1;
137 return new Promise((done) => {
138 (function loop(now) {
139 const t = (now - t0) / 1000;
140 const step = Math.max(0, Math.floor(t * FPS));
141 if (step !== last) { last = step; frame(Math.min(step / FPS, SPIN + 1)); }
142 if (t >= JBOX_SPIN_LENGTH) return done();
143 requestAnimationFrame(loop);
144 })(t0);
145 });
146 }
147
148 /** Whole-pixel scale that fits the tag in a box. */
149 export function jboxScale(width, height) {
150 return Math.max(1, Math.floor(Math.min(width / VW, height / VH)));
151 }
src/arcade/snapshot.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * Export snapshot: a display's shelf in one offline HTML file, as
3 * browser-arcade's `bun run bundle` made it — the shelf, every cart and
4 * every cover inside the page, so it plays from disk with no server. Its
5 * one top line says it is a dated copy that will never update.
6 */
7
8 import type { App } from "../app.ts";
9 import type { DisplayConfig } from "../display/config.ts";
10 import { readCover } from "../git/builds.ts";
11 import { repoDir } from "../git/repos.ts";
12 import { readArt, readCapture } from "./carts.ts";
13 import { coverTemplateBytes, shelfPage } from "./host.ts";
14 import { slug, type Shelf } from "./shelf.ts";
15
16 /** `yyyymmdd` for a day. */
17 export const snapshotDate = (at: Date): string => at.toISOString().slice(0, 10).replaceAll("-", "");
18
19 /** The line across the top of a snapshot. */
20 export const snapshotLine = (date: string, producer: string | undefined): string =>
21 `${producer === undefined ? "" : `${producer.toUpperCase()} • `}snapshot ${date} | This is an HTML file that doesn't receive updates`;
22
23 const dataUrl = (bytes: Uint8Array): string => {
24 const type =
25 bytes[0] === 0xff ? "image/jpeg" : bytes[0] === 0x52 ? "image/webp" : "image/png";
26 return `data:${type};base64,${Buffer.from(bytes).toString("base64")}`;
27 };
28
29 /** The snapshot file for a display's shelf as it stands. */
30 export async function snapshotFile(
31 app: App,
32 config: Pick<DisplayConfig, "title" | "shelf">,
33 shelf: Shelf,
34 at: Date,
35 ): Promise<{ name: string; html: string }> {
36 const date = snapshotDate(at);
37 const dataDir = app.config.dataDir;
38 const carts: Record<string, string> = {};
39 const labels: Record<string, string> = { "cover-template.png": dataUrl(coverTemplateBytes()) };
40 for (const g of shelf.games)
41 for (const b of g.builds) {
42 const entry = shelf.entries.get(b.file);
43 if (entry === undefined) continue;
44 const { repo, pin, cart } = entry;
45 const html = readCapture(dataDir, repo, pin.oid, cart.page);
46 if (html !== undefined) carts[b.file] = new TextDecoder().decode(html);
47 if (b.label !== undefined && cart.cover !== undefined) {
48 const bytes = await readCover(repoDir(app.reposRoot, repo.ownerName, repo.name), cart.cover);
49 if (bytes !== undefined) labels[b.label] = dataUrl(bytes);
50 }
51 if (b.art !== undefined) {
52 const bytes = readArt(dataDir, repo, pin.oid, cart.page);
53 if (bytes !== undefined) labels[b.art] = dataUrl(bytes);
54 }
55 }
56 const html = shelfPage({
57 title: config.title,
58 tabTitle: `${config.title} · snapshot ${date}`,
59 producer: config.shelf.producer,
60 data: { snapshot: snapshotLine(date, config.shelf.producer), games: shelf.games, carts, labels },
61 });
62 return { name: `${slug(config.title)}-${date}.html`, html };
63 }
src/boards/guide.ts +1 −2
@@ -227,8 +227,7 @@ export function parseSentence(text: string, now: number): Record<string, string>
227227 else if (/\byesterday\b/.test(t)) {
228228 out.from = since(Date.parse(startOfToday(now)), 1);
229229 out.to = startOfToday(now);
230 }
231 else if (/\b(today|so far today)\b/.test(t)) out.from = startOfToday(now);
230 } else if (/\b(today|so far today)\b/.test(t)) out.from = startOfToday(now);
232231 else if (/\b(a week|weekly)\b/.test(t)) days = 7;
233232 }
234233 if (days !== undefined) out.from = since(now, days);
src/display/launch.ts +89 −5
@@ -1,13 +1,97 @@
11 /**
2 * Where a display item opens, as far as main reads it: a pin's screenshots
3 * on its own repository. Jay Moran's launch.ts (`origin/incoming/jay-main`)
4 * also signs grants for captured pages on the display origin and names the
5 * covers and downloads; those wait for the Arcade host and for `pin_demos.ts`.
2 * Where a display item opens. A `file` demo plays on the display origin,
3 * `arcade.<app domain>`, never on the forge's: a captured page is a
4 * stranger's code. A page in a repository anyone may read plays at its
5 * plain address; one in a repository only the viewer may read carries a
6 * short grant the forge signed for that page alone (`signCart`).
7 *
8 * An item the viewer cannot read the repository of (`open: false`) is
9 * reached only through its display, by its opaque id: on the forge under
10 * `/<owner>/<display>/display/demo/<id>/…`, and on the display origin at
11 * `displays/<owner>/<display>/<id>.html`. Showcase published it, so it
12 * plays without a grant when the display is public; a private display's
13 * page carries a grant for that path.
614 */
715
8 import type { Repo } from "../core/repos.ts";
16 import { createHmac, timingSafeEqual } from "node:crypto";
17 import type { App } from "../app.ts";
18 import { appsRuntime } from "../apps/runtime.ts";
19 import { arcadeUrl } from "../arcade/host.ts";
20 import { cartFile, shownFile } from "../arcade/shelf.ts";
21 import { canRead, type Repo } from "../core/repos.ts";
922 import type { BuildPin } from "../git/builds.ts";
23 import { loadKey } from "../kernel/secret.ts";
24 import type { DisplayItem } from "./items.ts";
25
26 /** How long a grant to play a private page lasts. */
27 export const CART_GRANT_MS = 60 * 60 * 1000;
28
29 const mac = (dataDir: string, payload: string): string =>
30 createHmac("sha256", loadKey(dataDir)).update(`cart:${payload}`).digest("base64url");
31
32 /** A grant to play the capture at `path` (`carts/<owner>/<repo>/<sha>/<page>.html`) until it expires. */
33 export function signCart(dataDir: string, path: string, now = Date.now()): string {
34 const expires = now + CART_GRANT_MS;
35 return `${expires}.${mac(dataDir, `${path}:${expires}`)}`;
36 }
37
38 /** Whether `grant` vouches for `path` now. */
39 export function verifyCart(dataDir: string, path: string, grant: string, now = Date.now()): boolean {
40 const dot = grant.indexOf(".");
41 const expires = grant.slice(0, dot);
42 if (dot === -1 || !/^\d{1,15}$/.test(expires) || Number(expires) <= now) return false;
43 const a = Buffer.from(grant.slice(dot + 1));
44 const b = Buffer.from(mac(dataDir, `${path}:${expires}`));
45 return a.length === b.length && timingSafeEqual(a, b);
46 }
47
48 /** The display origin, or undefined where no apps run. */
49 export function displayOrigin(app: App): string | undefined {
50 const runtime = appsRuntime(app);
51 return runtime === undefined ? undefined : arcadeUrl(runtime);
52 }
53
54 type Hidden = Pick<DisplayItem, "display" | "id">;
55
56 /** An item's own routes on the forge, under its display. */
57 export const shownBase = (item: Hidden): string => `/${item.display.ownerName}/${item.display.name}/display/demo/${item.id}`;
58
59 /** Where a captured demo plays, or undefined where no apps run or nothing is captured. */
60 export function playUrl(app: App, item: DisplayItem): string | undefined {
61 if (item.captured === undefined) return undefined;
62 if (item.open) return pinPlayUrl(app, item.repo, item.pin.oid, item.demo.page);
63 const origin = displayOrigin(app);
64 if (origin === undefined) return undefined;
65 const path = shownFile(item);
66 const plain = canRead(app.db, item.display, undefined);
67 return `${origin}/${path}${plain ? "" : `?g=${signCart(app.config.dataDir, path)}`}`;
68 }
69
70 /**
71 * Where a commit's captured page plays on the display origin, read through
72 * its own repository: plain when anyone may read it, else with a grant.
73 * The caller says it is captured (`captureState`).
74 */
75 export function pinPlayUrl(app: App, repo: Repo, oid: string, page: string): string | undefined {
76 const origin = displayOrigin(app);
77 if (origin === undefined) return undefined;
78 const path = cartFile(repo, oid, page);
79 return `${origin}/${path}${canRead(app.db, repo, undefined) ? "" : `?g=${signCart(app.config.dataDir, path)}`}`;
80 }
81
82 /** Where a captured demo downloads, on the forge. */
83 export const downloadUrl = (item: DisplayItem): string =>
84 item.open
85 ? `/${item.repo.ownerName}/${item.repo.name}/builds/${item.pin.oid}/download?page=${encodeURIComponent(item.demo.page)}`
86 : `${shownBase(item)}/download`;
1087
1188 /** A pin's screenshots, on its repository. */
1289 export const repoShotsUrl = (repo: Pick<Repo, "ownerName" | "name">, pin: Pick<BuildPin, "label">): string =>
1390 `/${repo.ownerName}/${repo.name}/builds/${encodeURIComponent(pin.label)}/shots`;
91
92 /** A demo's screenshots, on the forge. */
93 export const shotsUrl = (item: DisplayItem): string => (item.open ? repoShotsUrl(item.repo, item.pin) : `${shownBase(item)}/shots`);
94
95 /** A demo's cover, or the picture of its captured page, on the forge. */
96 export const coverUrl = (item: DisplayItem): string =>
97 item.open ? `/${item.repo.ownerName}/${item.repo.name}/builds/${encodeURIComponent(item.pin.label)}/cover` : `${shownBase(item)}/cover`;
src/git/builds.ts +9
@@ -647,6 +647,15 @@ export async function storeCover(dir: string, label: string, bytes: Uint8Array):
647647 return kept.code === 0 ? ok(oid) : err("git_failed");
648648 }
649649
650 /** A stored cover's bytes, read only through a pin that names it. */
651 export async function readCover(dir: string, oid: string): Promise<Uint8Array | undefined> {
652 if (!isSafeOid(oid)) return undefined;
653 const kind = await runGitCapture(["cat-file", "-t", oid], { cwd: dir });
654 if (kind.code !== 0 || kind.stdout.trim() !== "blob") return undefined;
655 const bytes = await runGitRaw(["cat-file", "blob", oid], { cwd: dir });
656 return bytes.ok ? bytes.value : undefined;
657 }
658
650659 /**
651660 * Every pin, newest first, read in one `for-each-ref`; or the one `label`.
652661 * A pin's note is its notes file on `refs/forge/notes` when it has one,
src/http/server.ts +5
@@ -521,6 +521,7 @@ import { Router } from "./router.ts";
521521 import { getUserById } from "../core/users.ts";
522522 import { appsRuntime } from "../apps/runtime.ts";
523523 import { holdAppHost } from "../apps/holding.ts";
524 import { isArcadeHost, serveArcade } from "../arcade/host.ts";
524525 import {
525526 appWebSocket,
526527 type AppSocket,
@@ -2406,6 +2407,10 @@ export async function serve(app: App): Promise<Servers> {
24062407 // must not take `<app>--<n>--<parentHost>`. Only its `"unknown"` falls
24072408 // through, since it reads every `--<its host>` label as its own slug.
24082409 let ownUnknown = false;
2410 // The Arcade's origin plays third-party carts; it is answered here, on
2411 // its own host, and nothing of the forge's is served on it.
2412 if (runtime !== undefined && isArcadeHost(request.headers.get("host"), runtime.options.domain))
2413 return securityFloor(await serveArcade(app, request));
24092414 if (runtime !== undefined) {
24102415 const target = resolveAppHost(app, runtime.options.domain, request);
24112416 // A preview's headers are the app's business: what `proxyApp`
src/tui/screens/display.ts +4 −1
@@ -15,6 +15,7 @@ import { selectionKeys } from "../../display/items.ts";
1515
1616 import { canRead, type Repo } from "../../core/repos.ts";
1717 import { displayItems, type DisplayItem } from "../../display/items.ts";
18 import { launchUrl } from "../../arcade/host.ts";
1819 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
1920 import type { Ctx, Screen } from "../app/app.ts";
2021 import { binding } from "../app/keys.ts";
@@ -39,6 +40,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3940 let choices: string[] | undefined;
4041 const ticked = new Set<string>();
4142 let note = "";
43 let launch: string | undefined;
4244 const state = new ListState();
4345 // What each build reads as, by key, so a choice shows its name.
4446 const named = new Map<string, string>();
@@ -51,6 +53,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5153 lines = config.ok ? items.map(itemLine) : [config.error];
5254 named.clear();
5355 for (const it of items) named.set(it.key, itemLine(it));
56 launch = await launchUrl(f.app, repo, config);
5457 choices = undefined;
5558 if (config.ok && writesDisplay(f.app, repo, f.user)) {
5659 const chosen = await selectionKeys(f.app, config.value, f.user);
@@ -76,7 +79,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7679 const line = (y: number, text: string, fg = c.fg, x = 1) => s.text(area.x + x, area.y + y, truncate(text, Math.max(1, area.w - x)), style(fg, c.bg), right);
7780 if (missing) return void line(0, NO_DISPLAY_LINE);
7881 if (head === undefined) return void line(0, "Reading…");
79 line(0, head);
82 line(0, launch === undefined ? head : `${head} · Launch: ${launch}/`);
8083 const shown = choices === undefined ? lines : choices.map((k) => `[${ticked.has(k) ? "x" : " "}] ${named.get(k) ?? k}`);
8184 if (note !== "") line(1, note, c.mutedFg);
8285 if (shown.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg);
src/web/pages/display.ts +7 −2
@@ -3,8 +3,9 @@
33 * `display.json` holds (`display/config.ts`), as whoever is looking may read
44 * them; a build whose repository the viewer cannot read shows its name and
55 * version alone. A writer also gets a form to choose the builds, which works
6 * without script (POST here) and, with script, sends the API's PUT. Editing
7 * the title, covers and the player wait. A bad `display.json` shows its error.
6 * without script (POST here) and, with script, sends the API's PUT. Launch
7 * opens the Arcade host. Editing the title and covers wait. A bad
8 * `display.json` shows its error.
89 */
910
1011 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -14,6 +15,7 @@ import { writesDisplay } from "../../display/selections.ts";
1415 import { exactRefOid } from "../../git/branches.ts";
1516 import { repoDir } from "../../git/repos.ts";
1617 import { INVALID_TITLE, displayOf } from "../../display/known.ts";
18 import { launchUrl } from "../../arcade/host.ts";
1719 import { repoShotsUrl } from "../../display/launch.ts";
1820 import type { Ctx } from "../../http/context.ts";
1921 import { crumbsFor, forbiddenPage, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
@@ -44,6 +46,8 @@ export async function displayPage(ctx: Ctx): Promise<Response> {
4446 const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo));
4547 if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
4648 const items = config.ok ? await timePhaseAsync(ctx.request, "items", () => displayItems(ctx.app, repo, config.value, ctx.auth.user)) : [];
49 // The Arcade host plays only the display titled `arcade-builds`; Launch is offered on that one.
50 const launch = await launchUrl(ctx.app, repo, config);
4751 const writes = config.ok && writesDisplay(ctx.app, repo, ctx.auth.user);
4852 const chosen = writes && config.ok ? await selectionKeys(ctx.app, config.value, ctx.auth.user) : [];
4953 const choices = writes && config.ok ? await choosableBuilds(ctx.app, config.value, chosen, ctx.auth.user) : [];
@@ -52,6 +56,7 @@ export async function displayPage(ctx: Ctx): Promise<Response> {
5256 const body = html`${repoHeader(ctx, repo, "code", { compact: true })}
5357 <div class="page-head mt">
5458 <h2>${title}</h2>
59 ${launch === undefined ? "" : html`<a class="btn" data-by="display-launch" href="${launch}/">Launch</a>`}
5560 <a href="/displays">All displays</a>
5661 </div>
5762 ${!config.ok
test/arcade-host.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Launching a display: the display page offers Launch for the Arcade's
3 * display, and the Arcade host (`arcade.<app domain>`) lists its carts,
4 * carries a policy with no third-party origin, and plays only what the
5 * display shows.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { importCart } from "../src/arcade/import.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { signCart } from "../src/display/launch.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19
20 const DOMAIN = "apps.test";
21 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5]);
22 const tagger = { name: "Jay Moran", email: "[email protected]" };
23
24 describe("the Arcade host", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let sha = "";
30
31 const arcade = (path: string, init: RequestInit = {}) =>
32 fetch(`${servers.url}${path}`, { ...init, headers: { host: `arcade.${DOMAIN}`, ...(init.headers ?? {}) }, redirect: "manual" });
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-arcade-host-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 await new AppsRuntime(app, { domain: DOMAIN, scheme: "http", publicPort: servers.port, forgeUrl: servers.url }).start();
39 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
40 if (!made.ok) throw new Error(made.error);
41 alice = made.value;
42 for (const name of ["karts", "arcade-display"]) {
43 const r = await createRepo(app, alice, { name, visibility: "public" });
44 if (!r.ok) throw new Error(String(r.error));
45 }
46 const repo = (await import("../src/core/repos.ts")).getRepo(app.db, "alice", "karts")!;
47 const c = await commitToBranch(app, alice, repo, { message: "one", files: [{ path: "a.txt", content: "a" }] });
48 if (!c.ok) throw new Error(JSON.stringify(c.error));
49 sha = c.value.commitOid;
50 const imported = await importCart(dataDir, { repo: "alice/karts", label: "iter-1", sha, note: "n", cart: { name: "fiesta-kart", version: "1.0", page: "/", shelf: true }, html: new TextEncoder().encode("<p>fiesta</p>"), cover: PNG, art: PNG }, tagger);
51 if (!imported.ok) throw new Error(JSON.stringify(imported.error));
52 const display = (await import("../src/core/repos.ts")).getRepo(app.db, "alice", "arcade-display")!;
53 const d = await commitToBranch(app, alice, display, {
54 message: "display",
55 files: [{ path: "display.json", content: JSON.stringify({ title: "arcade-builds", layout: "shelf", sources: ["alice/karts"], shelf: { producer: "jbox" } }) }],
56 });
57 if (!d.ok) throw new Error(JSON.stringify(d.error));
58 });
59
60 afterAll(async () => {
61 app.close();
62 await servers.stop?.();
63 await rm(dataDir, { recursive: true, force: true });
64 });
65
66 test("the display page links Launch to the host", async () => {
67 const cookie = `forge_session=${createSession(app.db, alice.id).token}`;
68 const res = await fetch(`${servers.url}/alice/arcade-display/display`, { headers: { cookie } });
69 const body = await res.text();
70 expect(res.status).toBe(200);
71 expect(body).toContain(`href="http://arcade.${DOMAIN}:${servers.port}/"`);
72 expect(body).toContain("Launch");
73 });
74
75 test("the host page carries the floor headers and a policy with no third-party origin", async () => {
76 const res = await arcade("/");
77 expect(res.status).toBe(200);
78 const csp = res.headers.get("content-security-policy") ?? "";
79 expect(csp).toContain("default-src 'self'");
80 expect(csp).toContain("frame-ancestors 'self'");
81 expect(csp).not.toMatch(/https?:\/\//);
82 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
83 expect(res.headers.get("referrer-policy")).toBe("no-referrer");
84 const page = await res.text();
85 expect(page).not.toContain("fonts.googleapis.com");
86 expect(page).toContain("<noscript>");
87 expect(page).toContain('id="prev"');
88 expect(page).toContain("hint-touch");
89 });
90
91 test("the host lists the display's carts and serves their pages", async () => {
92 const games = (await (await arcade("/games.json")).json()) as { name: string; builds: { file: string }[] }[];
93 expect(games.map((g) => g.name)).toEqual(["fiesta-kart"]);
94 const file = games[0]!.builds[0]!.file;
95 const played = await arcade(`/${file}`);
96 expect(played.status).toBe(200);
97 expect(await played.text()).toBe("<p>fiesta</p>");
98 expect(played.headers.get("content-security-policy")).toContain("frame-ancestors");
99 });
100
101 test("the host answers only reads, and nothing outside the display", async () => {
102 expect((await arcade("/", { method: "POST" })).status).toBe(405);
103 expect((await arcade("/carts/alice/karts/" + "0".repeat(40) + "/index.html")).status).toBe(404);
104 expect((await arcade("/settings")).status).toBe(404);
105 });
106
107 test("a malformed escape in the path is a 404, not a 500", async () => {
108 expect((await arcade("/%E0%A4%A")).status).toBe(404);
109 });
110
111 test("an unknown path is a 404", async () => {
112 expect((await arcade("/nothing/here")).status).toBe(404);
113 });
114
115 describe("a grant (?g=)", () => {
116 const path = "carts/alice/karts/" + "0".repeat(40) + "/index.html";
117 test("a good grant reaches the lookup (still 404: no such pin), a bad one never does", async () => {
118 const good = signCart(dataDir, path);
119 expect((await arcade(`/${path}?g=${encodeURIComponent(good)}`)).status).toBe(404);
120 });
121 test("an expired grant is refused", async () => {
122 const { verifyCart } = await import("../src/display/launch.ts");
123 const old = signCart(dataDir, path, Date.now() - 2 * 60 * 60 * 1000);
124 expect(verifyCart(dataDir, path, old)).toBe(false);
125 });
126 test("a grant for another path is refused", async () => {
127 const { verifyCart } = await import("../src/display/launch.ts");
128 expect(verifyCart(dataDir, path, signCart(dataDir, "carts/alice/karts/other.html"))).toBe(false);
129 expect(verifyCart(dataDir, path, signCart(dataDir, path))).toBe(true);
130 });
131 test("a tampered or malformed grant is refused", async () => {
132 const { verifyCart } = await import("../src/display/launch.ts");
133 const good = signCart(dataDir, path);
134 const flipped = good.slice(0, -1) + (good.endsWith("0") ? "1" : "0");
135 expect(verifyCart(dataDir, path, flipped)).toBe(false);
136 expect(verifyCart(dataDir, path, "nodot")).toBe(false);
137 expect(verifyCart(dataDir, path, "99999999999999." + good.split(".")[1])).toBe(false);
138 });
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs