291 lines
8 KiB
JavaScript
291 lines
8 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: "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);
|
|
}
|