Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 42 additions & 42 deletions FEATURES.md

Large diffs are not rendered by default.

82 changes: 41 additions & 41 deletions docs/feature-status.json

Large diffs are not rendered by default.

225 changes: 208 additions & 17 deletions scripts/capture/record.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
* server starts looking for a free port, up to 9 above it, so a capture stays inside its own range.
*
* Scenarios live in scripts/capture/scenarios/<name>.mjs and export
* `default async function (t)`; see helpers in `makeToolkit` below.
* `default async function (t)`; see helpers in `makeToolkit` below. A scenario may also export `env`:
* more environment for Orbit's launch (fixture paths, say), as an object or a function of `{ fx }`.
*/
import { execFile, spawn } from 'node:child_process'
import { mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { crc32, deflateSync } from 'node:zlib'
import { _electron as electron } from '@playwright/test'

const argv = process.argv.slice(2)
Expand Down Expand Up @@ -62,8 +64,32 @@ const log = (...m) => console.error(`[capture ${new Date().toISOString().slice(1
async function startFixtures() {
const accents = ['#e5484d', '#3e63dd', '#30a46c', '#f76b15', '#8e4ec6', '#12a594', '#d6409f', '#ffb224']
const esc = (t) => t.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
// What /feed.json offers: set by a scenario with fx.offer({ version, summary, security }).
let offer = null
// The `cut` files that broke off (`broken`, until a scenario calls fx.repair(name)) or were repaired.
const cuts = new Map()
const server = createServer((req, res) => {
const raw = (req.url ?? '/').split('/').pop() || 'Home'
const at = new URL(req.url ?? '/', 'http://127.0.0.1')
if (at.pathname.startsWith('/dl/')) {
sendFile(req, res, at, cuts)
return
}
// /feed.json: an update feed (UP-01). Before a scenario offers a version, its release is 0.0.0, older
// than any build. The release's page is one of these pages.
if (at.pathname === '/feed.json') {
const { version = '0.0.0', summary, security = false } = offer ?? {}
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
version,
url: `http://${req.headers.host}/${encodeURIComponent(`Orbit ${version}`)}`,
summary,
security,
}),
)
return
}
const raw = at.pathname.split('/').pop() || 'Home'
let name = raw
try {
name = decodeURIComponent(raw)
Expand All @@ -72,14 +98,21 @@ async function startFixtures() {
}
const accent = accents[[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % accents.length]
const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="${accent}"/><text x="16" y="22" font-family="Arial,sans-serif" font-size="17" font-weight="700" fill="#fff" text-anchor="middle">${esc(name[0] ?? '?')}</text></svg>`
const cards = ['Overview', 'Recent', 'Shared with you', 'Starred', 'Activity', 'Ideas']
.map(
(c, i) =>
`<div class="card"><div class="dot" style="opacity:${0.35 + (i % 3) * 0.2}"></div><h3>${c}</h3><p>Local demo content for ${esc(name)}. Nothing here comes from the network.</p></div>`,
)
.join('')
res.writeHead(200, { 'content-type': 'text/html' })
res.end(`<!doctype html><html><head><meta charset="utf-8"><title>${esc(name)}</title>
// /files/<name>: a page of download links (FILES) in place of the cards.
const cards = at.pathname.startsWith('/files/')
? FILES.map(
([file, query, size]) =>
`<a class="card file" data-file="${esc(file)}" href="/dl/${encodeURIComponent(file)}${query ? `?${query}` : ''}"><div class="dot"></div><h3>${esc(file)}</h3><p>${size}. Download</p></a>`,
).join('')
: ['Overview', 'Recent', 'Shared with you', 'Starred', 'Activity', 'Ideas']
.map(
(c, i) =>
`<div class="card"><div class="dot" style="opacity:${0.35 + (i % 3) * 0.2}"></div><h3>${c}</h3><p>Local demo content for ${esc(name)}. Nothing here comes from the network.</p></div>`,
)
.join('')
// ?wait=<ms>: the page comes that much later (at most 30 s), for a load still under way.
const wait = Math.min(Number(at.searchParams.get('wait')) || 0, 30_000)
const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(name)}</title>
<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(icon)}">
<style>
:root{color-scheme:light}body{font:16px/1.55 system-ui,sans-serif;margin:0;background:#fafafa;color:#1d1d1f}
Expand All @@ -90,8 +123,13 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa
.lead{color:#555;max-width:60ch;margin:0 0 28px}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}
.card{background:#fff;border:1px solid #eee;border-radius:14px;padding:18px}.card h3{margin:10px 0 4px;font-size:16px}
.card p{margin:0;color:#666;font-size:14px}.dot{width:36px;height:36px;border-radius:10px;background:${accent}}
a.file{color:inherit;text-decoration:none}a.file:hover{border-color:${accent}}
</style></head><body><header><div class="logo"></div><b>${esc(name)}</b><nav><span>Home</span><span>Library</span><span>Settings</span></nav></header>
<main><h1>${esc(name)}</h1><p class="lead">A local demo page for Orbit screen captures, served from 127.0.0.1.</p><div class="grid">${cards}</div></main></body></html>`)
<main><h1>${esc(name)}</h1><p class="lead">A local demo page for Orbit screen captures, served from 127.0.0.1.</p><div class="grid">${cards}</div></main></body></html>`
setTimeout(() => {
res.writeHead(200, { 'content-type': 'text/html' })
res.end(page)
}, wait)
})
let port = PORT
await new Promise((ok, fail) => {
Expand All @@ -100,7 +138,153 @@ main{padding:40px;max-width:1100px}h1{font-size:40px;margin:8px 0 6px;letter-spa
)
server.listen(port, '127.0.0.1', ok)
})
return { url: (name) => `http://127.0.0.1:${port}/${encodeURIComponent(name)}`, close: () => server.close() }
return {
url: (name) => `http://127.0.0.1:${port}/${encodeURIComponent(name)}`,
offer: (release) => {
offer = release
},
repair: (file) => {
cuts.set(file, 'repaired')
},
close: () => {
server.closeAllConnections()
server.close()
},
}
}

const MB = 1024 * 1024
/** The files a /files/ page links to: name, the /dl/ query, and the size it shows. */
const FILES = [
['Survey report.pdf', '', '1 page'],
['Site photo.png', '', '480 × 320'],
['Field recordings.zip', `size=${24 * MB}&rate=${1.5 * MB}`, '24 MB'],
['Station log.csv', `size=${6 * MB}&rate=${MB}&cut`, '6 MB'],
]

/**
* /dl/<name>?size=&rate=&cut: a file sent as an attachment (DL-01), with an ETag and byte ranges, so a
* paused or cut-off download goes on where it stopped (DL-15). A .pdf is a one-page PDF and a .png a
* picture, so a preview shows them; any other file is `size` bytes of filler. With `rate`, it comes at
* that many bytes a second. With `cut`, the first request breaks off after a quarter, and every request
* after it breaks too (the engine tries again by itself a few times) until the scenario repairs it.
*/
function sendFile(req, res, at, cuts) {
let name = at.pathname.slice('/dl/'.length)
try {
name = decodeURIComponent(name)
} catch {
// A malformed escape: the name as it came.
}
const ext = name.split('.').pop()
const size = Math.min(Number(at.searchParams.get('size')) || 64 * 1024, 64 * MB)
const data = ext === 'pdf' ? pdfFile(name.replace(/\.pdf$/, '')) : ext === 'png' ? pngFile() : filler(size)
const from = Number(/^bytes=(\d+)-/.exec(req.headers.range ?? '')?.[1] ?? 0)
if (cuts.get(name) === 'broken') {
res.destroy()
return
}
const cut = at.searchParams.has('cut') && !cuts.has(name)
const rest = from > 0 && from < data.length ? data.subarray(from) : data
res.writeHead(rest === data ? 200 : 206, {
'content-type': 'application/octet-stream',
'content-disposition': `attachment; filename="${name}"`,
etag: `"${name}-${data.length}"`,
'accept-ranges': 'bytes',
'content-length': String(rest.length),
...(rest === data ? {} : { 'content-range': `bytes ${from}-${data.length - 1}/${data.length}` }),
})
// The first request of a `cut` file stops a quarter in, and from then on every request breaks.
const stop = cut ? Math.floor(data.length / 4) : rest.length
if (cut) cuts.set(name, 'broken')
const rate = Number(at.searchParams.get('rate')) || 0
if (!rate && !cut) {
res.end(rest)
return
}
const step = rate ? Math.max(1, Math.round(rate / 20)) : stop
let sent = 0
const timer = setInterval(() => {
const next = Math.min(stop, sent + step)
res.write(rest.subarray(sent, next))
sent = next
if (sent < stop) return
clearInterval(timer)
if (cut) setTimeout(() => res.destroy(), 100)
else res.end()
}, 50)
res.on('close', () => clearInterval(timer))
}

function filler(size) {
const out = Buffer.alloc(size)
for (let i = 0; i < size; i++) out[i] = i % 251
return out
}

/** A one-page PDF with a title and two lines of text. */
function pdfFile(title) {
const text = (size, y, s) => `BT /F1 ${size} Tf 56 ${y} Td (${s.replace(/[()\\]/g, '\\$&')}) Tj ET`
const stream = [
text(28, 760, title),
text(13, 726, 'North ridge transect, plots 1 to 14.'),
text(13, 706, 'A local file for Orbit screen captures.'),
].join('\n')
const objects = [
'<</Type/Catalog/Pages 2 0 R>>',
'<</Type/Pages/Kids[3 0 R]/Count 1>>',
'<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>',
`<</Length ${stream.length}>>\nstream\n${stream}\nendstream`,
'<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>',
]
let out = '%PDF-1.4\n'
const offsets = objects.map((o, i) => {
const offset = out.length
out += `${i + 1} 0 obj${o}endobj\n`
return offset
})
const xref = out.length
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
out += offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
out += `trailer<</Size ${objects.length + 1}/Root 1 0 R>>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(out, 'latin1')
}

/** A 480 × 320 picture: a ridge under a sky, with a sun. */
function pngFile(w = 480, h = 320) {
const raw = Buffer.alloc((w * 3 + 1) * h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const ridge = h * 0.62 + Math.sin(x / 55) * 18 + Math.sin(x / 23) * 6
const sun = (x - w * 0.72) ** 2 + (y - h * 0.3) ** 2 < 30 ** 2
const k = y > ridge ? (y - ridge) / (h - ridge) : y / ridge
const rgb =
y > ridge ? [48 - 20 * k, 140 - 50 * k, 90 - 30 * k] : sun ? [255, 214, 120] : [140 + 90 * k, 190 + 50 * k, 245]
raw.set(
rgb.map((c) => Math.round(c)),
y * (w * 3 + 1) + 1 + x * 3,
)
}
}
const chunk = (type, body) => {
const head = Buffer.alloc(4)
head.writeUInt32BE(body.length)
const typed = Buffer.concat([Buffer.from(type, 'latin1'), body])
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(typed))
return Buffer.concat([head, typed, crc])
}
const header = Buffer.alloc(13)
header.writeUInt32BE(w, 0)
header.writeUInt32BE(h, 4)
header[8] = 8 // bits per channel
header[9] = 2 // RGB
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
])
}

function startRecorder(file) {
Expand Down Expand Up @@ -278,6 +462,10 @@ let app
let rec
let saved = false
try {
const scenarioFile = scenario.endsWith('.mjs')
? resolve(scenario)
: resolve(import.meta.dirname, 'scenarios', `${scenario}.mjs`)
const { default: play, setup, env = {} } = await import(pathToFileURL(scenarioFile).href)
log('launching Orbit')
app = await electron.launch({
args: [
Expand All @@ -286,7 +474,14 @@ try {
NO_LOOKUPS,
main,
],
env: { ...process.env, ORBIT_E2E: '1', ORBIT_USER_DATA: userData },
env: {
...process.env,
ORBIT_E2E: '1',
ORBIT_USER_DATA: userData,
// Import searches no browser on this computer: a scenario that imports names its made-up ones.
ORBIT_IMPORT_PLACES: '[]',
...(typeof env === 'function' ? await env({ fx }) : env),
},
})
let shell
for (let i = 0; i < 200 && !shell; i++) {
Expand Down Expand Up @@ -317,10 +512,6 @@ try {
throw new Error(`not recording: requests would reach the network (${proxies.join(', ')})`)
}

const scenarioFile = scenario.endsWith('.mjs')
? resolve(scenario)
: resolve(import.meta.dirname, 'scenarios', `${scenario}.mjs`)
const { default: play, setup } = await import(pathToFileURL(scenarioFile).href)
const toolkit = makeToolkit(app, shell, fx)
// Optional unrecorded preparation (Spaces, tabs, history), so the clip starts on the demo itself.
if (setup) {
Expand Down
Loading