# scripts/mobile-showcase.ts · screen/t3code-personal

[View on GitCafe](https://git.cafe/screen/t3code-personal/blob/c9a0e8a119271765196dec38ef91395713da4213/scripts/mobile-showcase.ts)

Repository: [screen/t3code-personal](https://git.cafe/screen/t3code-personal)

Visibility: public

Requested revision: c9a0e8a119271765196dec38ef91395713da4213

Requested commit: c9a0e8a119271765196dec38ef91395713da4213

Commit: c9a0e8a119271765196dec38ef91395713da4213

Blob: 2b141e12164f17cdea99f3b2eac40dc3cd6f3d97

Size: 57642 bytes

[Immutable source](https://git.cafe/screen/t3code-personal/blob/c9a0e8a119271765196dec38ef91395713da4213/scripts/mobile-showcase.ts?format=markdown)

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

  for (const [key, value] of [
    ["EXDevMenuIsOnboardingFinished", "true"],
    ["EXDevMenuShowFloatingActionButton", "false"],
    ["EXDevMenuShowsAtLaunch", "false"],
  ] as const) {
    await runCommand("xcrun", [
      "simctl",
      "spawn",
      simulator.udid,
      "defaults",
      "write",
      ANDROID_PACKAGE,
      key,
      "-bool",
      value,
    ]);
  }

  const metroUrl = `http://${metroHost}:${config.metroPort}?disableOnboarding=1`;
  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'`,
  ]);
}

async function prepareAndroidShowcaseApp(serial: string): Promise<void> {
  const preferences = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<map>
  <boolean name="isOnboardingFinished" value="true" />
  <boolean name="showsAtLaunch" value="false" />
  <boolean name="showFab" value="false" />
  <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}`,
    "--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);
  });
}

```
