// rnx screenshot appstore capture — headlessly (re)capture an app's App-Store // slides as EDITABLE 3-layer refs (captureRef + cleanRef + screenRef, plus // threeScene for 3d) and let the shell's existing auto-upload push them to the // signed-in user's org deck. NOT a reimplementation of capture/upload: it // launches a GPU headless Chrome, loads the org-mode build shell, seeds the // `rnx login` session into the page so the in-browser auto-upload PUTs // authenticate as the user, drives the same `SootSim.bridges.screenshotControl` // surface the rail UI uses, then waits for the deck/capture PUTs to flush. // // auth: `rnx login` (resolveCliAuth → session token). the token is seeded // into the shell page's localStorage (`sootsim.session.token`) — the exact key // `getSession()` reads, which both the deck PUT (projects-persistence) and the // per-ref capture PUT (capture-store) attach as `Authorization: Bearer`. // // safety: upload is the default, but the command REFUSES to overwrite a // non-empty org deck unless `--force` is passed (the deck PUT has snapshot // semantics — it replaces the whole deck). `--dry-run` captures and reports the // refs without uploading (PUTs are intercepted + aborted in the page). import { execSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { chromium, type Frame, type Page } from 'playwright-core' import { launchReapedChromeScoped } from '../../../../scripts/lib/reap-browser' import { rnxPublicBrand } from '../../src/public-brand' import { resolveCliAuth, type CliAuth } from '../auth' import { rnxExit } from '../run-rnx' const HELP = ` rnx screenshot appstore capture — headlessly capture + upload App Store slides usage: rnx screenshot appstore capture --org --repo [options] what it does: boots the org-mode build shell in headless GPU Chrome (authed via your \`rnx login\` session), captures each slide through the live screenshot composer so every slide gets editable layers (captureRef + cleanRef + screenRef), and lets the shell auto-upload them to your org deck. options: --org github org slug (required) --repo repo name (required) --branch build branch to boot (default: main) --platform

ios | android (default: ios) --slides 1-based indices to capture, e.g. 1,3,5 (default: all) --dry-run capture + report refs, do NOT upload (PUTs aborted) --force allow overwriting a NON-EMPTY existing deck --out

