import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { spawn, spawnSync } from 'node:child_process' import { copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import net from 'node:net' import type { Browser, Page } from '@playwright/test' import { chromium } from '@playwright/test' import { createClient } from '@supabase/supabase-js' import { Client } from 'pg' import { installMocks, maskIdentities } from './marketing-screenshots/mocks' import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe' import { getSupabaseWorktreeConfig } from './supabase-worktree-config' interface ManagedProcess { child: ChildProcessWithoutNullStreams name: string } interface SupabaseStatus { API_URL?: string ANON_KEY?: string PUBLISHABLE_KEY?: string DB_URL?: string } interface ScreenshotDevice { slug: string // App Store Connect matches screenshots by pixel size, the prefix only keeps files readable filePrefix: string cssWidth: number cssHeight: number scale: number mobile: boolean // Defaults to the fastlane deliver folder; set for sizes deliver can't upload yet outputDir?: string // Google Play screenshot folders that reuse this capture androidDirs: string[] } interface CreativeAsset { fileName: string cssWidth: number cssHeight: number scale: number layout: 'header' | 'search' | 'feature' } const repoRoot = process.cwd() const outputRoot = resolve(repoRoot, 'fastlane/screenshots/en-US') const androidOutputRoot = resolve(repoRoot, 'fastlane/metadata/android/en-US/images') const androidScreenshotDirs = ['phoneScreenshots', 'sevenInchScreenshots', 'tenInchScreenshots'] const appStoreAssetsRoot = resolve(repoRoot, 'fastlane/app-store-assets') const rawRoot = resolve(repoRoot, '.context/app-store-screenshots/raw') const composeRoot = resolve(repoRoot, '.context/app-store-screenshots/compose') const brandMarkPath = resolve(repoRoot, 'assets/brand/capgo-mark-white.svg') const logRoot = resolve(repoRoot, '.context/app-store-screenshots/logs') const screenshotBaseUrl = process.env.APP_STORE_SCREENSHOT_BASE_URL || 'http://127.0.0.1:5173' const supabaseConfig = getSupabaseWorktreeConfig(repoRoot) const localSupabaseUrl = `http://127.0.0.1:${supabaseConfig.ports.api}` const localApiDomain = `127.0.0.1:${supabaseConfig.ports.api}/functions/v1` const stripePort = getStripeEmulatorPort(process.env) const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(process.env) const managedProcesses: ManagedProcess[] = [] const isWindows = process.platform === 'win32' const devices: ScreenshotDevice[] = [ { slug: 'iphone-6-9', filePrefix: 'iPhone 6.9in', cssWidth: 440, cssHeight: 956, scale: 3, mobile: true, androidDirs: [], }, { slug: 'iphone-6-5', filePrefix: 'iPhone 6.5in', cssWidth: 428, cssHeight: 926, scale: 3, mobile: true, androidDirs: [], }, { slug: 'iphone-6-3', filePrefix: 'iPhone 6.3in', cssWidth: 402, cssHeight: 874, scale: 3, mobile: true, androidDirs: [], }, { slug: 'iphone-5-5', filePrefix: 'iPhone 5.5in', cssWidth: 414, cssHeight: 736, scale: 3, mobile: true, androidDirs: ['phoneScreenshots'], }, { slug: 'iphone-duo-outer', filePrefix: 'iPhone Duo outer', cssWidth: 466, cssHeight: 678, scale: 3, mobile: true, outputDir: resolve(appStoreAssetsRoot, 'iphone-duo'), androidDirs: [], }, { slug: 'iphone-duo-inner', filePrefix: 'iPhone Duo inner', cssWidth: 669, cssHeight: 951, scale: 3, mobile: true, outputDir: resolve(appStoreAssetsRoot, 'iphone-duo'), androidDirs: [], }, { slug: 'ipad-13', filePrefix: 'iPad 13in', cssWidth: 1032, cssHeight: 1376, scale: 2, mobile: false, androidDirs: ['sevenInchScreenshots', 'tenInchScreenshots'], }, ] // App Store creative assets (product page header, search results) and the Google Play feature graphic const creativeAssets: CreativeAsset[] = [ { fileName: 'product-page-header-21x9.png', cssWidth: 1920, cssHeight: 823, scale: 2, layout: 'header' }, { fileName: 'product-page-header-16x9.png', cssWidth: 2622, cssHeight: 1475, scale: 2, layout: 'header' }, { fileName: 'search-result-3x2.png', cssWidth: 1920, cssHeight: 1280, scale: 2, layout: 'search' }, { fileName: 'search-result-16x9.png', cssWidth: 2622, cssHeight: 1475, scale: 2, layout: 'search' }, ] const featureGraphic: CreativeAsset = { fileName: 'featureGraphic.png', cssWidth: 1024, cssHeight: 500, scale: 1, layout: 'feature' } // Demo data comes from scripts/marketing-screenshots (seed.sql + mocked analytics), so every screen shows a live-looking app // playOnly screens show other platforms (Android device rows, platform tags), which App Review rejects (guideline 2.3.10) const screens: { slug: string, path: string, title: string, subtitle: string, playOnly?: boolean, prepare?: (page: Page) => Promise }[] = [ { slug: 'live-release', path: '/app/com.demo.app?days=7', title: 'Watch each release go live', subtitle: 'Adoption, installs, and failures in real time' }, { slug: 'rollout', path: '/app/com.demo.app/channel/1', title: 'Roll out safely', subtitle: 'Start small and pause automatically on errors', playOnly: true, prepare: async (page) => { await page.getByText(/of devices receive/).first().evaluate((el: Element) => el.closest('div')?.parentElement?.scrollIntoView({ block: 'center' })).catch(() => {}) await page.waitForTimeout(600) }, }, { slug: 'update-health', path: '/app/com.demo.app/observe/errors?days=7', title: 'Catch failed updates fast', subtitle: 'See which devices failed and why' }, { slug: 'channels', path: '/app/com.demo.app/channels', title: 'Ship to beta, then production', subtitle: 'Channels for every audience' }, { slug: 'devices', path: '/app/com.demo.app/devices', title: 'Know what each device runs', subtitle: 'Find any user\'s version in seconds', playOnly: true }, { slug: 'preview-qr', path: '/app/com.demo.app/channel/1/preview?appStoreQr=1', title: 'Preview any build with a scan', subtitle: 'Share bundles with your team by QR' }, { slug: 'builds', path: '/app/com.demo.app/builds', title: 'Native builds in the cloud', subtitle: 'Signed, uploaded, and tracked for you' }, ] const hideChromeCss = ` #__vue-devtools-container__, [id^="vue-devtools"], vite-error-overlay { display: none !important; } *, *::before, *::after { caret-color: transparent !important; } [data-sonner-toaster], [data-test="getting-started-nav"], [data-test="section-intro"] { display: none !important; } ` // Framed layout: headline on top, the app capture inside a bezel that bleeds off the bottom edge const frameWidthRatio = 0.84 const frameTopRatio = 0.27 const frameBleedRatio = 0.04 const bezelRatio = 0.016 function frameMetrics(device: ScreenshotDevice) { const frameWidth = Math.round(device.cssWidth * frameWidthRatio) const bezel = Math.max(4, Math.round(device.cssWidth * bezelRatio)) const frameTop = Math.round(device.cssHeight * frameTopRatio) const frameHeight = device.cssHeight - frameTop + Math.round(device.cssHeight * frameBleedRatio) return { frameWidth, frameTop, bezel, rawWidth: frameWidth - bezel * 2, rawHeight: frameHeight - bezel * 2, } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } async function isHttpReady(url: string): Promise { try { const response = await fetch(url) return response.status < 500 } catch { return false } } function isTcpReady(port: number, host = '127.0.0.1'): Promise { return new Promise((resolveReady) => { const socket = net.connect({ port, host }) socket.setTimeout(750) socket.once('connect', () => { socket.destroy() resolveReady(true) }) socket.once('timeout', () => { socket.destroy() resolveReady(false) }) socket.once('error', () => { socket.destroy() resolveReady(false) }) }) } async function waitForHttp(url: string, timeoutMs: number, label: string) { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { if (await isHttpReady(url)) return await sleep(1000) } throw new Error(`Timed out waiting for ${label} at ${url}`) } async function waitForTcp(port: number, timeoutMs: number, label: string) { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { if (await isTcpReady(port)) return await sleep(500) } throw new Error(`Timed out waiting for ${label} on port ${port}`) } async function waitForFile(path: string, timeoutMs: number, label: string) { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { if (existsSync(path)) return await sleep(1000) } throw new Error(`Timed out waiting for ${label} at ${path}`) } function startProcess(name: string, args: string[], env: NodeJS.ProcessEnv = {}): ManagedProcess { mkdirSync(logRoot, { recursive: true }) const logPath = resolve(logRoot, `${name}.log`) const logStream = createWriteStream(logPath, { flags: 'a' }) const child = spawn(args[0], args.slice(1), { cwd: repoRoot, env: { ...process.env, ...env }, detached: !isWindows, }) child.stdout.pipe(logStream) child.stderr.pipe(logStream) const managed = { child, name } managedProcesses.push(managed) return managed } async function stopProcess(processToStop: ManagedProcess) { const hasExited = () => processToStop.child.exitCode !== null || processToStop.child.signalCode !== null if (hasExited()) return const waitForExit = (timeoutMs: number) => { if (hasExited()) return Promise.resolve(true) return Promise.race([ new Promise((resolveExit) => { processToStop.child.once('exit', () => resolveExit(true)) }), sleep(timeoutMs).then(() => hasExited()), ]) } const terminateProcessTree = (signal: NodeJS.Signals) => { if (!processToStop.child.pid) return if (isWindows) { const args = ['/PID', String(processToStop.child.pid), '/T'] if (signal === 'SIGKILL') args.push('/F') spawnSync('taskkill', args, { stdio: 'ignore' }) return } try { process.kill(-processToStop.child.pid, signal) } catch { // The process might not be the group leader on older runs. } } if (processToStop.child.pid) { terminateProcessTree('SIGTERM') if (!isWindows) spawnSync('pkill', ['-TERM', '-P', String(processToStop.child.pid)], { stdio: 'ignore' }) } processToStop.child.kill('SIGTERM') if (await waitForExit(5000)) return if (processToStop.child.pid) { terminateProcessTree('SIGKILL') if (!isWindows) spawnSync('pkill', ['-KILL', '-P', String(processToStop.child.pid)], { stdio: 'ignore' }) } if (!hasExited()) processToStop.child.kill('SIGKILL') await waitForExit(2000) } function parseSupabaseStatus(stdout: string): SupabaseStatus | null { const jsonStart = stdout.indexOf('{') if (jsonStart < 0) return null try { return JSON.parse(stdout.slice(jsonStart)) as SupabaseStatus } catch { return null } } function getSupabaseStatus(): SupabaseStatus | null { const result = spawnSync('bun', ['scripts/supabase-worktree.ts', 'status', '-o', 'json'], { cwd: repoRoot, encoding: 'utf8', env: process.env, }) if ((result.status ?? 1) !== 0) return null return parseSupabaseStatus(result.stdout || '') } function readPngSize(path: string): { width: number, height: number } { const buffer = readFileSync(path) if (buffer.toString('ascii', 1, 4) !== 'PNG') throw new Error(`${path} is not a PNG file`) return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), } } function verifyScreenshotSize(path: string, expectedWidth: number, expectedHeight: number) { const { width, height } = readPngSize(path) if (width !== expectedWidth || height !== expectedHeight) throw new Error(`${path} is ${width}x${height}, expected ${expectedWidth}x${expectedHeight}`) } async function ensureLocalStack() { if (!(await isTcpReady(stripePort))) { startProcess('stripe-emulator', ['bun', 'run', 'stripe:emulator'], { STRIPE_EMULATOR_PORT: String(stripePort), }) await waitForTcp(stripePort, 60_000, 'Stripe emulator') } const backendOkUrl = `${localSupabaseUrl}/functions/v1/ok` const reuseBackend = process.env.APP_STORE_SCREENSHOTS_REUSE_BACKEND === 'true' if (!reuseBackend || !(await isHttpReady(backendOkUrl))) { const readyFile = resolve(repoRoot, '.context/app-store-screenshots/backend.ready') rmSync(readyFile, { force: true }) startProcess('backend-playwright', ['bun', 'run', 'backend:playwright'], { ENV: 'local', STRIPE_SECRET_KEY: 'sk_test_emulator', STRIPE_API_BASE_URL: stripeApiBaseUrl, STRIPE_WEBHOOK_SECRET: 'testsecret', WEBAPP_URL: screenshotBaseUrl, PLAYWRIGHT_READY_FILE: readyFile, }) await waitForFile(readyFile, 360_000, 'Supabase functions') } const status = getSupabaseStatus() const supabaseUrl = status?.API_URL || localSupabaseUrl const supabaseAnon = process.env.SUPABASE_ANON_KEY || status?.ANON_KEY || status?.PUBLISHABLE_KEY if (!supabaseAnon) throw new Error('Unable to resolve the local Supabase anon key from environment or worktree status') const apiDomain = supabaseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') + '/functions/v1' if (!(await isHttpReady(`${screenshotBaseUrl}/login/`))) { const frontendPort = new URL(screenshotBaseUrl).port || '5173' startProcess('frontend', ['bunx', 'vite', '--host', '127.0.0.1', '--port', frontendPort, '--strictPort'], { ENV: 'local', SUPA_URL: supabaseUrl, SUPA_ANON: supabaseAnon, API_DOMAIN: apiDomain || localApiDomain, CAPTCHA_KEY: '', }) await waitForHttp(`${screenshotBaseUrl}/login/`, 180_000, 'frontend') } return { supabaseUrl, supabaseAnon } } async function seedDemoData() { const dbUrl = process.env.DB_URL || getSupabaseStatus()?.DB_URL if (!dbUrl) throw new Error('Unable to resolve the local database URL for the demo seed') // IPv6 hosts come back bracketed (`[::1]`) from URL.hostname const host = new URL(dbUrl).hostname.replace(/^\[|\]$/g, '') if (!['127.0.0.1', 'localhost', '::1'].includes(host)) throw new Error(`Refusing to seed non-local database host "${host}".`) const client = new Client({ connectionString: dbUrl }) await client.connect() try { await client.query(readFileSync(resolve(repoRoot, 'scripts/marketing-screenshots/seed.sql'), 'utf8')) } finally { await client.end() } } async function prepareScreenshotData(supabaseUrl: string, supabaseAnon: string) { const supabase = createClient(supabaseUrl, supabaseAnon, { auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, }, }) const { error: signInError } = await supabase.auth.signInWithPassword({ email: 'test@capgo.app', password: 'testtest', }) if (signInError) throw new Error(`Unable to sign in for screenshot data setup: ${signInError.message}`) const { error } = await supabase .from('apps') .update({ allow_preview: true }) .eq('app_id', 'com.demo.app') if (error) throw new Error(`Unable to enable demo preview for screenshots: ${error.message}`) } // Runs in the browser before every navigation: keep first-run hints, the // support usernames prompt, and the dev-server Vue DevTools button out of store screenshots. function hideScreenshotNoise() { localStorage.setItem('capgo-section-intro-dismissed', JSON.stringify(['apps', 'bundles', 'channels', 'builds', 'devices', 'apikeys'])) for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) if (!key?.startsWith('sb-') || !key.endsWith('-auth-token')) continue try { const userId = JSON.parse(localStorage.getItem(key) || '{}')?.user?.id if (userId) localStorage.setItem(`capgo.supportUsernames.dismissed.${userId}`, '1') } catch {} } document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style') style.textContent = '#__vue-devtools-container__, [id^="vue-devtools"] { display: none !important; }' document.head.appendChild(style) }) } async function login(page: Page) { await page.goto('/login/', { waitUntil: 'domcontentloaded' }) await page.fill('[data-test="email"]', 'test@capgo.app') await page.waitForSelector('[data-test="password"]', { timeout: 30_000 }) await page.fill('[data-test="password"]', 'testtest') await page.click('[data-test="submit"]') // Match the pathname only: a bounced login lands on /login?to=/dashboard await page.waitForURL(url => /^\/(?:apps|dashboard)(?:\/|$)/.test(url.pathname), { timeout: 60_000 }) } async function settlePage(page: Page) { await page.waitForLoadState('domcontentloaded') await page.addStyleTag({ content: hideChromeCss }).catch(() => {}) await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {}) await page.waitForTimeout(3000) // Re-apply: some pages mount their chrome after the first paint await page.addStyleTag({ content: hideChromeCss }).catch(() => {}) } function escapeHtml(value: string) { return value.replace(/&/g, '&').replace(//g, '>') } function fileUrl(path: string) { return pathToFileURL(path).href } const pageStyle = ` * { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 100%; height: 100%; overflow: hidden; } body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #fff; background: radial-gradient(120% 70% at 50% 0%, rgba(17, 158, 255, 0.28) 0%, rgba(17, 158, 255, 0) 60%), radial-gradient(90% 60% at 100% 100%, rgba(31, 178, 165, 0.18) 0%, rgba(31, 178, 165, 0) 70%), linear-gradient(180deg, #001a2c 0%, #04253d 100%); -webkit-font-smoothing: antialiased; } .phone { position: absolute; background: #0a1622; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14), 0 40px 80px rgba(0, 0, 0, 0.45); overflow: hidden; } .phone img { display: block; width: 100%; height: auto; } ` function framedScreenHtml(device: ScreenshotDevice, screen: (typeof screens)[number], rawPath: string) { const m = frameMetrics(device) const titleSize = Math.min(device.cssWidth * 0.085, device.cssHeight * 0.05) const subtitleSize = titleSize * 0.46 const radius = Math.round(m.frameWidth * 0.1) return `

${escapeHtml(screen.title)}

${escapeHtml(screen.subtitle)}

` } // Three phones fanned around the center: App Store crops headers per device, so the focal point stays centered function creativeHtml(asset: CreativeAsset, rawPaths: string[]) { const { cssWidth: w, cssHeight: h } = asset const mark = fileUrl(brandMarkPath) if (asset.layout === 'feature') { const phoneWidth = 200 return `

Ship app updates in minutes

Live updates for Capacitor apps

` } const isHeader = asset.layout === 'header' const titleSize = Math.round(h * (isHeader ? 0.075 : 0.065)) const copyHeight = Math.round(h * (isHeader ? 0.24 : 0.22)) const phoneWidth = Math.round(h * (isHeader ? 0.42 : 0.4)) const gap = Math.round(phoneWidth * 0.08) const centerTop = copyHeight + Math.round(h * 0.03) const sideTop = centerTop + Math.round(h * 0.08) const centerLeft = Math.round((w - phoneWidth) / 2) const title = isHeader ? 'Ship app updates in minutes' : 'Live updates for Capacitor apps' return `

${title}

` } async function renderHtml(browser: Browser, html: string, name: string, cssWidth: number, cssHeight: number, scale: number, outputPath: string) { mkdirSync(composeRoot, { recursive: true }) const htmlPath = resolve(composeRoot, `${name}.html`) writeFileSync(htmlPath, html) const context = await browser.newContext({ viewport: { width: cssWidth, height: cssHeight }, deviceScaleFactor: scale }) try { const page = await context.newPage() await page.goto(fileUrl(htmlPath), { waitUntil: 'load' }) await page.evaluate(() => document.fonts.ready) mkdirSync(dirname(outputPath), { recursive: true }) await page.screenshot({ path: outputPath, fullPage: false, animations: 'disabled' }) verifyScreenshotSize(outputPath, cssWidth * scale, cssHeight * scale) } finally { await context.close() } } async function captureRawScreens(browser: Browser, device: ScreenshotDevice) { const { rawWidth, rawHeight } = frameMetrics(device) const context = await browser.newContext({ baseURL: screenshotBaseUrl, viewport: { width: rawWidth, height: rawHeight }, deviceScaleFactor: device.scale, hasTouch: device.mobile, isMobile: device.mobile, locale: 'en-US', reducedMotion: 'reduce', colorScheme: 'light', timezoneId: 'UTC', }) await context.addInitScript(hideScreenshotNoise) const page = await context.newPage() await installMocks(page) const rawPaths: string[] = [] try { await login(page) for (const screen of screens) { await page.goto(screen.path, { waitUntil: 'domcontentloaded' }) await settlePage(page) await screen.prepare?.(page) await maskIdentities(page) const rawPath = resolve(rawRoot, device.slug, `${screen.slug}.png`) mkdirSync(dirname(rawPath), { recursive: true }) await page.screenshot({ path: rawPath, fullPage: false, animations: 'disabled', caret: 'hide' }) rawPaths.push(rawPath) } } finally { await context.close() } return rawPaths } async function captureScreenshots() { for (const dir of [outputRoot, appStoreAssetsRoot, rawRoot, ...androidScreenshotDirs.map(dir => resolve(androidOutputRoot, dir))]) { rmSync(dir, { recursive: true, force: true }) mkdirSync(dir, { recursive: true }) } const browser = await chromium.launch({ headless: true }) try { let creativeRawPaths: string[] = [] for (const device of devices) { const rawPaths = await captureRawScreens(browser, device) if (device.slug === 'iphone-6-9') creativeRawPaths = rawPaths let appleIndex = 0 for (const [index, screen] of screens.entries()) { if (screen.playOnly && device.androidDirs.length === 0) continue const androidName = `${String(index + 1).padStart(2, '0')}-${screen.slug}.png` const fileName = screen.playOnly ? androidName : `${device.filePrefix}-${String(++appleIndex).padStart(2, '0')}-${screen.slug}.png` const outputPath = screen.playOnly ? resolve(composeRoot, device.slug, androidName) : resolve(device.outputDir ?? outputRoot, fileName) await renderHtml(browser, framedScreenHtml(device, screen, rawPaths[index]), `${device.slug}-${screen.slug}`, device.cssWidth, device.cssHeight, device.scale, outputPath) for (const dir of device.androidDirs) copyFileSync(outputPath, resolve(androidOutputRoot, dir, androidName)) console.log(`${device.slug}: ${fileName}`) } } for (const asset of creativeAssets) { await renderHtml(browser, creativeHtml(asset, creativeRawPaths), asset.fileName.replace('.png', ''), asset.cssWidth, asset.cssHeight, asset.scale, resolve(appStoreAssetsRoot, 'creative', asset.fileName)) console.log(`creative: ${asset.fileName}`) } await renderHtml(browser, creativeHtml(featureGraphic, creativeRawPaths), 'feature-graphic', featureGraphic.cssWidth, featureGraphic.cssHeight, featureGraphic.scale, resolve(androidOutputRoot, featureGraphic.fileName)) console.log(`android: ${featureGraphic.fileName}`) } finally { await browser.close() } } try { const localStack = await ensureLocalStack() await seedDemoData() await prepareScreenshotData(localStack.supabaseUrl, localStack.supabaseAnon) await captureScreenshots() console.log(`Screenshots written to ${outputRoot}, ${appStoreAssetsRoot} and ${androidOutputRoot}`) } finally { for (const managed of managedProcesses.reverse()) await stopProcess(managed) }