mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-09 20:01:15 +02:00
1674 lines
56 KiB
JavaScript
1674 lines
56 KiB
JavaScript
#!/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);
|
||
});
|
||
}
|