#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const mobileRoot = resolve(__dirname, ".."); const derivedDataPath = resolve(mobileRoot, "ios", "build", "screenshots"); const appPath = resolve( derivedDataPath, "Build", "Products", "Release-iphonesimulator", "mobile.app", ); const productsPath = resolve(derivedDataPath, "Build", "Products", "Release-iphonesimulator"); const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots"); const bundleId = "fr.negopoly.app"; const screenshotStorageKey = "negopoly:screenshot-scene"; const launchWaitMs = 4500; const sceneWaitMs = 2200; const targetDevices = [ { matchName: "iPhone 17 Pro Max", outputFolder: "iPhone 17 Pro Max" }, { matchName: "iPad Air 13-inch", outputFolder: "iPad Air 13-inch" }, ]; const scenes = [ { slug: "start", fileName: "Start.png" }, { slug: "lobby", fileName: "Lobby.png" }, { slug: "home", fileName: "Home.png" }, { slug: "transfers", fileName: "Transfers.png" }, { slug: "help", fileName: "Help.png" }, { slug: "chat", fileName: "Chat.png" }, ]; function parseArgs(argv) { const options = { skipBuild: false, outputRoot: defaultOutputRoot, }; for (let index = 0; index < argv.length; index += 1) { const current = argv[index]; if (current === "--skip-build") { options.skipBuild = true; continue; } if (current === "--output") { const nextValue = argv[index + 1]; if (!nextValue) { throw new Error("Missing value after --output"); } options.outputRoot = resolve(mobileRoot, nextValue); index += 1; continue; } throw new Error(`Unknown argument: ${current}`); } return options; } function run(command, args, options = {}) { const label = `${command} ${args.join(" ")}`; console.log(`\n> ${label}`); return execFileSync(command, args, { cwd: mobileRoot, encoding: "utf8", stdio: options.stdio ?? "pipe", }); } function runQuiet(command, args) { try { return run(command, args); } catch { return ""; } } function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function listAvailableDevices() { const output = run("xcrun", ["simctl", "list", "devices", "available", "--json"]); const payload = JSON.parse(output); return Object.entries(payload.devices).flatMap(([runtime, devices]) => devices.map((device) => ({ runtime, name: device.name, udid: device.udid, state: device.state, isAvailable: device.isAvailable, })), ); } function resolveDevice(matchName, devices) { const matches = devices .filter((device) => device.isAvailable) .filter((device) => device.name === matchName || device.name.startsWith(matchName)) .sort((left, right) => { if (left.state === "Booted" && right.state !== "Booted") return -1; if (left.state !== "Booted" && right.state === "Booted") return 1; return right.runtime.localeCompare(left.runtime); }); if (matches.length === 0) { throw new Error(`No available simulator found for "${matchName}"`); } return matches[0]; } function ensureBooted(udid) { runQuiet("xcrun", ["simctl", "boot", udid]); run("xcrun", ["simctl", "bootstatus", udid, "-b"]); } function setSimulatorAppearance(udid) { runQuiet("xcrun", ["simctl", "ui", udid, "appearance", "light"]); runQuiet("xcrun", ["simctl", "status_bar", udid, "clear"]); runQuiet("xcrun", [ "simctl", "status_bar", udid, "override", "--time", "9:41", "--dataNetwork", "wifi", "--wifiBars", "3", "--batteryState", "charged", "--batteryLevel", "100", ]); } function buildReleaseApp(buildDestinationUdid) { run( "xcodebuild", [ "-workspace", "ios/mobile.xcworkspace", "-scheme", "mobile", "-configuration", "Release", "-destination", `platform=iOS Simulator,id=${buildDestinationUdid}`, "-derivedDataPath", derivedDataPath, "build", ], { stdio: "inherit" }, ); return resolveBuiltAppPath(); } function resolveBuiltAppPath() { if (existsSync(appPath)) { return appPath; } if (!existsSync(productsPath)) { throw new Error(`Build products directory not found at ${productsPath}`); } const candidates = readdirSync(productsPath) .filter((entry) => entry.endsWith(".app")) .filter((entry) => !entry.endsWith(".app.dSYM")) .sort((left, right) => left.localeCompare(right)); if (candidates.length === 0) { throw new Error(`Built app not found under ${productsPath}`); } return join(productsPath, candidates[0]); } function installApp(udid, builtAppPath) { runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]); run("xcrun", ["simctl", "install", udid, builtAppPath]); } function grantAppPermissions(udid) { runQuiet("xcrun", ["simctl", "privacy", udid, "grant", "notifications", bundleId]); } function getDataContainerPath(udid) { return run("xcrun", ["simctl", "get_app_container", udid, bundleId, "data"]).trim(); } function writeScreenshotScene(udid, scene) { const containerPath = getDataContainerPath(udid); const storageDir = join( containerPath, "Library", "Application Support", bundleId, "RCTAsyncLocalStorage_V1", ); const manifestPath = join(storageDir, "manifest.json"); mkdirSync(storageDir, { recursive: true }); let manifest = {}; if (existsSync(manifestPath)) { try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")); } catch { manifest = {}; } } manifest[screenshotStorageKey] = scene; writeFileSync(manifestPath, JSON.stringify(manifest), "utf8"); } function launchApp(udid) { run("xcrun", ["simctl", "launch", udid, bundleId]); } function openScene(udid, scene, waitMs) { runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); writeScreenshotScene(udid, scene); launchApp(udid); sleep(waitMs); } function captureScene(udid, destinationPath) { run("xcrun", ["simctl", "io", udid, "screenshot", destinationPath]); } function main() { const options = parseArgs(process.argv.slice(2)); const devices = listAvailableDevices(); const resolvedTargets = targetDevices.map((target) => ({ ...target, simulator: resolveDevice(target.matchName, devices), })); const builtAppPath = !options.skipBuild ? buildReleaseApp(resolvedTargets[0].simulator.udid) : resolveBuiltAppPath(); if (options.skipBuild && !existsSync(builtAppPath)) { throw new Error(`Cannot use --skip-build because ${builtAppPath} does not exist`); } mkdirSync(options.outputRoot, { recursive: true }); console.log(`Using built app: ${builtAppPath}`); for (const target of resolvedTargets) { const { simulator } = target; const deviceOutputDir = join(options.outputRoot, target.outputFolder); console.log(`\n## ${target.outputFolder} (${simulator.name})`); mkdirSync(deviceOutputDir, { recursive: true }); ensureBooted(simulator.udid); setSimulatorAppearance(simulator.udid); installApp(simulator.udid, builtAppPath); grantAppPermissions(simulator.udid); for (const [index, scene] of scenes.entries()) { const targetPath = join(deviceOutputDir, scene.fileName); openScene(simulator.udid, scene.slug, index === 0 ? launchWaitMs : sceneWaitMs); captureScene(simulator.udid, targetPath); console.log(`Saved ${targetPath}`); } runQuiet("xcrun", ["simctl", "terminate", simulator.udid, bundleId]); runQuiet("xcrun", ["simctl", "status_bar", simulator.udid, "clear"]); } } try { main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`\nScreenshot generation failed: ${message}`); process.exit(1); }