CoompanionApp/mobile/scripts/generate-screenshots.mjs

290 lines
7.9 KiB
JavaScript

#!/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: "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);
}