#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync } 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 defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots"); const bundleId = "fr.negopoly.app"; 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: "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" }, ); if (!existsSync(appPath)) { throw new Error(`Built app not found at ${appPath}`); } } function installApp(udid) { runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]); run("xcrun", ["simctl", "install", udid, appPath]); } function openScene(udid, scene, waitMs) { run("xcrun", ["simctl", "openurl", udid, `negopoly://screenshot/${scene}`]); 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), })); if (!options.skipBuild) { buildReleaseApp(resolvedTargets[0].simulator.udid); } else if (!existsSync(appPath)) { throw new Error(`Cannot use --skip-build because ${appPath} does not exist`); } mkdirSync(options.outputRoot, { recursive: true }); 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); 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); }