t3-code-android-nightly/scripts/mobile-showcase.ts
Julius Marminge ce920f2ac8
chore(mobile): drive dev-menu suppression from the dev-client launch URL (#12049)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-10-01 10:51:24 -07:00

1674 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly.
import * as NodeChildProcess from "node:child_process";
import * as NodeFSP from "node:fs/promises";
import * as NodeNet from "node:net";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeProcess from "node:process";
import * as NodeURL from "node:url";
import { PNG } from "pngjs";
import showcaseConfig, {
type ShowcaseAppearance,
type ShowcaseAndroidDevice,
type ShowcaseConfig,
type ShowcaseDevice,
type ShowcaseIosDevice,
type ShowcaseStoreAssetSpec,
SHOWCASE_SCENES,
type ShowcaseScene,
SHOWCASE_THEMES,
type ShowcaseTheme,
} from "./mobile-showcase.config.ts";
import {
SHOWCASE_ENVIRONMENTS,
SHOWCASE_PROJECTS,
SHOWCASE_THREADS,
seedShowcaseEnvironment,
} from "./mobile-showcase-environment.ts";
const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");
const ANDROID_PACKAGE = "com.t3tools.t3code";
const APP_SCHEME = "t3code";
// expo-dev-launcher reads these off the manifest URL and updates the dev menu
// preferences before the app loads, keeping captures free of dev chrome.
const DEV_CLIENT_LAUNCH_FLAGS = "disableOnboarding=1&disableFab=1&disableAutoLaunch=1";
const IOS_READY_FILENAME = "T3ShowcaseReadyScene";
const SERVER_HOST = "0.0.0.0";
const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";
const IOS_APP_PATH = NodePath.join(
MOBILE_ROOT,
".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app",
);
const ANDROID_APK_PATH = NodePath.join(
MOBILE_ROOT,
"android/app/build/outputs/apk/debug/app-debug.apk",
);
export function resolveAndroidSdkRoot(
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform = NodeProcess.platform,
): string {
const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT;
if (configured) return configured;
const home = environment.HOME ?? environment.USERPROFILE ?? "";
const path = platform === "win32" ? NodePath.win32 : NodePath.posix;
return path.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk");
}
const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
const MOBILE_BUILD_ENV = {
...NodeProcess.env,
ANDROID_HOME: ANDROID_SDK_ROOT,
APP_VARIANT: "production",
EXPO_NO_GIT_STATUS: "1",
// Lets the capture build require full screen on iPad so the app can rotate
// itself to landscape (see app.config.ts).
T3_SHOWCASE_CAPTURE_BUILD: "1",
JAVA_HOME:
NodeProcess.env.JAVA_HOME ??
(NodeProcess.platform === "darwin"
? "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
: undefined),
NODE_ENV: "development",
};
interface CliOptions {
readonly platforms: ReadonlySet<ShowcaseDevice["platform"]>;
readonly deviceIds: ReadonlySet<string>;
readonly scenes: ReadonlySet<ShowcaseScene>;
readonly appearances: ReadonlySet<ShowcaseAppearance>;
readonly themes: ReadonlySet<ShowcaseTheme>;
readonly skipBuild: boolean;
readonly skipMetro: boolean;
readonly keepRunning: boolean;
readonly validateOnly: boolean;
readonly list: boolean;
}
export interface ShowcaseCapture {
readonly device: ShowcaseDevice;
readonly scenes: ReadonlyArray<ShowcaseScene>;
readonly appearance: ShowcaseAppearance;
readonly theme: ShowcaseTheme;
}
interface IosCaptureCleanup {
readonly udid: string;
readonly startedByRunner: boolean;
readonly createdByRunner: boolean;
}
interface AndroidCaptureCleanup {
readonly device: ShowcaseAndroidDevice;
readonly serial: string;
readonly startedByRunner: boolean;
}
interface NetworkAddress {
readonly address: string;
readonly family: string;
readonly internal: boolean;
}
export function selectLanIpv4Address(addresses: ReadonlyArray<NetworkAddress>): string | null {
return (
addresses.find(
({ address, family, internal }) =>
family === "IPv4" && !internal && !address.startsWith("169.254."),
)?.address ?? null
);
}
function lanIpv4Address(): string {
const address = selectLanIpv4Address(
Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []),
);
if (!address) {
throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro.");
}
return address;
}
export interface PngMetadata {
readonly width: number;
readonly height: number;
readonly bitDepth: number;
readonly colorType: number;
readonly hasAlpha: boolean;
}
export function readPngMetadata(bytes: Uint8Array): PngMetadata {
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) {
throw new Error("Captured file is not a valid PNG.");
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const colorType = view.getUint8(25);
return {
width: view.getUint32(16),
height: view.getUint32(20),
bitDepth: view.getUint8(24),
colorType,
hasAlpha: colorType === 4 || colorType === 6,
};
}
export function readPngDimensions(bytes: Uint8Array): {
readonly width: number;
readonly height: number;
} {
const { width, height } = readPngMetadata(bytes);
return { width, height };
}
export function normalizeStorePng(bytes: Uint8Array): Buffer {
const png = PNG.sync.read(Buffer.from(bytes));
return PNG.sync.write(png, {
bitDepth: 8,
colorType: 2,
inputColorType: 6,
inputHasAlpha: true,
});
}
export function validateStoreAsset(
spec: ShowcaseStoreAssetSpec,
bytes: Uint8Array,
label = "Screenshot",
): PngMetadata {
const metadata = readPngMetadata(bytes);
if (metadata.width !== spec.width || metadata.height !== spec.height) {
throw new Error(
`${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`,
);
}
if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) {
throw new Error(
`${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`,
);
}
if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) {
throw new Error(
`${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`,
);
}
if (spec.store === "google-play") {
const shortestSide = Math.min(metadata.width, metadata.height);
const longestSide = Math.max(metadata.width, metadata.height);
if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) {
throw new Error(
`${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`,
);
}
if (metadata.width * 16 !== metadata.height * 9) {
throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`);
}
}
return metadata;
}
export function validateStoreAssetCount(
spec: ShowcaseStoreAssetSpec,
count: number,
requireMinimum: boolean,
): void {
if (count > spec.maximumUploadCount) {
throw new Error(
`${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`,
);
}
if (requireMinimum && count < spec.minimumUploadCount) {
throw new Error(
`${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`,
);
}
}
export function showcaseCaptureDirectory(
outputDirectory: string,
capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">,
): string {
// Each palette owns a leaf folder so one upload slot never mixes themes and
// every folder keeps a store-legal screenshot count of its own.
return NodePath.join(
outputDirectory,
capture.device.storeAsset.directory,
capture.appearance,
capture.theme,
);
}
async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {
const normalized = normalizeStorePng(await NodeFSP.readFile(destination));
await NodeFSP.writeFile(destination, normalized);
const metadata = validateStoreAsset(
device.storeAsset,
normalized,
NodePath.basename(destination),
);
NodeProcess.stdout.write(
`Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`,
);
}
async function validateCaptureSet(
capture: ShowcaseCapture,
outputDirectory: string,
requireMinimum: boolean,
): Promise<void> {
const directory = showcaseCaptureDirectory(outputDirectory, capture);
const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort();
const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort();
const missingFiles = expectedFiles.filter((file) => !files.includes(file));
if (missingFiles.length > 0) {
throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`);
}
validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum);
for (const file of files) {
const bytes = await NodeFSP.readFile(NodePath.join(directory, file));
validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`);
}
NodeProcess.stdout.write(
`Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`,
);
}
function argumentValue(args: ReadonlyArray<string>, index: number, flag: string): string {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value.`);
}
return value;
}
export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
const platforms = new Set<ShowcaseDevice["platform"]>();
const deviceIds = new Set<string>();
const scenes = new Set<ShowcaseScene>();
const appearances = new Set<ShowcaseAppearance>();
const themes = new Set<ShowcaseTheme>();
let skipBuild = false;
let skipMetro = false;
let keepRunning = false;
let validateOnly = false;
let list = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--platform") {
const value = argumentValue(args, index, argument);
if (value !== "ios" && value !== "android" && value !== "all") {
throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`);
}
if (value === "all") {
platforms.add("ios");
platforms.add("android");
} else {
platforms.add(value);
}
index += 1;
} else if (argument === "--device") {
deviceIds.add(argumentValue(args, index, argument));
index += 1;
} else if (argument === "--scene") {
const value = argumentValue(args, index, argument);
if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) {
throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`);
}
scenes.add(value as ShowcaseScene);
index += 1;
} else if (argument === "--appearance") {
const value = argumentValue(args, index, argument);
if (value !== "light" && value !== "dark" && value !== "both") {
throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`);
}
if (value === "both") {
appearances.add("light");
appearances.add("dark");
} else {
appearances.add(value);
}
index += 1;
} else if (argument === "--theme") {
const value = argumentValue(args, index, argument);
if (value === "all") {
for (const theme of SHOWCASE_THEMES) themes.add(theme);
} else if (SHOWCASE_THEMES.some((theme) => theme === value)) {
themes.add(value as ShowcaseTheme);
} else {
// The app silently falls back to its default palette for an unknown id,
// so reject it here rather than shipping a mislabeled screenshot.
throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`);
}
index += 1;
} else if (argument === "--skip-build") {
skipBuild = true;
} else if (argument === "--skip-metro") {
skipMetro = true;
} else if (argument === "--keep-running") {
keepRunning = true;
} else if (argument === "--validate-only") {
validateOnly = true;
} else if (argument === "--list") {
list = true;
} else if (argument === "--help" || argument === "-h") {
list = true;
} else {
throw new Error(`Unknown option '${argument}'.`);
}
}
return {
platforms,
deviceIds,
scenes,
appearances,
themes,
skipBuild,
skipMetro,
keepRunning,
validateOnly,
list,
};
}
export function planShowcaseCaptures(
config: ShowcaseConfig,
options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">,
): ReadonlyArray<ShowcaseCapture> {
const captures = config.devices
.filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
.filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id))
.flatMap((device) => {
const appearances =
options.appearances.size === 0 ? [device.appearance] : options.appearances;
const themes = options.themes.size === 0 ? [device.theme] : options.themes;
return [...appearances].flatMap((appearance) =>
[...themes].map((theme) => ({
device,
appearance,
theme,
scenes:
options.scenes.size === 0
? device.scenes
: device.scenes.filter((scene) => options.scenes.has(scene)),
})),
);
})
.filter((capture) => capture.scenes.length > 0);
const knownDeviceIds = new Set(config.devices.map((device) => device.id));
for (const id of options.deviceIds) {
if (!knownDeviceIds.has(id)) {
throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`);
}
}
if (captures.length === 0) {
throw new Error("No captures match the selected platform, device, and scene filters.");
}
return captures;
}
function printUsage(config: ShowcaseConfig): void {
NodeProcess.stdout.write(`App screenshot showcase
Usage:
pnpm --filter @t3tools/mobile screenshots [options]
Options:
--platform ios|android|all Capture one platform (repeatable)
--device <id> Capture one configured device (repeatable)
--scene <name> Capture one scene (repeatable)
--appearance light|dark|both
Override the configured appearance
--theme <id>|all Override the configured palette (repeatable)
--skip-build Reuse the existing simulator app / debug APK
--skip-metro Reuse an already running showcase Metro server
--keep-running Leave devices and Metro running after capture
--validate-only Validate existing upload assets without capturing
--list Print this help and the configured matrix
Scenes: ${SHOWCASE_SCENES.join(", ")}
Themes: ${SHOWCASE_THEMES.join(", ")}
Configured devices:
${config.devices
.map((device) => {
const target = device.platform === "ios" ? device.simulator : device.avd;
return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark}/<theme> (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance} ${device.theme}) [${device.scenes.join(", ")}]`;
})
.join("\n")}
`);
}
function spawnProcess(
command: string,
args: ReadonlyArray<string>,
options: NodeChildProcess.SpawnOptions = {},
): NodeChildProcess.ChildProcess {
return NodeChildProcess.spawn(command, args, {
cwd: REPO_ROOT,
env: NodeProcess.env,
stdio: "inherit",
...options,
});
}
async function runCommand(
command: string,
args: ReadonlyArray<string>,
options: NodeChildProcess.SpawnOptions = {},
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawnProcess(command, args, options);
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`,
),
);
}
});
});
}
async function commandOutput(
command: string,
args: ReadonlyArray<string>,
options: NodeChildProcess.ExecFileOptions = {},
): Promise<string> {
return await new Promise<string>((resolve, reject) => {
NodeChildProcess.execFile(
command,
[...args],
{ cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options },
(error, stdout) => {
if (error) reject(error);
else resolve(String(stdout));
},
);
});
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function stopProcess(child: NodeChildProcess.ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolve) => {
child.once("exit", () => resolve());
});
child.kill("SIGTERM");
await Promise.race([exited, delay(5_000)]);
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGKILL");
await Promise.race([exited, delay(1_000)]);
}
async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const open = await new Promise<boolean>((resolve) => {
const socket = NodeNet.createConnection({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
socket.setTimeout(500, () => {
socket.destroy();
resolve(false);
});
});
if (open) return;
await delay(500);
}
throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`);
}
async function waitForFileContent(
filePath: string,
label: string,
timeoutMs = 60_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await NodeFSP.readFile(filePath, "utf8").then(
(value) => value.trim(),
() => "",
);
if (content) return content;
await delay(250);
}
throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`);
}
async function reserveAvailablePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = NodeNet.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Could not reserve a local port for the showcase environment."));
return;
}
server.close((error) => (error ? reject(error) : resolve(address.port)));
});
});
}
async function createShowcaseShell(baseDir: string): Promise<string> {
const shellPath = NodePath.join(baseDir, "showcase-shell");
await NodeFSP.writeFile(
shellPath,
`#!/bin/sh
if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then
exec /bin/sh -c "$2"
fi
exec /bin/cat
`,
{ mode: 0o755 },
);
return shellPath;
}
async function createShowcaseLabelProbe(baseDir: string, label: string): Promise<string> {
const binDirectory = NodePath.join(baseDir, "showcase-bin");
await NodeFSP.mkdir(binDirectory, { recursive: true });
const probeScript = `#!/bin/sh
if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then
printf '%s\\n' ${JSON.stringify(label)}
exit 0
fi
if [ "$1" = "--pretty" ]; then
printf '%s\\n' ${JSON.stringify(label)}
exit 0
fi
exit 1
`;
await Promise.all(
["scutil", "hostnamectl"].map((executable) =>
NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }),
),
);
return binDirectory;
}
function startShowcaseServer(
baseDir: string,
workspaceRoot: string,
port: number,
shellPath: string,
labelProbeDirectory: string,
): NodeChildProcess.ChildProcess {
return spawnProcess(
"node",
[
"apps/server/src/bin.ts",
"serve",
"--host",
SERVER_HOST,
"--port",
String(port),
"--base-dir",
baseDir,
"--no-browser",
"--log-level",
"error",
workspaceRoot,
],
{
env: {
...NodeProcess.env,
PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`,
SHELL: shellPath,
},
},
);
}
export function parsePairingCredentialOutput(output: string): string {
const jsonStart = output.indexOf("{");
const jsonEnd = output.lastIndexOf("}");
if (jsonStart === -1 || jsonEnd < jsonStart) {
throw new Error("Pairing credential command did not return JSON.");
}
const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as {
readonly credential?: unknown;
};
if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
throw new Error("Pairing credential command returned no credential.");
}
return parsed.credential;
}
async function issuePairingCredential(baseDir: string): Promise<string> {
const output = await commandOutput(
"node",
["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"],
{ env: { ...NodeProcess.env, NO_COLOR: "1" } },
);
return parsePairingCredentialOutput(output);
}
function buildShowcasePairingUrl(host: string, port: number, credential: string): string {
const url = new URL(`http://${host}:${port}/`);
url.hash = new URLSearchParams([["token", credential]]).toString();
return url.toString();
}
export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {
return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
}
function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess {
return spawnProcess(
"pnpm",
["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)],
{
cwd: MOBILE_ROOT,
env: {
...MOBILE_BUILD_ENV,
EXPO_PUBLIC_SHOWCASE: "1",
},
},
);
}
async function warmMetroBundle(
platform: ShowcaseDevice["platform"],
host: string,
config: ShowcaseConfig,
): Promise<void> {
const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`;
await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]);
}
async function buildIos(): Promise<string> {
const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data");
await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], {
cwd: MOBILE_ROOT,
env: MOBILE_BUILD_ENV,
});
await runCommand(
"xcodebuild",
[
"-workspace",
NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"),
"-scheme",
"T3Code",
"-configuration",
"Debug",
"-sdk",
"iphonesimulator",
"-derivedDataPath",
derivedData,
"-quiet",
`ARCHS=${IOS_SIMULATOR_ARCH}`,
"ONLY_ACTIVE_ARCH=YES",
"build",
],
{ cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV },
);
return IOS_APP_PATH;
}
async function buildAndroid(abis: ReadonlyArray<string>): Promise<string> {
await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], {
cwd: MOBILE_ROOT,
env: MOBILE_BUILD_ENV,
});
await runCommand(
"./gradlew",
[
"app:assembleDebug",
...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []),
],
{
cwd: NodePath.join(MOBILE_ROOT, "android"),
env: MOBILE_BUILD_ENV,
},
);
return ANDROID_APK_PATH;
}
async function existingArtifact(path: string): Promise<string | null> {
return await NodeFSP.access(path).then(
() => path,
() => null,
);
}
interface SimctlDevice {
readonly name: string;
readonly udid: string;
readonly state: "Booted" | "Shutdown" | string;
readonly isAvailable: boolean;
}
async function findIosSimulator(name: string): Promise<SimctlDevice | null> {
const parsed = JSON.parse(
await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]),
) as {
readonly devices: Readonly<Record<string, ReadonlyArray<SimctlDevice>>>;
};
const candidates = Object.entries(parsed.devices)
.filter(([runtime]) => runtime.includes("iOS"))
.flatMap(([, devices]) => devices)
.filter((device) => device.isAvailable && device.name === name);
return candidates.at(-1) ?? null;
}
async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{
readonly simulator: SimctlDevice;
readonly createdByRunner: boolean;
}> {
const existing = await findIosSimulator(device.simulator);
if (existing) return { simulator: existing, createdByRunner: false };
if (!device.simulatorDeviceType) {
throw new Error(
`iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`,
);
}
const udid = (
await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType])
).trim();
if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`);
return {
simulator: {
name: device.simulator,
udid,
state: "Shutdown",
isAvailable: true,
},
createdByRunner: true,
};
}
async function iosSimulatorDataPath(udid: string): Promise<string> {
const parsed = JSON.parse(await commandOutput("xcrun", ["simctl", "list", "devices", "-j"])) as {
readonly devices: Readonly<
Record<string, ReadonlyArray<SimctlDevice & { readonly dataPath: string }>>
>;
};
const dataPath = Object.values(parsed.devices)
.flat()
.find((device) => device.udid === udid)?.dataPath;
if (!dataPath) throw new Error(`Could not resolve the data path of iOS simulator ${udid}.`);
return dataPath;
}
// generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up
// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it
// on every boot until the user dismisses it. Stamping the readiness marker
// before boot makes the daemon skip the post, and dropping the CoreFollowUp
// store clears a banner that a previous boot already queued. Runs while the
// device is shut down so the files are read fresh on the next boot.
async function suppressIosSystemFollowUps(udid: string): Promise<void> {
const dataPath = await iosSimulatorDataPath(udid);
const preferences = NodePath.join(dataPath, "Library/Preferences");
await NodeFSP.mkdir(preferences, { recursive: true });
await NodeFSP.writeFile(
NodePath.join(preferences, "com.apple.generativeexperiences.corefollowup.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>DateOfLastAppleIntelligenceReadinessCFU</key>
\t<date>2020-01-01T00:00:00Z</date>
</dict>
</plist>
`,
);
await NodeFSP.rm(NodePath.join(dataPath, "Library/CoreFollowUp"), {
recursive: true,
force: true,
});
}
async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise<void> {
await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]);
// Always-on displays (Pro Max) dim a locked screen instead of turning it
// off, which the lock-screen wake cannot tell from a lit one. Without it the
// locked display goes dark and the wake lights it fully.
await runCommand("xcrun", [
"simctl",
"spawn",
udid,
"defaults",
"write",
"com.apple.springboard",
"SBEnableAlwaysOn",
"-bool",
"false",
]);
await runCommand("xcrun", [
"simctl",
"status_bar",
udid,
"override",
"--time",
"9:41",
"--batteryState",
"charged",
"--batteryLevel",
"100",
"--wifiBars",
"3",
"--cellularBars",
"4",
]);
}
// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed
// portrait compatibility window, which defeats the in-app landscape rotation
// the capture build relies on. Switch the device to Full Screen Apps mode
// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it.
async function ensureIosFullScreenAppsMode(udid: string): Promise<void> {
const current = await commandOutput("xcrun", [
"simctl",
"spawn",
udid,
"defaults",
"read",
"com.apple.springboard",
"SBChamoisWindowingEnabled",
]).catch(() => "");
if (current.trim() === "0") return;
// The Settings toggle writes all three keys; SBChamoisWindowingEnabled
// alone is not honored on a freshly created device.
for (const key of [
"SBChamoisWindowingEnabled",
"SBMedusaMultitaskingEnabled",
"SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation",
]) {
await runCommand("xcrun", [
"simctl",
"spawn",
udid,
"defaults",
"write",
"com.apple.springboard",
key,
"-bool",
"false",
]);
}
// A SpringBoard restart is not enough on a freshly created simulator (the
// first CI run captured with windowing still active), so reboot the device
// and verify the mode actually stuck.
await runCommand("xcrun", ["simctl", "shutdown", udid]);
await runCommand("xcrun", ["simctl", "boot", udid]);
await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]);
const applied = await commandOutput("xcrun", [
"simctl",
"spawn",
udid,
"defaults",
"read",
"com.apple.springboard",
"SBChamoisWindowingEnabled",
]).catch(() => "");
if (applied.trim() !== "0") {
throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`);
}
}
async function iosAppContainer(udid: string): Promise<string> {
return (
await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])
).trim();
}
async function waitForIosShowcaseScene(
udid: string,
scene: ShowcaseScene,
timeoutMs = 90_000,
): Promise<void> {
const readyPath = NodePath.join(
await iosAppContainer(udid),
"Library/Caches",
IOS_READY_FILENAME,
);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => "");
if (readyScene.trim() === scene) return;
await delay(500);
}
throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
}
function iosAxe(): string {
return NodeProcess.env.AXE_PATH ?? "axe";
}
async function runAxe(udid: string, args: ReadonlyArray<string>): Promise<void> {
// HID events sent right after the simulator settles are dropped without it.
await runCommand(iosAxe(), [...args, "--udid", udid], {
env: { ...NodeProcess.env, AXE_HID_STABILIZATION_MS: "3000" },
}).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(
"The agent-activity scene drives the simulator with AXe. Install it with `brew tap cameroncooke/axe && brew install axe`, or set AXE_PATH.",
);
}
throw error;
});
}
async function iosSimulatorLocked(udid: string): Promise<boolean> {
const state = await commandOutput("xcrun", [
"simctl",
"spawn",
udid,
"notifyutil",
"-g",
"com.apple.springboard.lockstate",
]).catch(() => "");
return state.trim().endsWith(" 1");
}
/**
* The staging app asks for notification permission; simctl has no privacy
* service for it, so answer the prompt until the scene reports ready.
*/
async function allowIosNotificationsUntilReady(udid: string, ready: Promise<void>): Promise<void> {
const state = { settled: false };
const tapping = (async () => {
while (!state.settled) {
await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined);
if (!state.settled) await delay(1_000);
}
})();
try {
await ready;
} finally {
state.settled = true;
await tapping;
}
}
/**
* Locks the simulator over the staged Live Activity and delivers the alert a
* relay push would, so the lock screen shows both.
*/
async function presentIosLockScreen(udid: string): Promise<void> {
await runAxe(udid, ["button", "lock"]);
const deadline = Date.now() + 15_000;
while (!(await iosSimulatorLocked(udid))) {
if (Date.now() > deadline) throw new Error(`Simulator ${udid} did not lock.`);
await delay(500);
}
const alert = showcaseAgentAlert();
await new Promise<void>((resolve, reject) => {
const child = NodeChildProcess.execFile(
"xcrun",
["simctl", "push", udid, ANDROID_PACKAGE, "-"],
{ cwd: REPO_ROOT },
(error) => (error ? reject(error) : resolve()),
);
child.stdin?.end(
JSON.stringify({
aps: { alert: { title: alert.title, body: alert.body }, sound: "default" },
}),
);
});
await wakeIosLockScreen(udid);
// The first Live Activity on the lock screen asks to keep allowing them.
await delay(2_000);
await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined);
}
/**
* The alert usually wakes the display, but not always. A home press on a dark
* display only wakes it, so press only after a screenshot proves it is dark;
* pressing on a lit lock screen would unlock the device instead.
*/
async function wakeIosLockScreen(udid: string): Promise<void> {
const probe = NodePath.join(NodeOS.tmpdir(), `t3-showcase-wake-${udid}.png`);
try {
for (let attempt = 0; attempt < 5; attempt += 1) {
await delay(2_000);
await runCommand("xcrun", ["simctl", "io", udid, "screenshot", probe]);
if (!pngIsBlack(await NodeFSP.readFile(probe))) return;
await runAxe(udid, ["button", "home"]);
}
throw new Error(`Simulator ${udid} lock screen stayed dark.`);
} finally {
await NodeFSP.rm(probe, { force: true });
}
}
function pngIsBlack(bytes: Uint8Array): boolean {
const { data } = PNG.sync.read(Buffer.from(bytes));
// Sample a sparse grid; a sleeping display is uniformly black.
for (let offset = 0; offset < data.length; offset += 4 * 997) {
if ((data[offset] ?? 0) > 8 || (data[offset + 1] ?? 0) > 8 || (data[offset + 2] ?? 0) > 8) {
return false;
}
}
return true;
}
async function unlockIosSimulator(udid: string): Promise<void> {
// The first press wakes the display, the second dismisses the lock screen.
await runAxe(udid, ["button", "home"]);
await delay(2_000);
await runAxe(udid, ["button", "home"]);
}
/** Mirrors the app's staged hero row (showcaseAgentActivity.ts). */
function showcaseAgentAlert(): { readonly title: string; readonly body: string } {
const thread = SHOWCASE_THREADS.find((candidate) => candidate.id === "pocket-command-center");
const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === thread?.projectId);
if (!thread || !project) throw new Error("The showcase fixture lost its agent-activity thread.");
return { title: thread.title, body: `Approval: ${project.title}` };
}
async function captureIos(
capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice },
appPath: string | null,
outputDirectory: string,
config: ShowcaseConfig,
metroHost: string,
pairingUrls: ReadonlyArray<string>,
registerCleanup: (cleanup: IosCaptureCleanup) => void,
): Promise<void> {
const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
const startedByRunner = simulator.state !== "Booted";
registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
if (!startedByRunner) {
// Clear transient SpringBoard state (permission prompts, stale URL-open
// confirmations, keyboards) without erasing the developer's simulator.
await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]);
}
await suppressIosSystemFollowUps(simulator.udid);
await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
if (capture.device.orientation === "landscape") {
await ensureIosFullScreenAppsMode(simulator.udid);
}
await normalizeIosSimulator(capture.appearance, simulator.udid);
if (appPath) {
await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
() => undefined,
);
await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]);
}
// The dev-client launch URL carries the dev menu preferences (SDK 58), so
// nothing is written into the app container ahead of launch.
const metroUrl = `http://${metroHost}:${config.metroPort}?${DEV_CLIENT_LAUNCH_FLAGS}`;
const scenePath = NodePath.join(
await iosAppContainer(simulator.udid),
"Library/Caches/T3ShowcaseScene",
);
const readyPath = NodePath.join(
await iosAppContainer(simulator.udid),
"Library/Caches",
IOS_READY_FILENAME,
);
const firstScene = capture.scenes[0] ?? "threads";
const launchShowcaseApp = async (terminateRunningProcess: boolean) => {
await runCommand("xcrun", [
"simctl",
"launch",
...(terminateRunningProcess ? ["--terminate-running-process"] : []),
simulator.udid,
ANDROID_PACKAGE,
"--initialUrl",
metroUrl,
"--showcasePairingUrl",
JSON.stringify(pairingUrls),
"--showcaseScene",
firstScene,
"--showcaseTheme",
capture.theme,
// The app rotates itself; Simulator menu UI scripting needs macOS
// Accessibility permission that CI runners do not grant to osascript.
"--showcaseOrientation",
capture.device.orientation ?? "portrait",
]);
};
await NodeFSP.rm(readyPath, { force: true });
await NodeFSP.writeFile(scenePath, firstScene);
await launchShowcaseApp(false);
for (const [sceneIndex, scene] of capture.scenes.entries()) {
if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true });
await NodeFSP.writeFile(scenePath, scene);
const waitForScene = (timeoutMs?: number) => {
const ready = waitForIosShowcaseScene(simulator.udid, scene, timeoutMs);
return scene === "agent-activity"
? allowIosNotificationsUntilReady(simulator.udid, ready)
: ready;
};
if (sceneIndex === 0) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const isLastAttempt = attempt === 1;
try {
// A freshly installed Expo development build can spend well over 30s
// applying an already-bundled update after it reaches 100%. Killing it
// at that point sends the next capture back to the dev launcher.
await waitForScene(120_000);
break;
} catch (error) {
if (isLastAttempt) throw error;
await launchShowcaseApp(true);
}
}
} else {
await waitForScene();
}
if (scene === "agent-activity") await presentIosLockScreen(simulator.udid);
await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs);
const destination = NodePath.join(
showcaseCaptureDirectory(outputDirectory, capture),
`${scene}.png`,
);
await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
if (capture.device.orientation === "landscape") {
// A headless simulator keeps its display portrait while the rotated app
// renders sideways inside it; with Simulator.app attached the display
// itself rotates. Only post-rotate the former.
const { width, height } = readPngDimensions(await NodeFSP.readFile(destination));
if (height > width) {
await runCommand("sips", ["--rotate", "270", destination]);
}
}
if (scene === "agent-activity") await unlockIosSimulator(simulator.udid);
await finalizeCapture(destination, capture.device);
}
}
function androidSdkTool(relativePath: string): string {
return NodePath.join(ANDROID_SDK_ROOT, relativePath);
}
async function adbOutput(serial: string, args: ReadonlyArray<string>): Promise<string> {
return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
}
async function runAdb(serial: string, args: ReadonlyArray<string>): Promise<void> {
await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
}
/**
* Emulator images post their own ongoing notices (keyboard configured, serial
* console enabled) that would share the shade with the app's. Snoozing hides
* them for the capture; they come back on their own an hour later.
*/
async function snoozeAndroidSystemNotifications(serial: string): Promise<void> {
const keys = (await adbOutput(serial, ["shell", "cmd", "notification", "list"]))
.split("\n")
.map((line) => line.trim())
.filter((key) => key.includes("|") && !key.includes(`|${ANDROID_PACKAGE}|`));
for (const key of keys) {
// adb joins shell args with spaces, so the key needs quoting for the pipes.
await runAdb(serial, ["shell", `cmd notification snooze --for 3600000 '${key}'`]);
}
}
async function runningAndroidAvds(): Promise<ReadonlyMap<string, string>> {
const adb = androidSdkTool("platform-tools/adb");
const devices = (await commandOutput(adb, ["devices"]))
.split("\n")
.map((line) => line.trim().split(/\s+/u))
.filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device")
.map((parts) => parts[0] as string);
const result = new Map<string, string>();
for (const serial of devices) {
const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim();
if (avdName) result.set(avdName, serial);
}
return result;
}
async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const serial = (await runningAndroidAvds()).get(avd);
if (serial) {
await runAdb(serial, ["wait-for-device"]);
const bootCompleted = (
await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"])
).trim();
if (bootCompleted === "1") return serial;
}
await delay(1_000);
}
throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`);
}
async function normalizeAndroidEmulator(
device: ShowcaseAndroidDevice,
appearance: ShowcaseAppearance,
serial: string,
): Promise<void> {
await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]);
await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]);
await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]);
await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]);
await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]);
await runAdb(serial, ["emu", "power", "capacity", "100"]);
await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]);
await runAdb(serial, [
"shell",
"am",
"broadcast",
"-a",
"com.android.systemui.demo",
"-e",
"command",
"enter",
]);
await runAdb(serial, [
"shell",
"am",
"broadcast",
"-a",
"com.android.systemui.demo",
"-e",
"command",
"clock",
"-e",
"hhmm",
"0941",
]);
await runAdb(serial, [
"shell",
"am",
"broadcast",
"-a",
"com.android.systemui.demo",
"-e",
"command",
"battery",
"-e",
"level",
"100",
"-e",
"plugged",
"false",
]);
if (device.viewport) {
await runAdb(serial, [
"shell",
"wm",
"size",
`${device.viewport.width}x${device.viewport.height}`,
]);
if (device.viewport.density) {
await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]);
}
}
}
async function waitForAndroidShowcaseScene(
serial: string,
scene: ShowcaseScene,
timeoutMs = 90_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const readyScene = await adbOutput(serial, [
"shell",
"run-as",
ANDROID_PACKAGE,
"cat",
"files/t3-showcase-ready",
]).catch(() => "");
if (readyScene.trim() === scene) return;
await delay(500);
}
throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
}
async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise<void> {
await runAdb(serial, [
"shell",
`run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`,
]);
}
// Gesture and key-command toggles have no launch-URL flag, so they still go
// through the preferences file; onboarding, auto-launch and the floating button
// come from DEV_CLIENT_LAUNCH_FLAGS on the launch URL.
async function prepareAndroidShowcaseApp(serial: string): Promise<void> {
const preferences = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<map>
<boolean name="motionGestureEnabled" value="false" />
<boolean name="touchGestureEnabled" value="false" />
<boolean name="keyCommandsEnabled" value="false" />
</map>`;
const encodedPreferences = Buffer.from(preferences).toString("base64");
await runAdb(serial, [
"shell",
`run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`,
]);
}
async function captureAndroid(
capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
apkPath: string | null,
outputDirectory: string,
config: ShowcaseConfig,
pairingUrls: ReadonlyArray<string>,
registerCleanup: (cleanup: AndroidCaptureCleanup) => void,
): Promise<void> {
const running = await runningAndroidAvds();
const existingSerial = running.get(capture.device.avd);
const startedByRunner = !existingSerial;
let launchedEmulator: NodeChildProcess.ChildProcess | null = null;
if (startedByRunner) {
const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"]))
.split("\n")
.map((value) => value.trim());
if (!installedAvds.includes(capture.device.avd)) {
throw new Error(
`Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`,
);
}
launchedEmulator = spawnProcess(
androidSdkTool("emulator/emulator"),
["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"],
{ stdio: "ignore", detached: true },
);
launchedEmulator.unref();
}
const serial =
existingSerial ??
(await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => {
if (launchedEmulator) await stopProcess(launchedEmulator);
throw error;
}));
registerCleanup({ device: capture.device, serial, startedByRunner });
await normalizeAndroidEmulator(capture.device, capture.appearance, serial);
if (apkPath) {
await runAdb(serial, ["install", "-r", apkPath]);
}
await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]);
// The agent-activity scene posts notifications; granting up front keeps the
// runtime prompt off every capture.
await runAdb(serial, [
"shell",
"pm",
"grant",
ANDROID_PACKAGE,
"android.permission.POST_NOTIFICATIONS",
]);
await prepareAndroidShowcaseApp(serial);
await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]);
const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`);
const firstScene = capture.scenes[0] ?? "threads";
await runAdb(serial, [
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
`'${APP_SCHEME}://expo-development-client/?url=${metroUrl}&${DEV_CLIENT_LAUNCH_FLAGS}'`,
"--es",
"showcasePairingUrl",
encodeAndroidPairingUrls(pairingUrls),
"--es",
"showcaseScene",
firstScene,
"--es",
"showcaseTheme",
capture.theme,
ANDROID_PACKAGE,
]);
for (const [sceneIndex, scene] of capture.scenes.entries()) {
if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene);
await waitForAndroidShowcaseScene(serial, scene);
if (scene === "agent-activity") {
await snoozeAndroidSystemNotifications(serial);
await runAdb(serial, ["shell", "cmd", "statusbar", "expand-notifications"]);
}
await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000));
const destination = NodePath.join(
showcaseCaptureDirectory(outputDirectory, capture),
`${scene}.png`,
);
const png = await new Promise<Buffer>((resolve, reject) => {
NodeChildProcess.execFile(
androidSdkTool("platform-tools/adb"),
["-s", serial, "exec-out", "screencap", "-p"],
{ cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
(error, stdout) => {
if (error) reject(error);
else resolve(stdout);
},
);
});
await NodeFSP.writeFile(destination, png);
if (scene === "agent-activity") {
await runAdb(serial, ["shell", "cmd", "statusbar", "collapse"]);
}
await finalizeCapture(destination, capture.device);
}
}
async function cleanupAndroidViewport(
device: ShowcaseAndroidDevice,
serial: string,
): Promise<void> {
await runAdb(serial, [
"shell",
"am",
"broadcast",
"-a",
"com.android.systemui.demo",
"-e",
"command",
"exit",
]);
if (!device.viewport) return;
await runAdb(serial, ["shell", "wm", "size", "reset"]);
if (device.viewport.density) {
await runAdb(serial, ["shell", "wm", "density", "reset"]);
}
}
async function main(): Promise<void> {
const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2));
if (options.list) {
printUsage(showcaseConfig);
return;
}
const captures = planShowcaseCaptures(showcaseConfig, options);
const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory);
if (options.validateOnly) {
for (const capture of captures) {
await validateCaptureSet(
capture,
outputDirectory,
capture.scenes.length === capture.device.scenes.length,
);
}
return;
}
const hasIos = captures.some((capture) => capture.device.platform === "ios");
const hasAndroid = captures.some((capture) => capture.device.platform === "android");
const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1";
await NodeFSP.mkdir(outputDirectory, { recursive: true });
for (const capture of captures) {
const directory = showcaseCaptureDirectory(outputDirectory, capture);
await NodeFSP.rm(directory, { recursive: true, force: true });
await NodeFSP.mkdir(directory, { recursive: true });
}
const showcaseRootDir = await NodeFSP.mkdtemp(
NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"),
);
const showcaseServers: NodeChildProcess.ChildProcess[] = [];
const showcaseEnvironments: Array<{
readonly baseDir: string;
readonly environmentId: string;
readonly label: string;
readonly port: number;
}> = [];
let metro: NodeChildProcess.ChildProcess | null = null;
const iosCleanups: IosCaptureCleanup[] = [];
const androidCleanups: AndroidCaptureCleanup[] = [];
try {
for (const environment of SHOWCASE_ENVIRONMENTS) {
const projectId = environment.projectIds[0];
const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId);
if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`);
const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id);
const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory);
const port = await reserveAvailablePort();
await NodeFSP.mkdir(workspaceRoot, { recursive: true });
const shellPath = await createShowcaseShell(baseDir);
const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label);
const server = startShowcaseServer(
baseDir,
workspaceRoot,
port,
shellPath,
labelProbeDirectory,
);
showcaseServers.push(server);
await waitForPort(port, `${environment.label} server`);
await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds });
// The server begins listening before the ServerEnvironment layer
// persists the environment id, so poll rather than read once.
const environmentId = await waitForFileContent(
NodePath.join(baseDir, "userdata", "environment-id"),
`${environment.label} environment id`,
);
showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port });
}
if (!options.skipMetro) {
metro = startMetro(showcaseConfig);
await waitForPort(showcaseConfig.metroPort, "Metro");
await Promise.all([
hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(),
hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(),
]);
}
const iosAppPath = hasIos
? options.skipBuild
? await existingArtifact(IOS_APP_PATH)
: await buildIos()
: null;
const androidAbis = captures.flatMap((capture) =>
capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [],
);
const androidApkPath = hasAndroid
? options.skipBuild
? await existingArtifact(ANDROID_APK_PATH)
: await buildAndroid([...new Set(androidAbis)])
: null;
for (const capture of captures) {
const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2";
const pairingUrls = await Promise.all(
showcaseEnvironments.map(async (environment) => {
const credential = await issuePairingCredential(environment.baseDir);
return buildShowcasePairingUrl(pairingHost, environment.port, credential);
}),
);
if (capture.device.platform === "ios") {
await captureIos(
capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice },
iosAppPath,
outputDirectory,
showcaseConfig,
metroHost,
pairingUrls,
(cleanup) => iosCleanups.push(cleanup),
);
} else {
await captureAndroid(
capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
androidApkPath,
outputDirectory,
showcaseConfig,
pairingUrls,
(cleanup) => androidCleanups.push(cleanup),
);
}
await validateCaptureSet(
capture,
outputDirectory,
capture.scenes.length === capture.device.scenes.length,
);
}
NodeProcess.stdout.write(
`\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`,
);
if (options.keepRunning) {
const serverSummary = showcaseEnvironments
.map((environment) => `${environment.label}:${environment.port}`)
.join(", ");
NodeProcess.stdout.write(
`Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`,
);
}
} finally {
if (options.keepRunning) {
metro?.unref();
for (const server of showcaseServers) server.unref();
} else {
for (const cleanup of androidCleanups) {
await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined);
if (cleanup.startedByRunner) {
await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined);
}
}
for (const cleanup of iosCleanups) {
if (cleanup.startedByRunner || cleanup.createdByRunner) {
await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
}
if (cleanup.createdByRunner) {
await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined);
}
}
await Promise.all([
...(metro ? [stopProcess(metro)] : []),
...showcaseServers.map((server) => stopProcess(server)),
]);
await NodeFSP.rm(showcaseRootDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
}
}
if (import.meta.main) {
void main().catch((error: unknown) => {
// Stack over message: the harness only fails in CI, where the line that
// threw is the whole diagnosis and there is nobody at a terminal to
// re-run it with more output.
NodeProcess.stderr.write(
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
NodeProcess.exit(1);
});
}