1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly.
3
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeNet from "node:net";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeProcess from "node:process";
10import * as NodeURL from "node:url";
11
12import { PNG } from "pngjs";
13
14import showcaseConfig, {
15 type ShowcaseAppearance,
16 type ShowcaseAndroidDevice,
17 type ShowcaseConfig,
18 type ShowcaseDevice,
19 type ShowcaseIosDevice,
20 type ShowcaseStoreAssetSpec,
21 SHOWCASE_SCENES,
22 type ShowcaseScene,
23 SHOWCASE_THEMES,
24 type ShowcaseTheme,
25} from "./mobile-showcase.config.ts";
26import {
27 SHOWCASE_ENVIRONMENTS,
28 SHOWCASE_PROJECTS,
29 SHOWCASE_THREADS,
30 seedShowcaseEnvironment,
31} from "./mobile-showcase-environment.ts";
32
33const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
34const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");
35const ANDROID_PACKAGE = "com.t3tools.t3code";
36const APP_SCHEME = "t3code";
37// expo-dev-launcher reads these off the manifest URL and updates the dev menu
38// preferences before the app loads, keeping captures free of dev chrome.
39const DEV_CLIENT_LAUNCH_FLAGS = "disableOnboarding=1&disableFab=1&disableAutoLaunch=1";
40const IOS_READY_FILENAME = "T3ShowcaseReadyScene";
41const SERVER_HOST = "0.0.0.0";
42const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";
43const IOS_APP_PATH = NodePath.join(
44 MOBILE_ROOT,
45 ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/TheKnife.app",
46);
47const ANDROID_APK_PATH = NodePath.join(
48 MOBILE_ROOT,
49 "android/app/build/outputs/apk/debug/app-debug.apk",
50);
51export function resolveAndroidSdkRoot(
52 environment: Readonly<Record<string, string | undefined>>,
53 platform: NodeJS.Platform = NodeProcess.platform,
54): string {
55 const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT;
56 if (configured) return configured;
57 const home = environment.HOME ?? environment.USERPROFILE ?? "";
58 const path = platform === "win32" ? NodePath.win32 : NodePath.posix;
59 return path.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk");
60}
61
62const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
63const MOBILE_BUILD_ENV = {
64 ...NodeProcess.env,
65 ANDROID_HOME: ANDROID_SDK_ROOT,
66 APP_VARIANT: "production",
67 EXPO_NO_GIT_STATUS: "1",
68 // Lets the capture build require full screen on iPad so the app can rotate
69 // itself to landscape (see app.config.ts).
70 T3_SHOWCASE_CAPTURE_BUILD: "1",
71 JAVA_HOME:
72 NodeProcess.env.JAVA_HOME ??
73 (NodeProcess.platform === "darwin"
74 ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
75 : undefined),
76 NODE_ENV: "development",
77};
78
79interface CliOptions {
80 readonly platforms: ReadonlySet<ShowcaseDevice["platform"]>;
81 readonly deviceIds: ReadonlySet<string>;
82 readonly scenes: ReadonlySet<ShowcaseScene>;
83 readonly appearances: ReadonlySet<ShowcaseAppearance>;
84 readonly themes: ReadonlySet<ShowcaseTheme>;
85 readonly skipBuild: boolean;
86 readonly skipMetro: boolean;
87 readonly keepRunning: boolean;
88 readonly validateOnly: boolean;
89 readonly list: boolean;
90}
91
92export interface ShowcaseCapture {
93 readonly device: ShowcaseDevice;
94 readonly scenes: ReadonlyArray<ShowcaseScene>;
95 readonly appearance: ShowcaseAppearance;
96 readonly theme: ShowcaseTheme;
97}
98
99interface IosCaptureCleanup {
100 readonly udid: string;
101 readonly startedByRunner: boolean;
102 readonly createdByRunner: boolean;
103}
104
105interface AndroidCaptureCleanup {
106 readonly device: ShowcaseAndroidDevice;
107 readonly serial: string;
108 readonly startedByRunner: boolean;
109}
110
111interface NetworkAddress {
112 readonly address: string;
113 readonly family: string;
114 readonly internal: boolean;
115}
116
117export function selectLanIpv4Address(addresses: ReadonlyArray<NetworkAddress>): string | null {
118 return (
119 addresses.find(
120 ({ address, family, internal }) =>
121 family === "IPv4" && !internal && !address.startsWith("169.254."),
122 )?.address ?? null
123 );
124}
125
126function lanIpv4Address(): string {
127 const address = selectLanIpv4Address(
128 Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []),
129 );
130 if (!address) {
131 throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro.");
132 }
133 return address;
134}
135
136export interface PngMetadata {
137 readonly width: number;
138 readonly height: number;
139 readonly bitDepth: number;
140 readonly colorType: number;
141 readonly hasAlpha: boolean;
142}
143
144export function readPngMetadata(bytes: Uint8Array): PngMetadata {
145 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
146 if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) {
147 throw new Error("Captured file is not a valid PNG.");
148 }
149 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
150 const colorType = view.getUint8(25);
151 return {
152 width: view.getUint32(16),
153 height: view.getUint32(20),
154 bitDepth: view.getUint8(24),
155 colorType,
156 hasAlpha: colorType === 4 || colorType === 6,
157 };
158}
159
160export function readPngDimensions(bytes: Uint8Array): {
161 readonly width: number;
162 readonly height: number;
163} {
164 const { width, height } = readPngMetadata(bytes);
165 return { width, height };
166}
167
168export function normalizeStorePng(bytes: Uint8Array): Buffer {
169 const png = PNG.sync.read(Buffer.from(bytes));
170 return PNG.sync.write(png, {
171 bitDepth: 8,
172 colorType: 2,
173 inputColorType: 6,
174 inputHasAlpha: true,
175 });
176}
177
178export function validateStoreAsset(
179 spec: ShowcaseStoreAssetSpec,
180 bytes: Uint8Array,
181 label = "Screenshot",
182): PngMetadata {
183 const metadata = readPngMetadata(bytes);
184 if (metadata.width !== spec.width || metadata.height !== spec.height) {
185 throw new Error(
186 `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`,
187 );
188 }
189 if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) {
190 throw new Error(
191 `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`,
192 );
193 }
194 if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) {
195 throw new Error(
196 `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`,
197 );
198 }
199 if (spec.store === "google-play") {
200 const shortestSide = Math.min(metadata.width, metadata.height);
201 const longestSide = Math.max(metadata.width, metadata.height);
202 if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) {
203 throw new Error(
204 `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`,
205 );
206 }
207 if (metadata.width * 16 !== metadata.height * 9) {
208 throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`);
209 }
210 }
211 return metadata;
212}
213
214export function validateStoreAssetCount(
215 spec: ShowcaseStoreAssetSpec,
216 count: number,
217 requireMinimum: boolean,
218): void {
219 if (count > spec.maximumUploadCount) {
220 throw new Error(
221 `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`,
222 );
223 }
224 if (requireMinimum && count < spec.minimumUploadCount) {
225 throw new Error(
226 `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`,
227 );
228 }
229}
230
231export function showcaseCaptureDirectory(
232 outputDirectory: string,
233 capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">,
234): string {
235 // Each palette owns a leaf folder so one upload slot never mixes themes and
236 // every folder keeps a store-legal screenshot count of its own.
237 return NodePath.join(
238 outputDirectory,
239 capture.device.storeAsset.directory,
240 capture.appearance,
241 capture.theme,
242 );
243}
244
245async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {
246 const normalized = normalizeStorePng(await NodeFSP.readFile(destination));
247 await NodeFSP.writeFile(destination, normalized);
248 const metadata = validateStoreAsset(
249 device.storeAsset,
250 normalized,
251 NodePath.basename(destination),
252 );
253 NodeProcess.stdout.write(
254 `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`,
255 );
256}
257
258async function validateCaptureSet(
259 capture: ShowcaseCapture,
260 outputDirectory: string,
261 requireMinimum: boolean,
262): Promise<void> {
263 const directory = showcaseCaptureDirectory(outputDirectory, capture);
264 const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort();
265 const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort();
266 const missingFiles = expectedFiles.filter((file) => !files.includes(file));
267 if (missingFiles.length > 0) {
268 throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`);
269 }
270 validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum);
271 for (const file of files) {
272 const bytes = await NodeFSP.readFile(NodePath.join(directory, file));
273 validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`);
274 }
275 NodeProcess.stdout.write(
276 `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`,
277 );
278}
279
280function argumentValue(args: ReadonlyArray<string>, index: number, flag: string): string {
281 const value = args[index + 1];
282 if (!value || value.startsWith("--")) {
283 throw new Error(`${flag} requires a value.`);
284 }
285 return value;
286}
287
288export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
289 const platforms = new Set<ShowcaseDevice["platform"]>();
290 const deviceIds = new Set<string>();
291 const scenes = new Set<ShowcaseScene>();
292 const appearances = new Set<ShowcaseAppearance>();
293 const themes = new Set<ShowcaseTheme>();
294 let skipBuild = false;
295 let skipMetro = false;
296 let keepRunning = false;
297 let validateOnly = false;
298 let list = false;
299
300 for (let index = 0; index < args.length; index += 1) {
301 const argument = args[index];
302 if (argument === "--platform") {
303 const value = argumentValue(args, index, argument);
304 if (value !== "ios" && value !== "android" && value !== "all") {
305 throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`);
306 }
307 if (value === "all") {
308 platforms.add("ios");
309 platforms.add("android");
310 } else {
311 platforms.add(value);
312 }
313 index += 1;
314 } else if (argument === "--device") {
315 deviceIds.add(argumentValue(args, index, argument));
316 index += 1;
317 } else if (argument === "--scene") {
318 const value = argumentValue(args, index, argument);
319 if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) {
320 throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`);
321 }
322 scenes.add(value as ShowcaseScene);
323 index += 1;
324 } else if (argument === "--appearance") {
325 const value = argumentValue(args, index, argument);
326 if (value !== "light" && value !== "dark" && value !== "both") {
327 throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`);
328 }
329 if (value === "both") {
330 appearances.add("light");
331 appearances.add("dark");
332 } else {
333 appearances.add(value);
334 }
335 index += 1;
336 } else if (argument === "--theme") {
337 const value = argumentValue(args, index, argument);
338 if (value === "all") {
339 for (const theme of SHOWCASE_THEMES) themes.add(theme);
340 } else if (SHOWCASE_THEMES.some((theme) => theme === value)) {
341 themes.add(value as ShowcaseTheme);
342 } else {
343 // The app silently falls back to its default palette for an unknown id,
344 // so reject it here rather than shipping a mislabeled screenshot.
345 throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`);
346 }
347 index += 1;
348 } else if (argument === "--skip-build") {
349 skipBuild = true;
350 } else if (argument === "--skip-metro") {
351 skipMetro = true;
352 } else if (argument === "--keep-running") {
353 keepRunning = true;
354 } else if (argument === "--validate-only") {
355 validateOnly = true;
356 } else if (argument === "--list") {
357 list = true;
358 } else if (argument === "--help" || argument === "-h") {
359 list = true;
360 } else {
361 throw new Error(`Unknown option '${argument}'.`);
362 }
363 }
364
365 return {
366 platforms,
367 deviceIds,
368 scenes,
369 appearances,
370 themes,
371 skipBuild,
372 skipMetro,
373 keepRunning,
374 validateOnly,
375 list,
376 };
377}
378
379export function planShowcaseCaptures(
380 config: ShowcaseConfig,
381 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">,
382): ReadonlyArray<ShowcaseCapture> {
383 const captures = config.devices
384 .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
385 .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id))
386 .flatMap((device) => {
387 const appearances =
388 options.appearances.size === 0 ? [device.appearance] : options.appearances;
389 const themes = options.themes.size === 0 ? [device.theme] : options.themes;
390 return [...appearances].flatMap((appearance) =>
391 [...themes].map((theme) => ({
392 device,
393 appearance,
394 theme,
395 scenes:
396 options.scenes.size === 0
397 ? device.scenes
398 : device.scenes.filter((scene) => options.scenes.has(scene)),
399 })),
400 );
401 })
402 .filter((capture) => capture.scenes.length > 0);
403
404 const knownDeviceIds = new Set(config.devices.map((device) => device.id));
405 for (const id of options.deviceIds) {
406 if (!knownDeviceIds.has(id)) {
407 throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`);
408 }
409 }
410 if (captures.length === 0) {
411 throw new Error("No captures match the selected platform, device, and scene filters.");
412 }
413 return captures;
414}
415
416function printUsage(config: ShowcaseConfig): void {
417 NodeProcess.stdout.write(`App screenshot showcase
418
419Usage:
420 pnpm --filter @t3tools/mobile screenshots [options]
421
422Options:
423 --platform ios|android|all Capture one platform (repeatable)
424 --device <id> Capture one configured device (repeatable)
425 --scene <name> Capture one scene (repeatable)
426 --appearance light|dark|both
427 Override the configured appearance
428 --theme <id>|all Override the configured palette (repeatable)
429 --skip-build Reuse the existing simulator app / debug APK
430 --skip-metro Reuse an already running showcase Metro server
431 --keep-running Leave devices and Metro running after capture
432 --validate-only Validate existing upload assets without capturing
433 --list Print this help and the configured matrix
434
435Scenes: ${SHOWCASE_SCENES.join(", ")}
436Themes: ${SHOWCASE_THEMES.join(", ")}
437
438Configured devices:
439${config.devices
440 .map((device) => {
441 const target = device.platform === "ios" ? device.simulator : device.avd;
442 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(", ")}]`;
443 })
444 .join("\n")}
445`);
446}
447
448function spawnProcess(
449 command: string,
450 args: ReadonlyArray<string>,
451 options: NodeChildProcess.SpawnOptions = {},
452): NodeChildProcess.ChildProcess {
453 return NodeChildProcess.spawn(command, args, {
454 cwd: REPO_ROOT,
455 env: NodeProcess.env,
456 stdio: "inherit",
457 ...options,
458 });
459}
460
461async function runCommand(
462 command: string,
463 args: ReadonlyArray<string>,
464 options: NodeChildProcess.SpawnOptions = {},
465): Promise<void> {
466 await new Promise<void>((resolve, reject) => {
467 const child = spawnProcess(command, args, options);
468 child.once("error", reject);
469 child.once("exit", (code, signal) => {
470 if (code === 0) {
471 resolve();
472 } else {
473 reject(
474 new Error(
475 `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`,
476 ),
477 );
478 }
479 });
480 });
481}
482
483async function commandOutput(
484 command: string,
485 args: ReadonlyArray<string>,
486 options: NodeChildProcess.ExecFileOptions = {},
487): Promise<string> {
488 return await new Promise<string>((resolve, reject) => {
489 NodeChildProcess.execFile(
490 command,
491 [...args],
492 { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options },
493 (error, stdout) => {
494 if (error) reject(error);
495 else resolve(String(stdout));
496 },
497 );
498 });
499}
500
501function delay(milliseconds: number): Promise<void> {
502 return new Promise((resolve) => setTimeout(resolve, milliseconds));
503}
504
505async function stopProcess(child: NodeChildProcess.ChildProcess): Promise<void> {
506 if (child.exitCode !== null || child.signalCode !== null) return;
507
508 const exited = new Promise<void>((resolve) => {
509 child.once("exit", () => resolve());
510 });
511 child.kill("SIGTERM");
512 await Promise.race([exited, delay(5_000)]);
513 if (child.exitCode !== null || child.signalCode !== null) return;
514
515 child.kill("SIGKILL");
516 await Promise.race([exited, delay(1_000)]);
517}
518
519async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise<void> {
520 const deadline = Date.now() + timeoutMs;
521 while (Date.now() < deadline) {
522 const open = await new Promise<boolean>((resolve) => {
523 const socket = NodeNet.createConnection({ host: "127.0.0.1", port });
524 socket.once("connect", () => {
525 socket.destroy();
526 resolve(true);
527 });
528 socket.once("error", () => resolve(false));
529 socket.setTimeout(500, () => {
530 socket.destroy();
531 resolve(false);
532 });
533 });
534 if (open) return;
535 await delay(500);
536 }
537 throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`);
538}
539
540async function waitForFileContent(
541 filePath: string,
542 label: string,
543 timeoutMs = 60_000,
544): Promise<string> {
545 const deadline = Date.now() + timeoutMs;
546 while (Date.now() < deadline) {
547 const content = await NodeFSP.readFile(filePath, "utf8").then(
548 (value) => value.trim(),
549 () => "",
550 );
551 if (content) return content;
552 await delay(250);
553 }
554 throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`);
555}
556
557async function reserveAvailablePort(): Promise<number> {
558 return await new Promise<number>((resolve, reject) => {
559 const server = NodeNet.createServer();
560 server.once("error", reject);
561 server.listen(0, "127.0.0.1", () => {
562 const address = server.address();
563 if (!address || typeof address === "string") {
564 server.close();
565 reject(new Error("Could not reserve a local port for the showcase environment."));
566 return;
567 }
568 server.close((error) => (error ? reject(error) : resolve(address.port)));
569 });
570 });
571}
572
573async function createShowcaseShell(baseDir: string): Promise<string> {
574 const shellPath = NodePath.join(baseDir, "showcase-shell");
575 await NodeFSP.writeFile(
576 shellPath,
577 `#!/bin/sh
578if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then
579 exec /bin/sh -c "$2"
580fi
581exec /bin/cat
582`,
583 { mode: 0o755 },
584 );
585 return shellPath;
586}
587
588async function createShowcaseLabelProbe(baseDir: string, label: string): Promise<string> {
589 const binDirectory = NodePath.join(baseDir, "showcase-bin");
590 await NodeFSP.mkdir(binDirectory, { recursive: true });
591 const probeScript = `#!/bin/sh
592if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then
593 printf '%s\\n' ${JSON.stringify(label)}
594 exit 0
595fi
596if [ "$1" = "--pretty" ]; then
597 printf '%s\\n' ${JSON.stringify(label)}
598 exit 0
599fi
600exit 1
601`;
602 await Promise.all(
603 ["scutil", "hostnamectl"].map((executable) =>
604 NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }),
605 ),
606 );
607 return binDirectory;
608}
609
610function startShowcaseServer(
611 baseDir: string,
612 workspaceRoot: string,
613 port: number,
614 shellPath: string,
615 labelProbeDirectory: string,
616): NodeChildProcess.ChildProcess {
617 return spawnProcess(
618 "node",
619 [
620 "apps/server/src/bin.ts",
621 "serve",
622 "--host",
623 SERVER_HOST,
624 "--port",
625 String(port),
626 "--base-dir",
627 baseDir,
628 "--no-browser",
629 "--log-level",
630 "error",
631 workspaceRoot,
632 ],
633 {
634 env: {
635 ...NodeProcess.env,
636 PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`,
637 SHELL: shellPath,
638 },
639 },
640 );
641}
642
643export function parsePairingCredentialOutput(output: string): string {
644 const jsonStart = output.indexOf("{");
645 const jsonEnd = output.lastIndexOf("}");
646 if (jsonStart === -1 || jsonEnd < jsonStart) {
647 throw new Error("Pairing credential command did not return JSON.");
648 }
649 const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as {
650 readonly credential?: unknown;
651 };
652 if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
653 throw new Error("Pairing credential command returned no credential.");
654 }
655 return parsed.credential;
656}
657
658async function issuePairingCredential(baseDir: string): Promise<string> {
659 const output = await commandOutput(
660 "node",
661 ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"],
662 { env: { ...NodeProcess.env, NO_COLOR: "1" } },
663 );
664 return parsePairingCredentialOutput(output);
665}
666
667function buildShowcasePairingUrl(host: string, port: number, credential: string): string {
668 const url = new URL(`http://${host}:${port}/`);
669 url.hash = new URLSearchParams([["token", credential]]).toString();
670 return url.toString();
671}
672
673export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {
674 return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
675}
676
677function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess {
678 return spawnProcess(
679 "pnpm",
680 ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)],
681 {
682 cwd: MOBILE_ROOT,
683 env: {
684 ...MOBILE_BUILD_ENV,
685 EXPO_PUBLIC_SHOWCASE: "1",
686 },
687 },
688 );
689}
690
691async function warmMetroBundle(
692 platform: ShowcaseDevice["platform"],
693 host: string,
694 config: ShowcaseConfig,
695): Promise<void> {
696 const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`;
697 await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]);
698}
699
700async function buildIos(): Promise<string> {
701 const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data");
702 await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], {
703 cwd: MOBILE_ROOT,
704 env: MOBILE_BUILD_ENV,
705 });
706 await runCommand(
707 "xcodebuild",
708 [
709 "-workspace",
710 NodePath.join(MOBILE_ROOT, "ios/TheKnife.xcworkspace"),
711 "-scheme",
712 "TheKnife",
713 "-configuration",
714 "Debug",
715 "-sdk",
716 "iphonesimulator",
717 "-derivedDataPath",
718 derivedData,
719 "-quiet",
720 `ARCHS=${IOS_SIMULATOR_ARCH}`,
721 "ONLY_ACTIVE_ARCH=YES",
722 "build",
723 ],
724 { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV },
725 );
726 return IOS_APP_PATH;
727}
728
729async function buildAndroid(abis: ReadonlyArray<string>): Promise<string> {
730 await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], {
731 cwd: MOBILE_ROOT,
732 env: MOBILE_BUILD_ENV,
733 });
734 await runCommand(
735 "./gradlew",
736 [
737 "app:assembleDebug",
738 ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []),
739 ],
740 {
741 cwd: NodePath.join(MOBILE_ROOT, "android"),
742 env: MOBILE_BUILD_ENV,
743 },
744 );
745 return ANDROID_APK_PATH;
746}
747
748async function existingArtifact(path: string): Promise<string | null> {
749 return await NodeFSP.access(path).then(
750 () => path,
751 () => null,
752 );
753}
754
755interface SimctlDevice {
756 readonly name: string;
757 readonly udid: string;
758 readonly state: "Booted" | "Shutdown" | string;
759 readonly isAvailable: boolean;
760}
761
762async function findIosSimulator(name: string): Promise<SimctlDevice | null> {
763 const parsed = JSON.parse(
764 await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]),
765 ) as {
766 readonly devices: Readonly<Record<string, ReadonlyArray<SimctlDevice>>>;
767 };
768 const candidates = Object.entries(parsed.devices)
769 .filter(([runtime]) => runtime.includes("iOS"))
770 .flatMap(([, devices]) => devices)
771 .filter((device) => device.isAvailable && device.name === name);
772 return candidates.at(-1) ?? null;
773}
774
775async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{
776 readonly simulator: SimctlDevice;
777 readonly createdByRunner: boolean;
778}> {
779 const existing = await findIosSimulator(device.simulator);
780 if (existing) return { simulator: existing, createdByRunner: false };
781 if (!device.simulatorDeviceType) {
782 throw new Error(
783 `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`,
784 );
785 }
786 const udid = (
787 await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType])
788 ).trim();
789 if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`);
790 return {
791 simulator: {
792 name: device.simulator,
793 udid,
794 state: "Shutdown",
795 isAvailable: true,
796 },
797 createdByRunner: true,
798 };
799}
800
801async function iosSimulatorDataPath(udid: string): Promise<string> {
802 const parsed = JSON.parse(await commandOutput("xcrun", ["simctl", "list", "devices", "-j"])) as {
803 readonly devices: Readonly<
804 Record<string, ReadonlyArray<SimctlDevice & { readonly dataPath: string }>>
805 >;
806 };
807 const dataPath = Object.values(parsed.devices)
808 .flat()
809 .find((device) => device.udid === udid)?.dataPath;
810 if (!dataPath) throw new Error(`Could not resolve the data path of iOS simulator ${udid}.`);
811 return dataPath;
812}
813
814// generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up
815// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it
816// on every boot until the user dismisses it. Stamping the readiness marker
817// before boot makes the daemon skip the post, and dropping the CoreFollowUp
818// store clears a banner that a previous boot already queued. Runs while the
819// device is shut down so the files are read fresh on the next boot.
820async function suppressIosSystemFollowUps(udid: string): Promise<void> {
821 const dataPath = await iosSimulatorDataPath(udid);
822 const preferences = NodePath.join(dataPath, "Library/Preferences");
823 await NodeFSP.mkdir(preferences, { recursive: true });
824 await NodeFSP.writeFile(
825 NodePath.join(preferences, "com.apple.generativeexperiences.corefollowup.plist"),
826 `<?xml version="1.0" encoding="UTF-8"?>
827<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
828<plist version="1.0">
829<dict>
830\t<key>DateOfLastAppleIntelligenceReadinessCFU</key>
831\t<date>2020-01-01T00:00:00Z</date>
832</dict>
833</plist>
834`,
835 );
836 await NodeFSP.rm(NodePath.join(dataPath, "Library/CoreFollowUp"), {
837 recursive: true,
838 force: true,
839 });
840}
841
842async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise<void> {
843 await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]);
844 // Always-on displays (Pro Max) dim a locked screen instead of turning it
845 // off, which the lock-screen wake cannot tell from a lit one. Without it the
846 // locked display goes dark and the wake lights it fully.
847 await runCommand("xcrun", [
848 "simctl",
849 "spawn",
850 udid,
851 "defaults",
852 "write",
853 "com.apple.springboard",
854 "SBEnableAlwaysOn",
855 "-bool",
856 "false",
857 ]);
858 await runCommand("xcrun", [
859 "simctl",
860 "status_bar",
861 udid,
862 "override",
863 "--time",
864 "9:41",
865 "--batteryState",
866 "charged",
867 "--batteryLevel",
868 "100",
869 "--wifiBars",
870 "3",
871 "--cellularBars",
872 "4",
873 ]);
874}
875
876// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed
877// portrait compatibility window, which defeats the in-app landscape rotation
878// the capture build relies on. Switch the device to Full Screen Apps mode
879// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it.
880async function ensureIosFullScreenAppsMode(udid: string): Promise<void> {
881 const current = await commandOutput("xcrun", [
882 "simctl",
883 "spawn",
884 udid,
885 "defaults",
886 "read",
887 "com.apple.springboard",
888 "SBChamoisWindowingEnabled",
889 ]).catch(() => "");
890 if (current.trim() === "0") return;
891 // The Settings toggle writes all three keys; SBChamoisWindowingEnabled
892 // alone is not honored on a freshly created device.
893 for (const key of [
894 "SBChamoisWindowingEnabled",
895 "SBMedusaMultitaskingEnabled",
896 "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation",
897 ]) {
898 await runCommand("xcrun", [
899 "simctl",
900 "spawn",
901 udid,
902 "defaults",
903 "write",
904 "com.apple.springboard",
905 key,
906 "-bool",
907 "false",
908 ]);
909 }
910 // A SpringBoard restart is not enough on a freshly created simulator (the
911 // first CI run captured with windowing still active), so reboot the device
912 // and verify the mode actually stuck.
913 await runCommand("xcrun", ["simctl", "shutdown", udid]);
914 await runCommand("xcrun", ["simctl", "boot", udid]);
915 await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]);
916 const applied = await commandOutput("xcrun", [
917 "simctl",
918 "spawn",
919 udid,
920 "defaults",
921 "read",
922 "com.apple.springboard",
923 "SBChamoisWindowingEnabled",
924 ]).catch(() => "");
925 if (applied.trim() !== "0") {
926 throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`);
927 }
928}
929
930async function iosAppContainer(udid: string): Promise<string> {
931 return (
932 await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"])
933 ).trim();
934}
935
936async function waitForIosShowcaseScene(
937 udid: string,
938 scene: ShowcaseScene,
939 timeoutMs = 90_000,
940): Promise<void> {
941 const readyPath = NodePath.join(
942 await iosAppContainer(udid),
943 "Library/Caches",
944 IOS_READY_FILENAME,
945 );
946 const deadline = Date.now() + timeoutMs;
947 while (Date.now() < deadline) {
948 const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => "");
949 if (readyScene.trim() === scene) return;
950 await delay(500);
951 }
952 throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
953}
954
955function iosAxe(): string {
956 return NodeProcess.env.AXE_PATH ?? "axe";
957}
958
959async function runAxe(udid: string, args: ReadonlyArray<string>): Promise<void> {
960 // HID events sent right after the simulator settles are dropped without it.
961 await runCommand(iosAxe(), [...args, "--udid", udid], {
962 env: { ...NodeProcess.env, AXE_HID_STABILIZATION_MS: "3000" },
963 }).catch((error: unknown) => {
964 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
965 throw new Error(
966 "The agent-activity scene drives the simulator with AXe. Install it with `brew tap cameroncooke/axe && brew install axe`, or set AXE_PATH.",
967 );
968 }
969 throw error;
970 });
971}
972
973async function iosSimulatorLocked(udid: string): Promise<boolean> {
974 const state = await commandOutput("xcrun", [
975 "simctl",
976 "spawn",
977 udid,
978 "notifyutil",
979 "-g",
980 "com.apple.springboard.lockstate",
981 ]).catch(() => "");
982 return state.trim().endsWith(" 1");
983}
984
985/**
986 * The staging app asks for notification permission; simctl has no privacy
987 * service for it, so answer the prompt until the scene reports ready.
988 */
989async function allowIosNotificationsUntilReady(udid: string, ready: Promise<void>): Promise<void> {
990 const state = { settled: false };
991 const tapping = (async () => {
992 while (!state.settled) {
993 await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined);
994 if (!state.settled) await delay(1_000);
995 }
996 })();
997 try {
998 await ready;
999 } finally {
1000 state.settled = true;
1001 await tapping;
1002 }
1003}
1004
1005/**
1006 * Locks the simulator over the staged Live Activity and delivers the alert a
1007 * relay push would, so the lock screen shows both.
1008 */
1009async function presentIosLockScreen(udid: string): Promise<void> {
1010 await runAxe(udid, ["button", "lock"]);
1011 const deadline = Date.now() + 15_000;
1012 while (!(await iosSimulatorLocked(udid))) {
1013 if (Date.now() > deadline) throw new Error(`Simulator ${udid} did not lock.`);
1014 await delay(500);
1015 }
1016 const alert = showcaseAgentAlert();
1017 await new Promise<void>((resolve, reject) => {
1018 const child = NodeChildProcess.execFile(
1019 "xcrun",
1020 ["simctl", "push", udid, ANDROID_PACKAGE, "-"],
1021 { cwd: REPO_ROOT },
1022 (error) => (error ? reject(error) : resolve()),
1023 );
1024 child.stdin?.end(
1025 JSON.stringify({
1026 aps: { alert: { title: alert.title, body: alert.body }, sound: "default" },
1027 }),
1028 );
1029 });
1030 await wakeIosLockScreen(udid);
1031 // The first Live Activity on the lock screen asks to keep allowing them.
1032 await delay(2_000);
1033 await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined);
1034}
1035
1036/**
1037 * The alert usually wakes the display, but not always. A home press on a dark
1038 * display only wakes it, so press only after a screenshot proves it is dark;
1039 * pressing on a lit lock screen would unlock the device instead.
1040 */
1041async function wakeIosLockScreen(udid: string): Promise<void> {
1042 const probe = NodePath.join(NodeOS.tmpdir(), `t3-showcase-wake-${udid}.png`);
1043 try {
1044 for (let attempt = 0; attempt < 5; attempt += 1) {
1045 await delay(2_000);
1046 await runCommand("xcrun", ["simctl", "io", udid, "screenshot", probe]);
1047 if (!pngIsBlack(await NodeFSP.readFile(probe))) return;
1048 await runAxe(udid, ["button", "home"]);
1049 }
1050 throw new Error(`Simulator ${udid} lock screen stayed dark.`);
1051 } finally {
1052 await NodeFSP.rm(probe, { force: true });
1053 }
1054}
1055
1056function pngIsBlack(bytes: Uint8Array): boolean {
1057 const { data } = PNG.sync.read(Buffer.from(bytes));
1058 // Sample a sparse grid; a sleeping display is uniformly black.
1059 for (let offset = 0; offset < data.length; offset += 4 * 997) {
1060 if ((data[offset] ?? 0) > 8 || (data[offset + 1] ?? 0) > 8 || (data[offset + 2] ?? 0) > 8) {
1061 return false;
1062 }
1063 }
1064 return true;
1065}
1066
1067async function unlockIosSimulator(udid: string): Promise<void> {
1068 // The first press wakes the display, the second dismisses the lock screen.
1069 await runAxe(udid, ["button", "home"]);
1070 await delay(2_000);
1071 await runAxe(udid, ["button", "home"]);
1072}
1073
1074/** Mirrors the app's staged hero row (showcaseAgentActivity.ts). */
1075function showcaseAgentAlert(): { readonly title: string; readonly body: string } {
1076 const thread = SHOWCASE_THREADS.find((candidate) => candidate.id === "pocket-command-center");
1077 const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === thread?.projectId);
1078 if (!thread || !project) throw new Error("The showcase fixture lost its agent-activity thread.");
1079 return { title: thread.title, body: `Approval: ${project.title}` };
1080}
1081
1082async function captureIos(
1083 capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice },
1084 appPath: string | null,
1085 outputDirectory: string,
1086 config: ShowcaseConfig,
1087 metroHost: string,
1088 pairingUrls: ReadonlyArray<string>,
1089 registerCleanup: (cleanup: IosCaptureCleanup) => void,
1090): Promise<void> {
1091 const { simulator, createdByRunner } = await ensureIosSimulator(capture.device);
1092 const startedByRunner = simulator.state !== "Booted";
1093 registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner });
1094 if (!startedByRunner) {
1095 // Clear transient SpringBoard state (permission prompts, stale URL-open
1096 // confirmations, keyboards) without erasing the developer's simulator.
1097 await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]);
1098 }
1099 await suppressIosSystemFollowUps(simulator.udid);
1100 await runCommand("xcrun", ["simctl", "boot", simulator.udid]);
1101 await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]);
1102 if (capture.device.orientation === "landscape") {
1103 await ensureIosFullScreenAppsMode(simulator.udid);
1104 }
1105 await normalizeIosSimulator(capture.appearance, simulator.udid);
1106 if (appPath) {
1107 await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch(
1108 () => undefined,
1109 );
1110 await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]);
1111 }
1112
1113 // The dev-client launch URL carries the dev menu preferences (SDK 58), so
1114 // nothing is written into the app container ahead of launch.
1115 const metroUrl = `http://${metroHost}:${config.metroPort}?${DEV_CLIENT_LAUNCH_FLAGS}`;
1116 const scenePath = NodePath.join(
1117 await iosAppContainer(simulator.udid),
1118 "Library/Caches/T3ShowcaseScene",
1119 );
1120 const readyPath = NodePath.join(
1121 await iosAppContainer(simulator.udid),
1122 "Library/Caches",
1123 IOS_READY_FILENAME,
1124 );
1125 const firstScene = capture.scenes[0] ?? "threads";
1126 const launchShowcaseApp = async (terminateRunningProcess: boolean) => {
1127 await runCommand("xcrun", [
1128 "simctl",
1129 "launch",
1130 ...(terminateRunningProcess ? ["--terminate-running-process"] : []),
1131 simulator.udid,
1132 ANDROID_PACKAGE,
1133 "--initialUrl",
1134 metroUrl,
1135 "--showcasePairingUrl",
1136 JSON.stringify(pairingUrls),
1137 "--showcaseScene",
1138 firstScene,
1139 "--showcaseTheme",
1140 capture.theme,
1141 // The app rotates itself; Simulator menu UI scripting needs macOS
1142 // Accessibility permission that CI runners do not grant to osascript.
1143 "--showcaseOrientation",
1144 capture.device.orientation ?? "portrait",
1145 ]);
1146 };
1147 await NodeFSP.rm(readyPath, { force: true });
1148 await NodeFSP.writeFile(scenePath, firstScene);
1149 await launchShowcaseApp(false);
1150 for (const [sceneIndex, scene] of capture.scenes.entries()) {
1151 if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true });
1152 await NodeFSP.writeFile(scenePath, scene);
1153 const waitForScene = (timeoutMs?: number) => {
1154 const ready = waitForIosShowcaseScene(simulator.udid, scene, timeoutMs);
1155 return scene === "agent-activity"
1156 ? allowIosNotificationsUntilReady(simulator.udid, ready)
1157 : ready;
1158 };
1159 if (sceneIndex === 0) {
1160 for (let attempt = 0; attempt < 2; attempt += 1) {
1161 const isLastAttempt = attempt === 1;
1162 try {
1163 // A freshly installed Expo development build can spend well over 30s
1164 // applying an already-bundled update after it reaches 100%. Killing it
1165 // at that point sends the next capture back to the dev launcher.
1166 await waitForScene(120_000);
1167 break;
1168 } catch (error) {
1169 if (isLastAttempt) throw error;
1170 await launchShowcaseApp(true);
1171 }
1172 }
1173 } else {
1174 await waitForScene();
1175 }
1176 if (scene === "agent-activity") await presentIosLockScreen(simulator.udid);
1177 await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs);
1178 const destination = NodePath.join(
1179 showcaseCaptureDirectory(outputDirectory, capture),
1180 `${scene}.png`,
1181 );
1182 await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]);
1183 if (capture.device.orientation === "landscape") {
1184 // A headless simulator keeps its display portrait while the rotated app
1185 // renders sideways inside it; with Simulator.app attached the display
1186 // itself rotates. Only post-rotate the former.
1187 const { width, height } = readPngDimensions(await NodeFSP.readFile(destination));
1188 if (height > width) {
1189 await runCommand("sips", ["--rotate", "270", destination]);
1190 }
1191 }
1192 if (scene === "agent-activity") await unlockIosSimulator(simulator.udid);
1193 await finalizeCapture(destination, capture.device);
1194 }
1195}
1196
1197function androidSdkTool(relativePath: string): string {
1198 return NodePath.join(ANDROID_SDK_ROOT, relativePath);
1199}
1200
1201async function adbOutput(serial: string, args: ReadonlyArray<string>): Promise<string> {
1202 return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
1203}
1204
1205async function runAdb(serial: string, args: ReadonlyArray<string>): Promise<void> {
1206 await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]);
1207}
1208
1209/**
1210 * Emulator images post their own ongoing notices (keyboard configured, serial
1211 * console enabled) that would share the shade with the app's. Snoozing hides
1212 * them for the capture; they come back on their own an hour later.
1213 */
1214async function snoozeAndroidSystemNotifications(serial: string): Promise<void> {
1215 const keys = (await adbOutput(serial, ["shell", "cmd", "notification", "list"]))
1216 .split("\n")
1217 .map((line) => line.trim())
1218 .filter((key) => key.includes("|") && !key.includes(`|${ANDROID_PACKAGE}|`));
1219 for (const key of keys) {
1220 // adb joins shell args with spaces, so the key needs quoting for the pipes.
1221 await runAdb(serial, ["shell", `cmd notification snooze --for 3600000 '${key}'`]);
1222 }
1223}
1224
1225async function runningAndroidAvds(): Promise<ReadonlyMap<string, string>> {
1226 const adb = androidSdkTool("platform-tools/adb");
1227 const devices = (await commandOutput(adb, ["devices"]))
1228 .split("\n")
1229 .map((line) => line.trim().split(/\s+/u))
1230 .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device")
1231 .map((parts) => parts[0] as string);
1232 const result = new Map<string, string>();
1233 for (const serial of devices) {
1234 const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim();
1235 if (avdName) result.set(avdName, serial);
1236 }
1237 return result;
1238}
1239
1240async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise<string> {
1241 const deadline = Date.now() + timeoutMs;
1242 while (Date.now() < deadline) {
1243 const serial = (await runningAndroidAvds()).get(avd);
1244 if (serial) {
1245 await runAdb(serial, ["wait-for-device"]);
1246 const bootCompleted = (
1247 await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"])
1248 ).trim();
1249 if (bootCompleted === "1") return serial;
1250 }
1251 await delay(1_000);
1252 }
1253 throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`);
1254}
1255
1256async function normalizeAndroidEmulator(
1257 device: ShowcaseAndroidDevice,
1258 appearance: ShowcaseAppearance,
1259 serial: string,
1260): Promise<void> {
1261 await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]);
1262 await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]);
1263 await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]);
1264 await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]);
1265 await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]);
1266 await runAdb(serial, ["emu", "power", "capacity", "100"]);
1267 await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]);
1268 await runAdb(serial, [
1269 "shell",
1270 "am",
1271 "broadcast",
1272 "-a",
1273 "com.android.systemui.demo",
1274 "-e",
1275 "command",
1276 "enter",
1277 ]);
1278 await runAdb(serial, [
1279 "shell",
1280 "am",
1281 "broadcast",
1282 "-a",
1283 "com.android.systemui.demo",
1284 "-e",
1285 "command",
1286 "clock",
1287 "-e",
1288 "hhmm",
1289 "0941",
1290 ]);
1291 await runAdb(serial, [
1292 "shell",
1293 "am",
1294 "broadcast",
1295 "-a",
1296 "com.android.systemui.demo",
1297 "-e",
1298 "command",
1299 "battery",
1300 "-e",
1301 "level",
1302 "100",
1303 "-e",
1304 "plugged",
1305 "false",
1306 ]);
1307 if (device.viewport) {
1308 await runAdb(serial, [
1309 "shell",
1310 "wm",
1311 "size",
1312 `${device.viewport.width}x${device.viewport.height}`,
1313 ]);
1314 if (device.viewport.density) {
1315 await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]);
1316 }
1317 }
1318}
1319
1320async function waitForAndroidShowcaseScene(
1321 serial: string,
1322 scene: ShowcaseScene,
1323 timeoutMs = 90_000,
1324): Promise<void> {
1325 const deadline = Date.now() + timeoutMs;
1326 while (Date.now() < deadline) {
1327 const readyScene = await adbOutput(serial, [
1328 "shell",
1329 "run-as",
1330 ANDROID_PACKAGE,
1331 "cat",
1332 "files/t3-showcase-ready",
1333 ]).catch(() => "");
1334 if (readyScene.trim() === scene) return;
1335 await delay(500);
1336 }
1337 throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`);
1338}
1339
1340async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise<void> {
1341 await runAdb(serial, [
1342 "shell",
1343 `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`,
1344 ]);
1345}
1346
1347// Gesture and key-command toggles have no launch-URL flag, so they still go
1348// through the preferences file; onboarding, auto-launch and the floating button
1349// come from DEV_CLIENT_LAUNCH_FLAGS on the launch URL.
1350async function prepareAndroidShowcaseApp(serial: string): Promise<void> {
1351 const preferences = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
1352<map>
1353 <boolean name="motionGestureEnabled" value="false" />
1354 <boolean name="touchGestureEnabled" value="false" />
1355 <boolean name="keyCommandsEnabled" value="false" />
1356</map>`;
1357 const encodedPreferences = Buffer.from(preferences).toString("base64");
1358 await runAdb(serial, [
1359 "shell",
1360 `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`,
1361 ]);
1362}
1363
1364async function captureAndroid(
1365 capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
1366 apkPath: string | null,
1367 outputDirectory: string,
1368 config: ShowcaseConfig,
1369 pairingUrls: ReadonlyArray<string>,
1370 registerCleanup: (cleanup: AndroidCaptureCleanup) => void,
1371): Promise<void> {
1372 const running = await runningAndroidAvds();
1373 const existingSerial = running.get(capture.device.avd);
1374 const startedByRunner = !existingSerial;
1375 let launchedEmulator: NodeChildProcess.ChildProcess | null = null;
1376 if (startedByRunner) {
1377 const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"]))
1378 .split("\n")
1379 .map((value) => value.trim());
1380 if (!installedAvds.includes(capture.device.avd)) {
1381 throw new Error(
1382 `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`,
1383 );
1384 }
1385 launchedEmulator = spawnProcess(
1386 androidSdkTool("emulator/emulator"),
1387 ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"],
1388 { stdio: "ignore", detached: true },
1389 );
1390 launchedEmulator.unref();
1391 }
1392 const serial =
1393 existingSerial ??
1394 (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => {
1395 if (launchedEmulator) await stopProcess(launchedEmulator);
1396 throw error;
1397 }));
1398 registerCleanup({ device: capture.device, serial, startedByRunner });
1399 await normalizeAndroidEmulator(capture.device, capture.appearance, serial);
1400 if (apkPath) {
1401 await runAdb(serial, ["install", "-r", apkPath]);
1402 }
1403 await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]);
1404 // The agent-activity scene posts notifications; granting up front keeps the
1405 // runtime prompt off every capture.
1406 await runAdb(serial, [
1407 "shell",
1408 "pm",
1409 "grant",
1410 ANDROID_PACKAGE,
1411 "android.permission.POST_NOTIFICATIONS",
1412 ]);
1413 await prepareAndroidShowcaseApp(serial);
1414 await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]);
1415 const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`);
1416 const firstScene = capture.scenes[0] ?? "threads";
1417 await runAdb(serial, [
1418 "shell",
1419 "am",
1420 "start",
1421 "-W",
1422 "-a",
1423 "android.intent.action.VIEW",
1424 "-d",
1425 `'${APP_SCHEME}://expo-development-client/?url=${metroUrl}&${DEV_CLIENT_LAUNCH_FLAGS}'`,
1426 "--es",
1427 "showcasePairingUrl",
1428 encodeAndroidPairingUrls(pairingUrls),
1429 "--es",
1430 "showcaseScene",
1431 firstScene,
1432 "--es",
1433 "showcaseTheme",
1434 capture.theme,
1435 ANDROID_PACKAGE,
1436 ]);
1437 for (const [sceneIndex, scene] of capture.scenes.entries()) {
1438 if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene);
1439 await waitForAndroidShowcaseScene(serial, scene);
1440 if (scene === "agent-activity") {
1441 await snoozeAndroidSystemNotifications(serial);
1442 await runAdb(serial, ["shell", "cmd", "statusbar", "expand-notifications"]);
1443 }
1444 await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000));
1445 const destination = NodePath.join(
1446 showcaseCaptureDirectory(outputDirectory, capture),
1447 `${scene}.png`,
1448 );
1449 const png = await new Promise<Buffer>((resolve, reject) => {
1450 NodeChildProcess.execFile(
1451 androidSdkTool("platform-tools/adb"),
1452 ["-s", serial, "exec-out", "screencap", "-p"],
1453 { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 },
1454 (error, stdout) => {
1455 if (error) reject(error);
1456 else resolve(stdout);
1457 },
1458 );
1459 });
1460 await NodeFSP.writeFile(destination, png);
1461 if (scene === "agent-activity") {
1462 await runAdb(serial, ["shell", "cmd", "statusbar", "collapse"]);
1463 }
1464 await finalizeCapture(destination, capture.device);
1465 }
1466}
1467
1468async function cleanupAndroidViewport(
1469 device: ShowcaseAndroidDevice,
1470 serial: string,
1471): Promise<void> {
1472 await runAdb(serial, [
1473 "shell",
1474 "am",
1475 "broadcast",
1476 "-a",
1477 "com.android.systemui.demo",
1478 "-e",
1479 "command",
1480 "exit",
1481 ]);
1482 if (!device.viewport) return;
1483 await runAdb(serial, ["shell", "wm", "size", "reset"]);
1484 if (device.viewport.density) {
1485 await runAdb(serial, ["shell", "wm", "density", "reset"]);
1486 }
1487}
1488
1489async function main(): Promise<void> {
1490 const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2));
1491 if (options.list) {
1492 printUsage(showcaseConfig);
1493 return;
1494 }
1495 const captures = planShowcaseCaptures(showcaseConfig, options);
1496 const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory);
1497 if (options.validateOnly) {
1498 for (const capture of captures) {
1499 await validateCaptureSet(
1500 capture,
1501 outputDirectory,
1502 capture.scenes.length === capture.device.scenes.length,
1503 );
1504 }
1505 return;
1506 }
1507 const hasIos = captures.some((capture) => capture.device.platform === "ios");
1508 const hasAndroid = captures.some((capture) => capture.device.platform === "android");
1509 const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1";
1510 await NodeFSP.mkdir(outputDirectory, { recursive: true });
1511 for (const capture of captures) {
1512 const directory = showcaseCaptureDirectory(outputDirectory, capture);
1513 await NodeFSP.rm(directory, { recursive: true, force: true });
1514 await NodeFSP.mkdir(directory, { recursive: true });
1515 }
1516
1517 const showcaseRootDir = await NodeFSP.mkdtemp(
1518 NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"),
1519 );
1520 const showcaseServers: NodeChildProcess.ChildProcess[] = [];
1521 const showcaseEnvironments: Array<{
1522 readonly baseDir: string;
1523 readonly environmentId: string;
1524 readonly label: string;
1525 readonly port: number;
1526 }> = [];
1527 let metro: NodeChildProcess.ChildProcess | null = null;
1528 const iosCleanups: IosCaptureCleanup[] = [];
1529 const androidCleanups: AndroidCaptureCleanup[] = [];
1530
1531 try {
1532 for (const environment of SHOWCASE_ENVIRONMENTS) {
1533 const projectId = environment.projectIds[0];
1534 const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId);
1535 if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`);
1536
1537 const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id);
1538 const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory);
1539 const port = await reserveAvailablePort();
1540 await NodeFSP.mkdir(workspaceRoot, { recursive: true });
1541 const shellPath = await createShowcaseShell(baseDir);
1542 const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label);
1543 const server = startShowcaseServer(
1544 baseDir,
1545 workspaceRoot,
1546 port,
1547 shellPath,
1548 labelProbeDirectory,
1549 );
1550 showcaseServers.push(server);
1551 await waitForPort(port, `${environment.label} server`);
1552 await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds });
1553 // The server begins listening before the ServerEnvironment layer
1554 // persists the environment id, so poll rather than read once.
1555 const environmentId = await waitForFileContent(
1556 NodePath.join(baseDir, "userdata", "environment-id"),
1557 `${environment.label} environment id`,
1558 );
1559 showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port });
1560 }
1561
1562 if (!options.skipMetro) {
1563 metro = startMetro(showcaseConfig);
1564 await waitForPort(showcaseConfig.metroPort, "Metro");
1565 await Promise.all([
1566 hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(),
1567 hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(),
1568 ]);
1569 }
1570
1571 const iosAppPath = hasIos
1572 ? options.skipBuild
1573 ? await existingArtifact(IOS_APP_PATH)
1574 : await buildIos()
1575 : null;
1576 const androidAbis = captures.flatMap((capture) =>
1577 capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [],
1578 );
1579 const androidApkPath = hasAndroid
1580 ? options.skipBuild
1581 ? await existingArtifact(ANDROID_APK_PATH)
1582 : await buildAndroid([...new Set(androidAbis)])
1583 : null;
1584
1585 for (const capture of captures) {
1586 const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2";
1587 const pairingUrls = await Promise.all(
1588 showcaseEnvironments.map(async (environment) => {
1589 const credential = await issuePairingCredential(environment.baseDir);
1590 return buildShowcasePairingUrl(pairingHost, environment.port, credential);
1591 }),
1592 );
1593 if (capture.device.platform === "ios") {
1594 await captureIos(
1595 capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice },
1596 iosAppPath,
1597 outputDirectory,
1598 showcaseConfig,
1599 metroHost,
1600 pairingUrls,
1601 (cleanup) => iosCleanups.push(cleanup),
1602 );
1603 } else {
1604 await captureAndroid(
1605 capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice },
1606 androidApkPath,
1607 outputDirectory,
1608 showcaseConfig,
1609 pairingUrls,
1610 (cleanup) => androidCleanups.push(cleanup),
1611 );
1612 }
1613 await validateCaptureSet(
1614 capture,
1615 outputDirectory,
1616 capture.scenes.length === capture.device.scenes.length,
1617 );
1618 }
1619
1620 NodeProcess.stdout.write(
1621 `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`,
1622 );
1623 if (options.keepRunning) {
1624 const serverSummary = showcaseEnvironments
1625 .map((environment) => `${environment.label}:${environment.port}`)
1626 .join(", ");
1627 NodeProcess.stdout.write(
1628 `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`,
1629 );
1630 }
1631 } finally {
1632 if (options.keepRunning) {
1633 metro?.unref();
1634 for (const server of showcaseServers) server.unref();
1635 } else {
1636 for (const cleanup of androidCleanups) {
1637 await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined);
1638 if (cleanup.startedByRunner) {
1639 await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined);
1640 }
1641 }
1642 for (const cleanup of iosCleanups) {
1643 if (cleanup.startedByRunner || cleanup.createdByRunner) {
1644 await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined);
1645 }
1646 if (cleanup.createdByRunner) {
1647 await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined);
1648 }
1649 }
1650 await Promise.all([
1651 ...(metro ? [stopProcess(metro)] : []),
1652 ...showcaseServers.map((server) => stopProcess(server)),
1653 ]);
1654 await NodeFSP.rm(showcaseRootDir, {
1655 recursive: true,
1656 force: true,
1657 maxRetries: 5,
1658 retryDelay: 100,
1659 });
1660 }
1661 }
1662}
1663
1664if (import.meta.main) {
1665 void main().catch((error: unknown) => {
1666 // Stack over message: the harness only fails in CI, where the line that
1667 // threw is the whole diagnosis and there is nobody at a terminal to
1668 // re-run it with more output.
1669 NodeProcess.stderr.write(
1670 `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
1671 );
1672 NodeProcess.exit(1);
1673 });
1674}