also write composed App-Store PNGs locally --shell-origin origin serving /build (default: your session origin) --api-origin origin for deck/capture APIs (default: session origin) --timeout per-step bridge timeout (default: 45000) --json machine-readable summary examples: rnx screenshot appstore capture --org acme --repo my-app --dry-run rnx screenshot appstore capture --org acme --repo my-app --branch main --force ` interface CaptureSlide { index: number id: string title: string captureRef: string | null cleanRef: string | null screenRef: string | null captureMode: '2d' | '3d' | null } interface ControlState { projectName: string deviceModel: string slides: CaptureSlide[] } interface Flags { org: string repo: string branch: string platform: string slides: number[] | null dryRun: boolean force: boolean out: string | null shellOrigin: string | null apiOrigin: string | null timeoutMs: number json: boolean } function parseFlags(args: string[]): Flags { const get = (name: string): string | undefined => { const i = args.indexOf(name) return i >= 0 ? args[i + 1] : undefined } const has = (name: string) => args.includes(name) const slidesRaw = get('--slides') const slides = slidesRaw ? slidesRaw .split(',') .map((s) => Number(s.trim())) .filter((n) => Number.isInteger(n) && n > 0) : null return { org: get('--org') ?? '', repo: get('--repo') ?? '', branch: get('--branch') ?? 'main', platform: get('--platform') ?? 'ios', slides: slides && slides.length ? slides : null, dryRun: has('--dry-run'), force: has('--force'), out: get('--out') ?? null, shellOrigin: get('--shell-origin') ?? null, apiOrigin: get('--api-origin') ?? null, timeoutMs: Number(get('--timeout') ?? '45000') || 45000, json: has('--json'), } } // the session token + origin power both the page auth seed and the API calls. // only a logged-in session works here (the in-browser PUTs read a bearer); an // api-key/github CliAuth has no browser session origin, so we direct those to // `rnx login` rather than silently failing mid-capture. function resolveSessionAuth(): { token: string; origin: string } { const auth: CliAuth | null = resolveCliAuth() if (auth && auth.kind === 'session') return { token: auth.token, origin: auth.origin } process.stderr.write( '\n rnx screenshot appstore capture needs a logged-in session.\n' + ' run `rnx login` first (the captured slides upload to that account).\n\n', ) rnxExit(1) } function isLoopback(hostname: string): boolean { const h = hostname.replace(/^\[|\]$/g, '').toLowerCase() return ( h === 'localhost' || h.endsWith('.localhost') || h === '::1' || /^127(?:\.\d{1,3}){3}$/.test(h) ) } // the build shell (/build/...) is served by the sootsim PAGES site, a DIFFERENT // origin from the contrast API/auth origin. derive it from the api origin: // loopback dev pairs the 3000(+offset) app with the 5173(+offset) shell (the // inverse of engine origin.ts's pairedLocalAppOrigin); prod's contrast.dev is // fronted by sootsim.com for /build. the engine itself resolves where the // deck/capture PUTs go from wherever the shell loads, so we only need to land // the shell on the right origin and seed the token there. function deriveShellOrigin(apiOrigin: string): string { const u = new URL(apiOrigin) if (isLoopback(u.hostname)) { const port = Number(u.port) || 3000 const offset = port - 3000 if (offset >= 0 && offset <= 1000) { u.port = String(5173 + offset) return u.origin } } if (u.hostname === 'contrast.dev' || u.hostname.endsWith('.contrast.dev')) { return rnxPublicBrand.origin } // single-origin deploys serve /build from the same origin as the api. return apiOrigin } function loadAvg1(): number { try { return Number(execSync('sysctl -n vm.loadavg').toString().trim().split(/\s+/)[1]) || 0 } catch { return 0 } } // a deck frame's persisted 3d scene (pose + look). present only on 3d slides. interface DeckFrame { threeScene?: Record | null screenRef?: string | null } // fetch the org deck's frames (in slide order) so a re-bake can restore each // slide's EXACT authored pose before capturing. without this the 3d bake // snapshots whatever pose the async focus-restore happens to have settled on — // which races to the default pose and silently flattens a slide (real bug). async function fetchDeckFrames( apiOrigin: string, token: string, org: string, repo: string, ): Promise { const res = await fetch( `${apiOrigin}/api/sootsim/screenshot-decks?org=${encodeURIComponent(org)}&repo=${encodeURIComponent(repo)}`, { headers: { authorization: `Bearer ${token}` } }, ) if (!res.ok) return [] const body = (await res.json().catch(() => null)) as { decks?: Array<{ config?: { frames?: DeckFrame[] } }> } | null const frames: DeckFrame[] = [] for (const deck of body?.decks ?? []) { if (Array.isArray(deck.config?.frames)) frames.push(...deck.config.frames) } return frames } // the bare saved screen (screenRef) decoded to a data url, so a device-free // re-bake textures the posed phone with the slide's own screen. async function fetchScreenImage( apiOrigin: string, token: string, org: string, ref: string, ): Promise { const res = await fetch( `${apiOrigin}/api/sootsim/screenshot-capture?ref=${encodeURIComponent(ref)}&org=${encodeURIComponent(org)}`, { headers: { authorization: `Bearer ${token}` } }, ) if (!res.ok) return null const buf = Buffer.from(await res.arrayBuffer()) return `data:image/png;base64,${buf.toString('base64')}` } // find the frame that hosts the screenshot plugin bridge. the build shell mounts // a /sootsim/ iframe and the screenshot plugin (contexts:['host']) publishes // screenshotControl on that frame's window — origin-agnostic detection by // probing for the bridge rather than matching a url pattern. async function waitForShellFrame(page: Page, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { for (const frame of page.frames()) { const ready = await frame .evaluate(() => { const ss = ( globalThis as { SootSim?: { bridges?: { screenshotControl?: unknown } } } ).SootSim return !!ss?.bridges?.screenshotControl }) .catch(() => false) if (ready) return frame } await page.waitForTimeout(250) } throw new Error( 'screenshot bridge never appeared — the build shell did not boot screenshot mode (check --branch/--platform has a ready build, and you are logged in)', ) } async function getState(frame: Frame): Promise { return frame.evaluate(async () => { const ss = ( globalThis as { SootSim?: { bridges?: { screenshotControl?: { getState: () => unknown } } } } ).SootSim const control = ss?.bridges?.screenshotControl if (!control) throw new Error('screenshotControl missing') return control.getState() as unknown }) as Promise } // ssMode=1 in the url makes the screenshot plugin auto-enter the mode at boot; // wait for that, and only if it never lands, set it directly through the // published settings bridge (the same store-routing the plugin uses — never a // raw shell-command event, which is a cross-worker footgun). async function ensureScreenshotMode(frame: Frame): Promise { await frame.evaluate(async () => { const settings = ( globalThis as { SootSim?: { bridges?: { settings?: { get: () => { screenshotMode?: boolean; showFrame?: boolean } set: (key: string, value: boolean) => void } } } } ).SootSim?.bridges?.settings const deadline = Date.now() + 3000 while (Date.now() < deadline) { if (settings?.get?.()?.screenshotMode) return await new Promise((r) => setTimeout(r, 50)) } if (settings && !settings.get().screenshotMode) { settings.set('screenshotMode', true) if (!settings.get().showFrame) settings.set('showFrame', true) } }) } // capture one slide into editable layers. for a 3d slide (has a persisted // scene) we EXPLICITLY restore the scene + pose and texture the saved screen // onto the phone BEFORE baking, rather than relying on the composer's async // focus-restore having settled — that race silently snapshots the default pose // and flattens the slide. for a 2d/fresh slide (no scene) this is a plain // capture, unchanged. async function captureSlide( frame: Frame, index: number, scene: Record | null, screenDataUrl: string | null, ): Promise { await frame.evaluate( async ([n, sc, screen]) => { const ss = ( globalThis as { SootSim?: { bridges?: { screenshotControl?: { capture: (ref: number) => Promise focusSlide: (ref: number) => unknown } threeMode?: { configure?: (opts: unknown) => unknown setScreenImage?: (dataUrl: string | null) => void captureStage?: (opts?: unknown) => unknown getState?: () => { ready?: boolean } | null } } } } ).SootSim const control = ss?.bridges?.screenshotControl if (!control) throw new Error('screenshotControl missing') const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) if (sc) { // focus → composer auto-toggles 3d + mounts the stage (whose bridge // methods land a few frames later). control.focusSlide(n as number) const deadline = Date.now() + 30000 let three = ss?.bridges?.threeMode while (Date.now() < deadline) { three = ss?.bridges?.threeMode if (three?.setScreenImage && three?.captureStage && three?.getState?.()?.ready) break await sleep(150) } await sleep(1000) const s = sc as Record // re-apply the EXACT saved scene+pose so the bake's getPose() snapshot // reads the authored pose, not a transient. three?.configure?.({ colorway: s.colorway, background: s.background, ...(s.customGradient ? { customGradient: s.customGradient } : {}), setting: s.setting, focus: s.focus, environment: s.environmentActive ? { pins: s.environmentPins ?? {}, effects: s.environmentEffects ?? {} } : false, pose: { phonePosition: s.phonePosition, phoneRotation: s.phoneRotation, cameraPosition: s.cameraPosition, target: s.target, }, }) await sleep(900) if (screen && three?.setScreenImage) { three.setScreenImage(screen as string) await sleep(1200) } } await control.capture(n as number) }, [index, scene, screenDataUrl] as const, ) } export async function runScreenshotsCapture(args: string[]): Promise { if (args.includes('--help') || args.includes('-h')) { console.log(HELP) return 0 } const flags = parseFlags(args) if (!flags.org || !flags.repo) { process.stderr.write(' --org and --repo are required (see --help)\n') return 1 } const { token, origin } = resolveSessionAuth() const apiOrigin = (flags.apiOrigin ?? origin).replace(/\/$/, '') const shellOrigin = (flags.shellOrigin ?? deriveShellOrigin(apiOrigin)).replace( /\/$/, '', ) // fetch the deck once: drives both the clobber guard and the per-slide scene // restore (so a 3d re-bake keeps each slide's exact authored pose). const deckFrames = await fetchDeckFrames(apiOrigin, token, flags.org, flags.repo) // guard against clobbering a non-empty deck (PUT replaces the whole deck). if (!flags.dryRun && !flags.force && deckFrames.length > 0) { process.stderr.write( ` refusing to overwrite the existing ${flags.org}/${flags.repo} deck ` + `(${deckFrames.length} slide(s)). re-run with --force to replace it, or --dry-run to preview.\n`, ) return 1 } // heavy-process guard: a headless engine + canvaskit boot pegs cores; bail if // the box is already saturated rather than piling on. const load = loadAvg1() const cores = Number(execSync('sysctl -n hw.ncpu').toString().trim()) || 8 if (load > cores) { process.stderr.write( ` load average ${load.toFixed(1)} exceeds ${cores} cores — tear down other work first.\n`, ) return 1 } const url = `${shellOrigin}/build/${encodeURIComponent(flags.org)}/${encodeURIComponent(flags.repo)}/` + `${encodeURIComponent(flags.branch)}?platform=${encodeURIComponent(flags.platform)}&ssMode=1` const profileDir = mkdtempSync(join(tmpdir(), 'rnx-capture-')) const { browser: ctx, reapNow } = await launchReapedChromeScoped( (options) => chromium.launchPersistentContext(profileDir, options), { headless: true, viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, args: ['--hide-scrollbars'], }, ) // seed the login session into every frame's localStorage BEFORE any script // runs, so the shell boots already authenticated and the auto-upload PUTs // carry the bearer. this is the exact key getSession() reads. await ctx.addInitScript((t: string) => { try { localStorage.setItem('sootsim.session.token', t) } catch {} }, token) // track the auto-upload PUTs so we can PROVE the refs round-tripped (not just // that we captured locally). in dry-run, abort them so nothing is written. const uploads: Array<{ kind: 'deck' | 'capture'; status: number }> = [] const page = ctx.pages()[0] ?? (await ctx.newPage()) if (flags.dryRun) { // abort only the WRITES (PUT). the GET that loads the org deck must go // through, or the shell falls back to an empty default project and there's // nothing to capture. await page.route(/\/api\/sootsim\/screenshot-(decks|capture)/, (route) => route.request().method() === 'PUT' ? route.abort() : route.continue(), ) } else { page.on('response', (res) => { const u = res.url() const method = res.request().method() if (method !== 'PUT') return if (u.includes('/api/sootsim/screenshot-decks')) uploads.push({ kind: 'deck', status: res.status() }) else if (u.includes('/api/sootsim/screenshot-capture')) uploads.push({ kind: 'capture', status: res.status() }) }) } const pageErrors: string[] = [] page.on('pageerror', (e) => pageErrors.push(e.message)) // the build page forwards ssOrg/ssRepo into the shell, which GETs the team // deck on boot. wait for that load so we capture against the real deck, not // the empty default project the shell starts with. const deckLoaded = page .waitForResponse( (r) => r.url().includes('/api/sootsim/screenshot-decks') && r.request().method() === 'GET', { timeout: flags.timeoutMs }, ) .catch(() => null) try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: flags.timeoutMs }) const frame = await waitForShellFrame(page, flags.timeoutMs) await ensureScreenshotMode(frame) // let the org deck GET resolve + the projects store apply it before reading. await deckLoaded await page.waitForTimeout(800) const before = await getState(frame) if (before.slides.length === 0) { process.stderr.write( ' the deck has no slides to capture — add slides in the composer first.\n', ) return 1 } const targets = flags.slides ? before.slides.filter((s) => flags.slides!.includes(s.index)) : before.slides console.log( ` ${before.projectName} on ${before.deviceModel} — capturing ${targets.length}/${before.slides.length} slide(s)${flags.dryRun ? ' (dry-run, no upload)' : ''}`, ) for (const slide of targets) { try { // restore the slide's authored 3d scene + its saved bare screen so the // re-bake keeps the exact pose and shows the right screen (pose-accurate, // device-free). 2d/fresh slides have no scene and capture live. const deckFrame = deckFrames[slide.index - 1] const scene = deckFrame?.threeScene ?? null const screenDataUrl = scene && deckFrame?.screenRef ? await fetchScreenImage(apiOrigin, token, flags.org, deckFrame.screenRef) : null await captureSlide(frame, slide.index, scene, screenDataUrl) console.log(` ✓ slide ${slide.index} "${slide.title.slice(0, 40)}"`) } catch (err) { console.log( ` ✗ slide ${slide.index} "${slide.title.slice(0, 40)}" — ${(err as Error).message}`, ) } } // let the debounced deck (800ms) + capture (400ms) PUTs flush. if (!flags.dryRun) await page.waitForTimeout(2500) const after = await getState(frame) const captured = after.slides.filter((s) => targets.some((t) => t.id === s.id)) // optional local PNG export. const written: string[] = [] if (flags.out) { const outDir = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out) for (const slide of captured) { if (!slide.captureRef) continue const png = await frame.evaluate(async (n: number) => { const ss = ( globalThis as { SootSim?: { bridges?: { screenshotControl?: { exportSlide: ( ref: number, ) => Promise<{ name: string; dataUrl: string }> } } } } ).SootSim const control = ss?.bridges?.screenshotControl if (!control) throw new Error('screenshotControl missing') return control.exportSlide(n) }, slide.index) const m = png.dataUrl.match(/^data:[^;]+;base64,(.+)$/) if (m) { const path = join(outDir, png.name) mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, Buffer.from(m[1], 'base64')) written.push(path) } } } const editable = captured.filter((s) => s.captureRef && s.cleanRef) const summary = { org: flags.org, repo: flags.repo, branch: flags.branch, dryRun: flags.dryRun, slides: captured.map((s) => ({ index: s.index, title: s.title, captureRef: !!s.captureRef, cleanRef: !!s.cleanRef, screenRef: !!s.screenRef, captureMode: s.captureMode, })), editableCount: editable.length, uploads: flags.dryRun ? 'skipped (dry-run)' : uploads, written, pageErrors: pageErrors.slice(0, 5), } if (flags.json) { console.log(JSON.stringify(summary, null, 2)) } else { console.log( `\n ${editable.length}/${captured.length} slide(s) now have editable layers (captureRef + cleanRef)`, ) const flat = captured.filter((s) => s.captureRef && !s.cleanRef) if (flat.length) { console.log( ` ⚠ ${flat.length} slide(s) baked a flat capture only (no cleanRef) — typically 3d slides whose scene didn't restore for a re-bake.`, ) } if (!flags.dryRun) { const ok = uploads.filter((u) => u.status >= 200 && u.status < 300).length console.log(` uploads: ${ok}/${uploads.length} PUT(s) succeeded`) } if (written.length) console.log(` wrote ${written.length} local PNG(s) to ${flags.out}`) if (pageErrors.length) console.log(` ⚠ ${pageErrors.length} page error(s) during capture`) } return 0 } finally { await ctx.close().catch(() => {}) reapNow() rmSync(profileDir, { recursive: true, force: true }) } }