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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9468-a-display-launches-into-the-arcade-host → main 8 commits fork 2 threads · 2 unresolved
14 files
changed +1089
−15
docs/design/areas/display.md +4 −2
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | # Display |
| 2 | 2 | |
| 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`. | |
| 4 | 4 | |
| 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) | ||
| 9 | 9 | |
| 10 | 10 | - **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. |
| 11 | 11 | |
| 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 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/apps/cloud.ts", |
| 3 | "src/arcade/cover.ts", | |
| 4 | "src/arcade/shelf.ts", | |
| 5 | 3 | "src/boards/machine.ts", |
| 6 | 4 | "src/crew/governor_log.ts", |
| 7 | 5 | "src/crew/tries.ts", |
src/arcade/arcade.html added +345
| @@ -0,0 +1,345 @@ | ||
| 1 | ||
| 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 | : #120a26; | |
| 11 | : #fff6e0; | |
| 12 | : #ffd200; | |
| 13 | : #9bbc0f; | |
| 14 | : #08040f; | |
| 15 | : "Press Start 2P", ui-monospace, Menlo, monospace; | |
| 16 | } | |
| 17 | * { box-sizing: border-box; } | |
| 18 | html, body { margin: 0; min-height: 100%; color: var(); font: 11px/1.5 var(); 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(); font-size: 10px; color: var(); | |
| 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(); border: 5px solid var(); | |
| 39 | box-shadow: inset 6px 6px 0 var(), inset -6px -6px 0 var(), 8px 8px 0 var(); } | |
| 40 | .shell::before { content: ""; position: absolute; left: 50%; top: 4px; width: 46px; height: 5px; margin-left: -23px; background: var(); box-shadow: 0 0 0 2px var(); } | |
| 41 | .screw { position: absolute; bottom: 52px; width: 9px; height: 9px; border-radius: 50%; background: var(); box-shadow: 0 0 0 2px var(), inset -2px -2px 0 var(); } | |
| 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(); | |
| 44 | background: repeating-linear-gradient(0deg, var() 0 5px, var() 5px 7px, var() 7px 10px); } | |
| 45 | .sticker { position: relative; aspect-ratio: 1; background: #000; border: 5px solid #fff; box-shadow: 0 0 0 4px var(); 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(), -1px -1px 0 var(); background: linear-gradient(transparent, rgba(0,0,0,.75)); text-align: left; } | |
| 49 | .cart.loading { top: 230px; transform: translateZ(0) scale(.92) ; } | |
| 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(); | |
| 54 | box-shadow: inset 0 8px 0 #6a6488, inset 0 -10px 0 #1c1828, 10px 10px 0 var(); } | |
| 55 | .console .slot { position: absolute; left: 50%; top: -6px; width: 250px; height: 22px; margin-left: -125px; background: #05030a; border: 6px solid var(); border-top: 0; } | |
| 56 | .row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } | |
| 57 | .row + .row { margin-top: 16px; } | |
| 58 | .badge { font: 16px "Bungee", sans-serif; letter-spacing: 5px; color: #b8b0d8; text-shadow: 2px 2px 0 var(); } | |
| 59 | .led { width: 14px; height: 14px; border-radius: 50%; background: #5a1010; box-shadow: 0 0 0 3px var(); } | |
| 60 | .led.on { background: #ff3a3a; box-shadow: 0 0 0 3px var(), 0 0 14px #ff3a3a; } | |
| 61 | .led.go { background: #3aff6a; box-shadow: 0 0 0 3px var(), 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(); } | |
| 63 | .lcd { flex: 1; min-width: 220px; background: #9bbc0f; color: #0f380f; border: 5px solid var(); 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(); box-shadow: inset 3px 3px 0 #55507a, 4px 4px 0 var(); } | |
| 67 | .switch[aria-pressed="true"] { background: var(); color: var(); box-shadow: inset 3px 3px 0 #fff2a0, 2px 2px 0 var(); transform: translate(2px, 2px); } | |
| 68 | .big { cursor: pointer; padding: 14px 18px; font-size: 12px; border: 5px solid var(); 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(); } | |
| 70 | .eject { background: #c8c0e0; color: var(); box-shadow: inset 4px 4px 0 #fff, inset -4px -4px 0 #8a80a8, 5px 5px 0 var(); } | |
| 71 | .big:active { transform: translate(4px, 4px); box-shadow: none; } | |
| 72 | .switch:focus-visible, .big:focus-visible, .cart:focus-visible { outline: 4px solid var(); outline-offset: 4px; } | |
| 73 | footer { position: relative; text-align: center; color: var(); 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(); border: 4px solid var(); box-shadow: 3px 3px 0 var(); } | |
| 78 | .pager button:disabled { color: #b8b0d8; border-color: #55507a; cursor: default; } | |
| 79 | .pager button:focus-visible { outline: 4px solid var(); outline-offset: 4px; } | |
| 80 | footer a { color: var(); text-decoration: none; margin-left: 14px; } | |
| 81 | footer a:hover, footer a:focus-visible { text-decoration: underline; } | |
| 82 | .empty { color: var(); 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(); | |
| 90 | background: #2c283c; border: 4px solid var(); box-shadow: 3px 3px 0 var(); 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(); 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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> | ||
| 227 | 227 | else if (/\byesterday\b/.test(t)) { |
| 228 | 228 | out.from = since(Date.parse(startOfToday(now)), 1); |
| 229 | 229 | 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); | |
| 232 | 231 | else if (/\b(a week|weekly)\b/.test(t)) days = 7; |
| 233 | 232 | } |
| 234 | 233 | if (days !== undefined) out.from = since(now, days); |
src/display/launch.ts +89 −5
| @@ -1,13 +1,97 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 14 | */ |
| 7 | 15 | |
| 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"; | |
| 9 | 22 | 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`; | |
| 10 | 87 | |
| 11 | 88 | /** A pin's screenshots, on its repository. */ |
| 12 | 89 | export const repoShotsUrl = (repo: Pick<Repo, "ownerName" | "name">, pin: Pick<BuildPin, "label">): string => |
| 13 | 90 | `/${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): | ||
| 647 | 647 | return kept.code === 0 ? ok(oid) : err("git_failed"); |
| 648 | 648 | } |
| 649 | 649 | |
| 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 | ||
| 650 | 659 | /** |
| 651 | 660 | * Every pin, newest first, read in one `for-each-ref`; or the one `label`. |
| 652 | 661 | * 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"; | ||
| 521 | 521 | import { getUserById } from "../core/users.ts"; |
| 522 | 522 | import { appsRuntime } from "../apps/runtime.ts"; |
| 523 | 523 | import { holdAppHost } from "../apps/holding.ts"; |
| 524 | import { isArcadeHost, serveArcade } from "../arcade/host.ts"; | |
| 524 | 525 | import { |
| 525 | 526 | appWebSocket, |
| 526 | 527 | type AppSocket, |
| @@ -2406,6 +2407,10 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2406 | 2407 | // must not take `<app>--<n>--<parentHost>`. Only its `"unknown"` falls |
| 2407 | 2408 | // through, since it reads every `--<its host>` label as its own slug. |
| 2408 | 2409 | 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)); | |
| 2409 | 2414 | if (runtime !== undefined) { |
| 2410 | 2415 | const target = resolveAppHost(app, runtime.options.domain, request); |
| 2411 | 2416 | // 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"; | ||
| 15 | 15 | |
| 16 | 16 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 17 | 17 | import { displayItems, type DisplayItem } from "../../display/items.ts"; |
| 18 | import { launchUrl } from "../../arcade/host.ts"; | |
| 18 | 19 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; |
| 19 | 20 | import type { Ctx, Screen } from "../app/app.ts"; |
| 20 | 21 | import { binding } from "../app/keys.ts"; |
| @@ -39,6 +40,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 39 | 40 | let choices: string[] | undefined; |
| 40 | 41 | const ticked = new Set<string>(); |
| 41 | 42 | let note = ""; |
| 43 | let launch: string | undefined; | |
| 42 | 44 | const state = new ListState(); |
| 43 | 45 | // What each build reads as, by key, so a choice shows its name. |
| 44 | 46 | const named = new Map<string, string>(); |
| @@ -51,6 +53,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 51 | 53 | lines = config.ok ? items.map(itemLine) : [config.error]; |
| 52 | 54 | named.clear(); |
| 53 | 55 | for (const it of items) named.set(it.key, itemLine(it)); |
| 56 | launch = await launchUrl(f.app, repo, config); | |
| 54 | 57 | choices = undefined; |
| 55 | 58 | if (config.ok && writesDisplay(f.app, repo, f.user)) { |
| 56 | 59 | const chosen = await selectionKeys(f.app, config.value, f.user); |
| @@ -76,7 +79,7 @@ export function displayScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 76 | 79 | 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); |
| 77 | 80 | if (missing) return void line(0, NO_DISPLAY_LINE); |
| 78 | 81 | if (head === undefined) return void line(0, "Reading…"); |
| 79 | line(0, head); | |
| 82 | line(0, launch === undefined ? head : `${head} · Launch: ${launch}/`); | |
| 80 | 83 | const shown = choices === undefined ? lines : choices.map((k) => `[${ticked.has(k) ? "x" : " "}] ${named.get(k) ?? k}`); |
| 81 | 84 | if (note !== "") line(1, note, c.mutedFg); |
| 82 | 85 | if (shown.length === 0) return void line(1, NO_BUILDS_LINE, c.mutedFg); |
src/web/pages/display.ts +7 −2
| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | * `display.json` holds (`display/config.ts`), as whoever is looking may read |
| 4 | 4 | * them; a build whose repository the viewer cannot read shows its name and |
| 5 | 5 | * 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. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -14,6 +15,7 @@ import { writesDisplay } from "../../display/selections.ts"; | ||
| 14 | 15 | import { exactRefOid } from "../../git/branches.ts"; |
| 15 | 16 | import { repoDir } from "../../git/repos.ts"; |
| 16 | 17 | import { INVALID_TITLE, displayOf } from "../../display/known.ts"; |
| 18 | import { launchUrl } from "../../arcade/host.ts"; | |
| 17 | 19 | import { repoShotsUrl } from "../../display/launch.ts"; |
| 18 | 20 | import type { Ctx } from "../../http/context.ts"; |
| 19 | 21 | import { crumbsFor, forbiddenPage, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| @@ -44,6 +46,8 @@ export async function displayPage(ctx: Ctx): Promise<Response> { | ||
| 44 | 46 | const config = await timePhaseAsync(ctx.request, "display", () => displayOf(ctx.app, repo)); |
| 45 | 47 | if (config === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); |
| 46 | 48 | 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); | |
| 47 | 51 | const writes = config.ok && writesDisplay(ctx.app, repo, ctx.auth.user); |
| 48 | 52 | const chosen = writes && config.ok ? await selectionKeys(ctx.app, config.value, ctx.auth.user) : []; |
| 49 | 53 | 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> { | ||
| 52 | 56 | const body = html`${repoHeader(ctx, repo, "code", { compact: true })} |
| 53 | 57 | <div class="page-head mt"> |
| 54 | 58 | <h2>${title}</h2> |
| 59 | ${launch === undefined ? "" : html`<a class="btn" data-by="display-launch" href="${launch}/">Launch</a>`} | |
| 55 | 60 | <a href="/displays">All displays</a> |
| 56 | 61 | </div> |
| 57 | 62 | ${!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 | }); |
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.