1475 lines · 48.6 KB · TypeScript
RawDownload
#!/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_TERMINAL_ID,  SHOWCASE_THREAD_ID,  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 ?? "";  return NodePath.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));}

Lines 1–500 of 1475.