Files
streamyfin/scripts/ios/build-ios.ts
Cristea Florian Victor 5d93483dc2 feat: xcode build script (#1296)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2026-01-13 19:32:58 +01:00

1676 lines
50 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env -S bun --transpile-only
/**
* Standalone iOS Build Script
*
* Author: Victor Cristea (retrozenith) <80767544+retrozenith@users.noreply.github.com>
*
* This script builds iOS apps similar to `cross-env EXPO_TV=0 expo run:ios`
* but as a completely separate standalone script.
*
* It also supports production builds similar to `eas build -p ios --local --non-interactive`
* without requiring EAS login.
*
* Usage:
* EXPO_TV=0 bun scripts/ios/build-ios.ts [options]
*
* Options:
* --configuration [Debug|Release] Xcode build configuration (default: Debug)
* --device [name|udid] Target device or simulator
* --scheme [name] Xcode scheme to build
* --no-bundler Skip starting Metro bundler
* --no-install Skip installing dependencies (pods)
* --clean Clean build before building
* --project-root [path] Project root directory (default: cwd)
* --port [number] Metro bundler port (default: 8081)
* --production Build production IPA (like eas build --local)
* --output [path] Output path for production build artifact
* --simulator Build for simulator (production mode)
* --skip-credentials Skip credentials setup (unsigned build)
* --verbose Show verbose output
* --help Show this help message
*/
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require("node:fs");
const path = require("node:path");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { spawn, execSync, spawnSync } = require("node:child_process");
import type { ChildProcess } from "node:child_process";
// Track Metro bundler process for cleanup
let metroProcess: ChildProcess | null = null;
// =============================================================================
// Configuration Constants
// =============================================================================
/** Default Metro bundler port */
const DEFAULT_METRO_PORT = 8081;
/** Minimum allowed port number (avoiding privileged ports) */
const MIN_PORT = 1024;
/** Maximum allowed port number */
const MAX_PORT = 65535;
/** Progress line width for padding */
const PROGRESS_LINE_WIDTH = 60;
/** Maximum buffer size for xcodebuild output (100MB) */
const MAX_BUILD_BUFFER = 100 * 1024 * 1024;
/** Default build timeout in milliseconds (30 minutes) */
const DEFAULT_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
/** Regex to find .app path in DerivedData build output */
const DERIVED_DATA_APP_PATH_REGEX =
/\/[\S]+\/DerivedData\/[\S]+\/Build\/Products\/[\S]+-[\S]+\/[\S]+\.app/;
/** Name of the iOS directory */
const IOS_DIR_NAME = "ios";
/** Maximum build timeout in milliseconds (2 hours) */
const MAX_BUILD_TIMEOUT_MS = 2 * 60 * 60 * 1000;
/** Simulator boot wait time in milliseconds (30 seconds max) */
const SIMULATOR_BOOT_WAIT_MS = 30 * 1000;
/** Number of output lines to show when no errors found */
const ERROR_OUTPUT_TAIL_LINES = 50;
// =============================================================================
// Security Helpers
// =============================================================================
/**
* Validates and sanitizes a path to prevent command injection.
* Throws an error if the path contains dangerous characters.
* @param inputPath - The path to sanitize
* @param projectRoot - Optional project root to verify path doesn't escape
* @param mustExist - If true, validates that the path exists (default: false)
* @returns The validated absolute path
* @throws Error if validation fails
*/
function sanitizePath(
inputPath: string,
projectRoot?: string,
mustExist = false,
): string {
// Validate input is a string
if (typeof inputPath !== "string" || inputPath.trim() === "") {
throw new Error("Path must be a non-empty string");
}
// Check for null bytes (common injection technique)
if (inputPath.includes("\0")) {
throw new Error("Path contains null byte");
}
// Resolve to absolute path to prevent traversal
const resolved = path.resolve(inputPath);
// Check for dangerous shell metacharacters (allow tilde for Unix home paths)
const dangerousChars = /[`$&|;<>(){}[\]!#]/;
if (dangerousChars.test(resolved)) {
throw new Error(
`Path contains potentially dangerous characters: ${resolved}`,
);
}
// If projectRoot provided, ensure path doesn't escape it
if (projectRoot) {
const absProjectRoot = path.resolve(projectRoot);
// Allow system temp directories for build artifacts
const systemTempDir = require("node:os").tmpdir();
if (
!resolved.startsWith(absProjectRoot) &&
!resolved.startsWith(systemTempDir)
) {
throw new Error(
`Path escapes project boundary: ${resolved} (expected within ${absProjectRoot})`,
);
}
}
// Optionally validate path exists
if (mustExist && !fs.existsSync(resolved)) {
throw new Error(`Path does not exist: ${resolved}`);
}
// Check for symlink traversal (optional additional security)
if (mustExist && fs.existsSync(resolved)) {
try {
const realPath = fs.realpathSync(resolved);
if (projectRoot) {
const absProjectRoot = path.resolve(projectRoot);
const systemTempDir = require("node:os").tmpdir();
if (
!realPath.startsWith(absProjectRoot) &&
!realPath.startsWith(systemTempDir)
) {
throw new Error(
`Symlink resolves outside project boundary: ${realPath}`,
);
}
}
} catch (error: unknown) {
if (
error instanceof Error &&
error.message?.includes("project boundary")
) {
throw error;
}
// Ignore other errors (e.g., permission issues)
}
}
return resolved;
}
/**
* Validates a bundle ID to prevent command injection.
* Bundle IDs should only contain alphanumeric, dots, and hyphens.
*/
function validateBundleId(bundleId: string): string {
if (!/^[a-zA-Z0-9.-]+$/.test(bundleId)) {
throw new Error(`Invalid bundle ID format: ${bundleId}`);
}
return bundleId;
}
/**
* Validates a scheme name to prevent command injection.
* Scheme names should only contain alphanumeric, spaces, dashes, and underscores.
*/
function validateSchemeName(scheme: string): string {
if (!/^[a-zA-Z0-9 _-]+$/.test(scheme)) {
throw new Error(`Invalid scheme name format: ${scheme}`);
}
return scheme;
}
/**
* Validates a port number to ensure it's within valid range.
* Ports must be between 1024 and 65535 (avoiding privileged ports).
* @param port - The port number to validate
* @returns The validated port number
* @throws Error if port is invalid
*/
function validatePort(port: number): number {
if (!Number.isInteger(port)) {
throw new Error(`Port must be an integer, got: ${port}`);
}
if (port < MIN_PORT || port > MAX_PORT) {
throw new Error(
`Port must be between ${MIN_PORT} and ${MAX_PORT} (got ${port}). Privileged ports (below ${MIN_PORT}) are not allowed.`,
);
}
return port;
}
/**
* Validates a timeout value in milliseconds.
* @param timeoutMs - The timeout in milliseconds
* @returns The validated timeout value
* @throws Error if timeout is invalid
*/
function validateTimeout(timeoutMs: number): number {
if (!Number.isInteger(timeoutMs)) {
throw new Error(`Timeout must be an integer, got: ${timeoutMs}`);
}
if (timeoutMs < 0) {
throw new Error(
`Timeout cannot be negative, got ${timeoutMs}ms. Use --no-timeout to disable timeout.`,
);
}
// Warn but allow large timeouts
if (timeoutMs > MAX_BUILD_TIMEOUT_MS) {
console.warn(
`\x1b[33m⚠ Warning: Custom timeout ${timeoutMs / 1000}s exceeds default limit of ${MAX_BUILD_TIMEOUT_MS / 1000}s. Proceeding anyway.\x1b[0m`,
);
}
return timeoutMs;
}
// =============================================================================
// Types
// =============================================================================
interface BuildOptions {
configuration: "Debug" | "Release";
device?: string;
scheme?: string;
bundler: boolean;
install: boolean;
clean: boolean;
projectRoot: string;
port: number;
production: boolean;
output?: string;
simulator: boolean;
skipCredentials: boolean;
verbose: boolean;
buildTimeout: number;
noTimeout: boolean;
}
interface XcodeProject {
name: string;
isWorkspace: boolean;
path: string;
}
interface Device {
udid: string;
name: string;
state: string;
isSimulator: boolean;
}
// =============================================================================
// Argument Parsing
// =============================================================================
function parseArgs(argv: string[]): BuildOptions {
const args = argv.slice(2);
const options: BuildOptions = {
configuration: "Debug",
bundler: true,
install: true,
clean: false,
projectRoot: process.cwd(),
port: DEFAULT_METRO_PORT,
production: false,
simulator: false,
skipCredentials: false,
verbose: false,
buildTimeout: DEFAULT_BUILD_TIMEOUT_MS,
noTimeout: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--help":
case "-h":
printHelp();
process.exit(0);
break;
case "--configuration": {
const configArg = args[++i];
if (!configArg) {
throw new Error("--configuration requires an argument");
}
options.configuration = (configArg as "Debug" | "Release") || "Debug";
break;
}
case "--device":
case "-d": {
const deviceArg = args[++i];
if (!deviceArg) {
throw new Error("--device requires an argument");
}
options.device = deviceArg;
break;
}
case "--scheme": {
const schemeArg = args[++i];
if (!schemeArg) {
throw new Error("--scheme requires an argument");
}
options.scheme = schemeArg;
break;
}
case "--no-bundler":
options.bundler = false;
break;
case "--no-install":
options.install = false;
break;
case "--clean":
options.clean = true;
break;
case "--project-root": {
const rootPath = args[++i];
if (!rootPath) {
throw new Error("--project-root requires a path argument");
}
const resolved = path.resolve(rootPath);
if (!fs.existsSync(resolved)) {
throw new Error(`Project root does not exist: ${resolved}`);
}
if (!fs.statSync(resolved).isDirectory()) {
throw new Error(`Project root is not a directory: ${resolved}`);
}
options.projectRoot = resolved;
break;
}
case "--port":
case "-p": {
const portArg = args[++i];
if (!portArg) {
throw new Error("--port requires an argument");
}
const effectivePort = parseInt(portArg, 10);
// Handle 0 or NaN validly based on PR suggestion logic
// Using isNaN check allows 0 to be a valid input if that was intended,
// though typically 0 means "random free port".
// The original PR suggestion was: const effectivePort = Number.isNaN(parsedPort) ? DEFAULT_METRO_PORT : parsedPort;
const validPort = Number.isNaN(effectivePort)
? DEFAULT_METRO_PORT
: effectivePort;
options.port = validatePort(validPort);
break;
}
case "--production":
options.production = true;
options.configuration = "Release";
options.skipCredentials = true; // Default to unsigned builds
break;
case "--output":
case "-o": {
const outputArg = args[++i];
if (!outputArg) {
throw new Error("--output requires a path argument");
}
const resolvedOutput = path.resolve(outputArg);
const outputDir = path.dirname(resolvedOutput);
if (!fs.existsSync(outputDir)) {
throw new Error(`Output directory does not exist: ${outputDir}`);
}
if (!fs.statSync(outputDir).isDirectory()) {
throw new Error(`Output path is not a directory: ${outputDir}`);
}
options.output = resolvedOutput;
break;
}
case "--simulator":
options.simulator = true;
break;
case "--skip-credentials":
options.skipCredentials = true;
break;
case "--sign":
options.skipCredentials = false;
break;
case "--verbose":
options.verbose = true;
break;
case "--no-timeout":
options.noTimeout = true;
break;
case "--timeout": {
const timeoutArg = args[++i];
if (!timeoutArg) {
throw new Error("--timeout requires an argument");
}
const timeoutSeconds = parseInt(timeoutArg, 10);
if (Number.isNaN(timeoutSeconds)) {
throw new Error(
"Invalid timeout value. Must be a number in seconds.",
);
}
options.buildTimeout = validateTimeout(timeoutSeconds * 1000);
break;
}
}
}
return options;
}
function printHelp(): void {
console.log(`
Standalone iOS Build Script
Usage:
EXPO_TV=0 bun scripts/ios/build-ios.ts [options]
Development Build Options:
--configuration [Debug|Release] Xcode build configuration (default: Debug)
--device, -d [name|udid] Target device or simulator
--scheme [name] Xcode scheme to build
--no-bundler Skip starting Metro bundler
--no-install Skip installing dependencies (pods)
--clean Clean build before building
--project-root [path] Project root directory (default: cwd)
--port, -p [number] Metro bundler port (default: ${DEFAULT_METRO_PORT})
Production Build Options:
--production Build unsigned production archive (default: no signing)
--output, -o [path] Output path for build artifact
--simulator Build .app for simulator instead of device
--sign Enable code signing (creates signed IPA)
--timeout [seconds] Build timeout in seconds (default: ${DEFAULT_BUILD_TIMEOUT_MS / 1000}s = 30min)
--no-timeout Disable build timeout entirely
Output Options:
--verbose Stream full xcodebuild output to console.
When disabled, only errors are shown on failure.
Note: CI uses --verbose for PRs to aid debugging.
--help, -h Show this help message
Environment Variables:
EXPO_TV=0|1 Set to 0 for phone, 1 for TV builds
NODE_ENV Set to 'production' for Release builds
Examples:
# Development build
EXPO_TV=0 bun scripts/ios/build-ios.ts
EXPO_TV=0 bun scripts/ios/build-ios.ts --device "iPhone 15"
# Production unsigned build (default)
EXPO_TV=0 bun scripts/ios/build-ios.ts --production
# Production signed IPA
EXPO_TV=0 bun scripts/ios/build-ios.ts --production --sign
# Production simulator build
EXPO_TV=0 bun scripts/ios/build-ios.ts --production --simulator
# Long build without timeout
EXPO_TV=0 bun scripts/ios/build-ios.ts --production --no-timeout
`);
}
// =============================================================================
// Logging
// =============================================================================
const log = {
info: (msg: string) => console.log(`\x1b[36m\x1b[0m ${msg}`),
success: (msg: string) => console.log(`\x1b[32m✓\x1b[0m ${msg}`),
warn: (msg: string) => console.log(`\x1b[33m⚠\x1b[0m ${msg}`),
error: (msg: string) => console.error(`\x1b[31m✖\x1b[0m ${msg}`),
step: (msg: string) => console.log(`\x1b[1m ${msg}\x1b[0m`),
};
/**
* Displays build error output in a structured format.
* Shows stderr, extracts error lines from stdout, or falls back to showing last N lines.
* @param stderr - Standard error output from the build process
* @param stdout - Standard output from the build process
* @param errorPatterns - Patterns to search for in stdout to identify errors
*/
function displayBuildError(
stderr: string,
stdout: string,
errorPatterns: string[] = [
"error:",
"Error:",
"fatal error",
"** BUILD FAILED **",
"** ARCHIVE FAILED **",
"** EXPORT FAILED **",
],
): void {
// Show stderr if present (may contain warnings or errors)
if (stderr.trim()) {
console.error("\n--- Build Error Output (stderr) ---");
console.error(stderr);
console.error("--- End stderr ---\n");
}
// Extract and show actual error lines from stdout
const errorLines = stdout
.split("\n")
.filter((line: string) =>
errorPatterns.some((pattern) => line.includes(pattern)),
);
if (errorLines.length > 0) {
console.error("\n--- Build Errors (from stdout) ---");
for (const line of errorLines) {
console.error(line);
}
console.error("--- End Build Errors ---\n");
} else if (stdout.trim()) {
// No specific error patterns found, show last N lines of stdout
const lines = stdout.split("\n");
const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n");
console.error(
`\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`,
);
console.error(lastLines);
console.error("--- End build output ---\n");
}
}
/**
* Sleeps for a specified duration.
* @param ms - Duration in milliseconds
*/
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
// =============================================================================
// Platform Check
// =============================================================================
function assertPlatform(): void {
if (process.platform !== "darwin") {
log.error("iOS apps can only be built on macOS devices.");
log.info("Use `eas build -p ios` to build in the cloud.");
process.exit(1);
}
}
// =============================================================================
// Xcode Project Resolution
// =============================================================================
/**
* Finds the Xcode project or workspace in the iOS directory.
* Prefers .xcworkspace over .xcodeproj when both exist.
* @param projectRoot - The root directory of the project
* @returns XcodeProject object containing project information
* @throws Error if iOS directory is not found or not readable
*/
function findXcodeProject(projectRoot: string): XcodeProject {
const iosPath = path.join(projectRoot, IOS_DIR_NAME);
try {
if (!fs.existsSync(iosPath)) {
log.error(`iOS directory not found at: ${iosPath}`);
log.info("Run `bunx expo prebuild` to generate the iOS project.");
process.exit(1);
}
let files: string[];
try {
files = fs.readdirSync(iosPath);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
log.error(`Failed to read iOS directory: ${errorMessage}`);
log.info(`Check permissions for directory: ${iosPath}`);
process.exit(1);
}
// Prefer workspace over project
const workspace = files.find((f: string) => f.endsWith(".xcworkspace"));
if (workspace) {
return {
name: workspace,
isWorkspace: true,
path: path.join(iosPath, workspace),
};
}
const project = files.find((f: string) => f.endsWith(".xcodeproj"));
if (project) {
return {
name: project,
isWorkspace: false,
path: path.join(iosPath, project),
};
}
log.error("No Xcode project or workspace found in ios/ directory");
log.info("Run `bunx expo prebuild` to generate the iOS project.");
process.exit(1);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error(`Unexpected error finding Xcode project: ${errorMessage}`);
throw error;
}
}
// =============================================================================
// Scheme Resolution
// =============================================================================
/**
* Retrieves available schemes from an Xcode project.
* Falls back to project name if xcodebuild command fails.
* @param xcodeProject - The Xcode project to query
* @returns Array of scheme names
*/
function getSchemes(xcodeProject: XcodeProject): string[] {
try {
const flag = xcodeProject.isWorkspace ? "-workspace" : "-project";
const safePath = sanitizePath(xcodeProject.path);
const result = spawnSync("xcodebuild", ["-list", flag, safePath], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
const output = result.stdout || "";
const schemesMatch = output.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|\n$|$)/);
if (schemesMatch) {
return schemesMatch[1]
.split("\n")
.map((s: string) => s.trim())
.filter(Boolean);
}
} catch (error) {
// xcodebuild -list failed; log a warning and fall back to inferring from project name
console.warn(
`Warning: Failed to list Xcode schemes for project "${xcodeProject.path}". Falling back to project-name-based scheme inference.`,
error,
);
}
// Default scheme from project name
const name = xcodeProject.name.replace(/\.(xcworkspace|xcodeproj)$/, "");
return [name];
}
/**
* Resolves and validates the scheme name for building.
* Uses provided scheme if valid, otherwise selects best match.
* @param xcodeProject - The Xcode project
* @param schemeName - Optional scheme name to use
* @returns The validated scheme name
* @throws Error if no schemes are available
*/
function resolveScheme(
xcodeProject: XcodeProject,
schemeName?: string,
): string {
const schemes = getSchemes(xcodeProject);
if (schemeName && schemes.includes(schemeName)) {
return validateSchemeName(schemeName);
}
if (schemes.length === 0) {
log.error("No schemes found in Xcode project");
process.exit(1);
}
// Prefer scheme that matches project name
const projectName = xcodeProject.name.replace(
/\.(xcworkspace|xcodeproj)$/,
"",
);
const matchingScheme = schemes.find((s) => s === projectName);
const finalScheme = matchingScheme || schemes[0];
return validateSchemeName(finalScheme);
}
// =============================================================================
// Device Resolution
// =============================================================================
/**
* Retrieves list of available iOS simulators.
* @returns Array of Device objects representing available simulators
*/
function getAvailableSimulators(): Device[] {
try {
const output = execSync("xcrun simctl list devices available --json", {
encoding: "utf-8",
});
const devicesData = JSON.parse(output);
const devices: Device[] = [];
for (const [runtime, deviceList] of Object.entries(
devicesData.devices || {},
)) {
if (!runtime.includes("iOS")) continue;
for (const device of deviceList as any[]) {
devices.push({
udid: device.udid,
name: device.name,
state: device.state,
isSimulator: true,
});
}
}
return devices;
} catch {
return [];
}
}
/**
* Resolves target device for installation.
* Prefers booted simulator or matches by name/UDID.
* @param deviceName - Optional device name or UDID to match
* @returns Device object for the resolved target
* @throws Error if no simulators are available
*/
function resolveDevice(deviceName?: string): Device {
const simulators = getAvailableSimulators();
if (simulators.length === 0) {
log.error("No iOS simulators available.");
log.info("Create a simulator using Xcode or `xcrun simctl create`");
process.exit(1);
}
if (deviceName) {
// Match by name or UDID
const match = simulators.find(
(d) =>
d.udid.toLowerCase() === deviceName.toLowerCase() ||
d.name.toLowerCase().includes(deviceName.toLowerCase()),
);
if (match) {
return match;
}
log.warn(`Device "${deviceName}" not found, using default simulator.`);
}
// Prefer booted simulator, otherwise pick the first iPhone
const bootedDevice = simulators.find((d) => d.state === "Booted");
if (bootedDevice) {
return bootedDevice;
}
const iPhoneDevice = simulators.find((d) => d.name.includes("iPhone"));
return iPhoneDevice || simulators[0];
}
// =============================================================================
// CocoaPods
// =============================================================================
/**
* Installs CocoaPods dependencies for the iOS project.
* Tries `pod install` first, falls back to `pod install --repo-update`.
* @param projectRoot - Project root directory
*/
function installPods(projectRoot: string): void {
const iosPath = path.join(projectRoot, IOS_DIR_NAME);
const podfilePath = path.join(iosPath, "Podfile");
if (!fs.existsSync(podfilePath)) {
log.info("No Podfile found, skipping pod install");
return;
}
log.step("Installing CocoaPods dependencies...");
try {
execSync("pod install", {
cwd: iosPath,
stdio: "inherit",
env: { ...process.env },
});
log.success("Pods installed successfully");
} catch (_error) {
log.warn("Pod install failed, trying with repo update...");
try {
execSync("pod install --repo-update", {
cwd: iosPath,
stdio: "inherit",
env: { ...process.env },
});
log.success("Pods installed successfully");
} catch {
log.error("Failed to install CocoaPods dependencies");
process.exit(1);
}
}
}
// =============================================================================
// Build Process
// =============================================================================
function getXcodeBuildArgs(
xcodeProject: XcodeProject,
scheme: string,
device: Device,
options: BuildOptions,
): string[] {
const args = [
xcodeProject.isWorkspace ? "-workspace" : "-project",
xcodeProject.path,
"-configuration",
options.configuration,
"-scheme",
scheme,
"-destination",
`id=${device.udid}`,
];
if (options.clean) {
args.push("clean", "build");
}
return args;
}
function getProcessEnv(options: BuildOptions): NodeJS.ProcessEnv {
return {
...process.env,
RCT_METRO_PORT: options.port.toString(),
RCT_NO_LAUNCH_PACKAGER: options.bundler ? undefined : "true",
// Preserve EXPO_TV and other environment variables
EXPO_TV: process.env.EXPO_TV,
};
}
async function runXcodeBuild(
args: string[],
env: NodeJS.ProcessEnv,
verbose = false,
): Promise<string> {
return new Promise((resolve, reject) => {
log.step("Building iOS app...");
log.info(`xcodebuild ${args.join(" ")}`);
const buildProcess = spawn("xcodebuild", args, {
env,
stdio: ["inherit", "pipe", "pipe"],
});
let output = "";
let errorOutput = "";
// Always capture stdout for binary path extraction
buildProcess.stdout?.on("data", (data: Buffer) => {
const str = data.toString();
output += str;
if (verbose) {
process.stdout.write(str);
} else {
// Simple progress indicator
if (str.includes("Build succeeded")) {
log.success("Build succeeded");
} else if (str.includes("Compiling")) {
// Show compilation progress
const match = str.match(/Compiling\s+(\S+)/);
if (match) {
process.stdout.write(
`\r Compiling ${match[1]}...`.padEnd(PROGRESS_LINE_WIDTH),
);
}
}
}
});
buildProcess.stderr?.on("data", (data: Buffer) => {
const str = data.toString();
errorOutput += str;
if (verbose) {
process.stderr.write(str);
}
});
buildProcess.on("close", (code: number | null) => {
if (!verbose) {
process.stdout.write("\n");
}
if (code === 0) {
resolve(output);
} else if (code === null) {
log.error("xcodebuild process terminated abnormally (no exit code)");
if (errorOutput && !verbose) {
console.error(errorOutput);
}
reject(new Error("Build process exited without code"));
} else {
log.error(`xcodebuild exited with code ${code}`);
if (errorOutput && !verbose) {
console.error(errorOutput);
}
reject(new Error(`Build failed with code ${code}`));
}
});
});
}
function extractBinaryPath(buildOutput: string): string | null {
// Extract CONFIGURATION_BUILD_DIR and UNLOCALIZED_RESOURCES_FOLDER_PATH
const buildDirMatch = buildOutput.match(
/export CONFIGURATION_BUILD_DIR\\?=(.+)$/m,
);
const appNameMatch = buildOutput.match(
/export UNLOCALIZED_RESOURCES_FOLDER_PATH\\?=(.+)$/m,
);
if (buildDirMatch && appNameMatch) {
return path.join(buildDirMatch[1], appNameMatch[1]);
}
// Fallback: find .app path in DerivedData
const appPathMatch = buildOutput.match(DERIVED_DATA_APP_PATH_REGEX);
return appPathMatch ? appPathMatch[0] : null;
}
// =============================================================================
// App Launch
// =============================================================================
/**
* Waits for simulator to boot by polling its state.
* @param udid - Simulator UDID
* @param maxWaitMs - Maximum time to wait in milliseconds
* @throws Error if simulator doesn't boot within timeout
*/
async function waitForSimulatorBoot(
udid: string,
maxWaitMs: number,
): Promise<void> {
const startTime = Date.now();
const pollIntervalMs = 1000; // Check every second
while (Date.now() - startTime < maxWaitMs) {
try {
const result = execSync("xcrun simctl list devices --json", {
encoding: "utf-8",
});
const data = JSON.parse(result);
// Find the device in the JSON output
let isBooted = false;
const devices = data.devices || {};
for (const runtime in devices) {
if (Object.hasOwn(devices, runtime)) {
const deviceList = devices[runtime];
if (Array.isArray(deviceList)) {
const device = deviceList.find((d: any) => d.udid === udid);
if (device && device.state === "Booted") {
isBooted = true;
break;
}
}
}
}
if (isBooted) {
log.info("Simulator is ready");
return;
}
} catch {
// Simulator not found or not booted yet, continue polling
if (pollIntervalMs > 1000) {
// Only log if we've been waiting a while to avoid spam
// console.warn("Simulator polling failed, retrying...");
}
}
// Wait before next poll
await sleep(pollIntervalMs);
}
throw new Error(
`Simulator failed to boot within ${maxWaitMs / 1000} seconds`,
);
}
/**
* Installs and launches the app on the specified simulator.
* Handles simulator booting, app installation, and launching.
* @param binaryPath - Path to the compiled .app directory
* @param device - Target simulator device
* @throws Error if installation or launch fails
*/
async function launchApp(binaryPath: string, device: Device): Promise<void> {
log.step("Installing and launching app...");
const sanitizedBinaryPath = sanitizePath(binaryPath);
// Boot simulator if not running
if (device.state !== "Booted") {
log.info(`Booting simulator: ${device.name}`);
try {
// Use spawnSync with array to prevent command injection via device.udid
spawnSync("xcrun", ["simctl", "boot", device.udid], { stdio: "ignore" });
} catch {
// May already be booting
}
}
// Open Simulator app
spawnSync("open", ["-a", "Simulator"], { stdio: "ignore" });
// Wait for simulator to be ready with polling
await waitForSimulatorBoot(device.udid, SIMULATOR_BOOT_WAIT_MS);
// Install the app
log.info("Installing app on simulator...");
try {
// Use spawnSync with array to prevent command injection
const installResult = spawnSync(
"xcrun",
["simctl", "install", device.udid, sanitizedBinaryPath],
{ stdio: "inherit" },
);
if (installResult.status !== 0) {
throw new Error("simctl install failed");
}
} catch (error) {
log.error("Failed to install app on simulator");
throw error;
}
// Get bundle ID from Info.plist
const infoPlistPath = path.join(sanitizedBinaryPath, "Info.plist");
let bundleId: string | null = null;
try {
const bundleIdResult = spawnSync(
"/usr/libexec/PlistBuddy",
["-c", "Print:CFBundleIdentifier", infoPlistPath],
{ encoding: "utf-8" },
);
if (bundleIdResult.status === 0 && bundleIdResult.stdout) {
bundleId = validateBundleId(bundleIdResult.stdout.toString().trim());
}
} catch {
log.warn("Could not read bundle ID from Info.plist");
}
if (bundleId) {
log.info(`Launching app: ${bundleId}`);
try {
// Use spawnSync with array to prevent command injection
const launchResult = spawnSync(
"xcrun",
["simctl", "launch", device.udid, bundleId],
{ stdio: "inherit" },
);
if (launchResult.status !== 0) {
throw new Error("simctl launch failed");
}
log.success(`App launched on ${device.name}`);
} catch (error) {
log.error("Failed to launch app");
throw error;
}
}
}
// =============================================================================
// Metro Bundler
// =============================================================================
/**
* Starts the Metro bundler in a detached process.
* Tracks the process for cleanup on script exit.
* @param projectRoot - Project root directory
* @param port - Port to run Metro on
*/
function startMetroBundler(projectRoot: string, port: number): void {
log.step("Starting Metro bundler...");
metroProcess = spawn("bunx", ["expo", "start", "--port", port.toString()], {
cwd: sanitizePath(projectRoot),
stdio: "inherit",
detached: true,
env: { ...process.env },
});
if (metroProcess) {
metroProcess.unref();
}
log.info(`Metro bundler started on port ${port}`);
}
/**
* Cleanup handler for Metro bundler process.
* Ensures Metro is killed when script exits.
*/
function cleanupMetroBundler(): void {
if (metroProcess && !metroProcess.killed) {
try {
metroProcess.kill();
log.info("Metro bundler stopped");
} catch (_error) {
// Process might already be killed, ignore error
}
}
}
// Register cleanup handler for process exit
process.on("exit", cleanupMetroBundler);
// Handle process termination for graceful cleanup
const handleExit = (signal: NodeJS.Signals, exitCode: number) => () => {
log.info(`Received ${signal}, shutting down gracefully...`);
cleanupMetroBundler();
process.exit(exitCode);
};
process.on("SIGINT", handleExit("SIGINT", 130));
process.on("SIGTERM", handleExit("SIGTERM", 143));
// =============================================================================
// Production Build (IPA/App Archive)
// =============================================================================
interface AppConfig {
expo?: {
ios?: {
bundleIdentifier?: string;
};
};
ios?: {
bundleIdentifier?: string;
};
}
/**
* Reads and parses the app configuration.
* @param projectRoot - Project root directory
* @returns Parsed app configuration object
*/
function getAppConfig(projectRoot: string): AppConfig {
// Try to read app.json or app.config.js
const appJsonPath = path.join(projectRoot, "app.json");
const appConfigPath = path.join(projectRoot, "app.config.js");
const appConfigTsPath = path.join(projectRoot, "app.config.ts");
if (fs.existsSync(appJsonPath)) {
try {
const content = fs.readFileSync(appJsonPath, "utf-8");
const parsed = JSON.parse(content);
return parsed.expo || parsed;
} catch (error) {
log.warn(
`Failed to parse app.json: ${error instanceof Error ? error.message : "Unknown error"}`,
);
log.info("Continuing with default configuration");
return {};
}
}
// For JS/TS configs, we'd need to evaluate them - just return defaults
if (fs.existsSync(appConfigPath) || fs.existsSync(appConfigTsPath)) {
log.warn("Dynamic app config detected. Using defaults for bundle ID.");
return {};
}
return {};
}
function getBundleIdentifier(
projectRoot: string,
xcodeProject: XcodeProject,
): string {
const appConfig = getAppConfig(projectRoot);
// Try from app config
if (appConfig.ios?.bundleIdentifier) {
return appConfig.ios.bundleIdentifier;
}
// Try from Xcode project
const projectName = xcodeProject.name.replace(
/\.(xcworkspace|xcodeproj)$/,
"",
);
const pbxprojPath = path.join(
projectRoot,
IOS_DIR_NAME,
`${projectName}.xcodeproj`,
"project.pbxproj",
);
if (fs.existsSync(pbxprojPath)) {
try {
const content = fs.readFileSync(pbxprojPath, "utf-8");
const match = content.match(
/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)"?/,
);
if (match) {
return match[1];
}
} catch {
// Fall through
}
}
// Default fallback
return `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
}
/**
* Creates the ExportOptions.plist file for IPA export.
* @param options - Build options
* @param outputDir - Directory to write the plist to
* @returns Path to the created plist file
* @throws Error if file creation fails
*/
function createExportOptionsPlist(
options: BuildOptions,
outputDir: string,
): string {
const plistPath = path.join(outputDir, "ExportOptions.plist");
const exportMethod = options.simulator
? "development"
: options.skipCredentials
? "development"
: "ad-hoc";
const plistContent = `<?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>
<key>method</key>
<string>${exportMethod}</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>compileBitcode</key>
<false/>
</dict>
</plist>`;
try {
fs.writeFileSync(plistPath, plistContent);
if (!fs.existsSync(plistPath)) {
throw new Error("Failed to write ExportOptions.plist");
}
return plistPath;
} catch (error) {
log.error(
`Failed to create export options: ${error instanceof Error ? error.message : "Unknown error"}`,
);
throw error;
}
}
/**
* Executes xcodebuild command with standardized error handling and options.
* @param args - Arguments for xcodebuild
* @param options - Build options
* @param errorContext - Description of action for error messages
*/
function runXcodeBuildCommand(
args: string[],
options: BuildOptions,
errorContext: string,
): void {
log.info(`xcodebuild ${args.join(" ")}`);
const result = spawnSync("xcodebuild", args, {
cwd: sanitizePath(path.join(options.projectRoot, IOS_DIR_NAME)),
stdio: options.verbose ? "inherit" : "pipe",
maxBuffer: MAX_BUILD_BUFFER,
timeout: options.noTimeout ? undefined : options.buildTimeout,
env: {
...process.env,
EXPO_TV: process.env.EXPO_TV,
},
});
if (result.status !== 0) {
log.error(`${errorContext} failed`);
if (!options.verbose && result.stderr) {
const stderr = result.stderr.toString();
const stdout = result.stdout?.toString() || "";
displayBuildError(stderr, stdout);
log.info("Run with --verbose to see full build output");
} else if (!options.verbose) {
log.info(
"No detailed error output available. Run with --verbose to see more.",
);
}
process.exit(1);
}
}
/**
* Orchestrates the production build process (Archive -> Export IPA).
* Handles both device (IPA) and simulator (.app) builds.
* @param options - Build options
* @throws Error if build, archive, or export fails
*/
async function runProductionBuild(options: BuildOptions): Promise<void> {
log.step("Production Build Mode");
console.log(
` Building ${options.simulator ? "Simulator" : "Device"} artifact...`,
);
console.log("");
const xcodeProject = findXcodeProject(options.projectRoot);
log.info(`Found Xcode project: ${xcodeProject.name}`);
const scheme = resolveScheme(xcodeProject, options.scheme);
log.info(`Using scheme: ${scheme}`);
const bundleId = getBundleIdentifier(options.projectRoot, xcodeProject);
log.info(`Bundle ID: ${bundleId}`);
// Install pods if needed
if (options.install) {
installPods(options.projectRoot);
}
// Create output directory
const outputDir = options.output
? path.dirname(options.output)
: path.join(options.projectRoot, "build");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const archivePath = path.join(outputDir, `${scheme}.xcarchive`);
const projectOrWorkspaceFlag = xcodeProject.isWorkspace
? "-workspace"
: "-project";
if (options.simulator) {
// Simulator build - just build the .app
log.step("Building for Simulator...");
const buildArgs = [
projectOrWorkspaceFlag,
sanitizePath(xcodeProject.path),
"-scheme",
scheme,
"-configuration",
options.configuration,
"-sdk",
"iphonesimulator",
"-derivedDataPath",
sanitizePath(path.join(outputDir, "DerivedData")),
"ONLY_ACTIVE_ARCH=NO",
"CODE_SIGNING_ALLOWED=NO",
"build",
];
if (options.clean) {
buildArgs.unshift("clean");
}
runXcodeBuildCommand(buildArgs, options, "Simulator build");
// Find the built .app
const derivedDataPath = path.join(outputDir, "DerivedData");
const productsPath = path.join(derivedDataPath, "Build", "Products");
let appPath: string | null = null;
if (fs.existsSync(productsPath)) {
const configDir = fs
.readdirSync(productsPath)
.find((d: string) => d.includes("iphonesimulator"));
if (configDir) {
const configPath = path.join(productsPath, configDir);
const appName = fs
.readdirSync(configPath)
.find((f: string) => f.endsWith(".app"));
if (appName) {
appPath = path.join(configPath, appName);
}
}
}
if (appPath && fs.existsSync(appPath)) {
// Copy to output location
const finalPath = sanitizePath(
options.output || path.join(outputDir, `${scheme}.app`),
options.projectRoot,
);
if (fs.existsSync(finalPath)) {
fs.rmSync(finalPath, { recursive: true });
}
fs.cpSync(sanitizePath(appPath), finalPath, {
recursive: true,
});
console.log("");
log.success("Simulator build complete!");
log.info(`Output: ${finalPath}`);
} else {
log.warn("Build completed but .app not found");
}
} else {
// Device build - create archive and export IPA
log.step("Creating Archive...");
const archiveArgs = [
projectOrWorkspaceFlag,
sanitizePath(xcodeProject.path),
"-scheme",
scheme,
"-configuration",
options.configuration,
"-archivePath",
sanitizePath(archivePath),
"archive",
];
if (!options.skipCredentials) {
archiveArgs.push(
"CODE_SIGN_STYLE=Automatic",
"-allowProvisioningUpdates",
);
} else {
archiveArgs.push("CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO");
}
if (options.clean) {
archiveArgs.unshift("clean");
}
runXcodeBuildCommand(archiveArgs, options, "Archive creation");
if (!fs.existsSync(archivePath)) {
log.error("Archive was not created");
process.exit(1);
}
log.success(`Archive created: ${archivePath}`);
if (!options.skipCredentials) {
// Export IPA
log.step("Exporting IPA...");
const exportDir = path.join(outputDir, "export");
if (!fs.existsSync(exportDir)) {
fs.mkdirSync(exportDir, { recursive: true });
}
const exportPlistPath = createExportOptionsPlist(options, outputDir);
const exportArgs = [
"-exportArchive",
"-archivePath",
sanitizePath(archivePath),
"-exportPath",
sanitizePath(exportDir),
"-exportOptionsPlist",
sanitizePath(exportPlistPath),
"-allowProvisioningUpdates",
];
runXcodeBuildCommand(exportArgs, options, "IPA export");
// Find the IPA
const ipaFile = fs
.readdirSync(exportDir)
.find((f: string) => f.endsWith(".ipa"));
if (ipaFile) {
const ipaPath = path.join(exportDir, ipaFile);
const finalPath = sanitizePath(
options.output || path.join(outputDir, `${scheme}.ipa`),
options.projectRoot,
);
if (finalPath !== ipaPath) {
fs.copyFileSync(ipaPath, finalPath);
}
console.log("");
log.success("Production build complete!");
log.info(`IPA: ${finalPath}`);
log.info(`Archive: ${archivePath}`);
} else {
log.warn("IPA not found in export directory");
log.info(`Archive available at: ${archivePath}`);
}
} else {
// Create unsigned IPA manually from the archive
log.step("Creating unsigned IPA from archive...");
const productsPath = path.join(archivePath, "Products", "Applications");
if (!fs.existsSync(productsPath)) {
log.error("Could not find Products/Applications in archive");
log.info(`Archive available at: ${archivePath}`);
process.exit(1);
}
const appName = fs
.readdirSync(productsPath)
.find((f: string) => f.endsWith(".app"));
if (!appName) {
log.error("Could not find .app in archive");
log.info(`Archive available at: ${archivePath}`);
process.exit(1);
}
const appPath = path.join(productsPath, appName);
const payloadDir = path.join(outputDir, "Payload");
const ipaPath = sanitizePath(
options.output || path.join(outputDir, `${scheme}.ipa`),
options.projectRoot,
);
// Clean up previous Payload directory if exists
if (fs.existsSync(payloadDir)) {
fs.rmSync(payloadDir, { recursive: true });
}
fs.mkdirSync(payloadDir, { recursive: true });
// Copy .app to Payload
log.info("Copying app to Payload folder...");
fs.cpSync(
sanitizePath(appPath),
path.join(payloadDir, path.basename(appPath)),
{
recursive: true,
},
);
// Create IPA (zip the Payload folder)
log.info("Creating IPA...");
const safeOutputDir = sanitizePath(outputDir);
const safeIpaPath = ipaPath; // Already sanitized above
// Zip Payload directory to create IPA
// Using /usr/bin/zip to ensure compatibility
const zipArgs = ["-r", "-y", safeIpaPath, "Payload"];
const zipResult = spawnSync("zip", zipArgs, {
cwd: safeOutputDir, // Run zip from output dir so Payload is at root of archive
stdio: options.verbose ? "inherit" : "pipe",
env: { ...process.env },
});
if (zipResult.status !== 0) {
log.error("Failed to create IPA");
if (!options.verbose) {
console.error(zipResult.stderr?.toString());
}
process.exit(1);
}
// Clean up Payload directory
fs.rmSync(payloadDir, { recursive: true });
console.log("");
log.success("Unsigned IPA created!");
log.info(`IPA: ${ipaPath}`);
log.info(`Archive: ${archivePath}`);
log.warn(
"Note: This IPA is unsigned and cannot be installed on devices without signing.",
);
}
}
}
// =============================================================================
// Main
// =============================================================================
/**
* Main entry point for the iOS build script.
* Handles argument parsing, environment setup, and dispatching to appropriate build flow.
* @throws Error if build fails to complete
*/
async function main(): Promise<void> {
assertPlatform();
const options = parseArgs(process.argv);
console.log("\n");
log.step("Standalone iOS Build Script");
console.log(` EXPO_TV=${process.env.EXPO_TV || "not set"}`);
console.log(` Mode: ${options.production ? "Production" : "Development"}`);
console.log(` Configuration: ${options.configuration}`);
console.log(` Project Root: ${options.projectRoot}`);
console.log("\n");
// Production build mode
if (options.production) {
await runProductionBuild(options);
return;
}
// Development build mode (original behavior)
// Find Xcode project
const xcodeProject = findXcodeProject(options.projectRoot);
log.info(`Found Xcode project: ${xcodeProject.name}`);
// Resolve scheme
const scheme = resolveScheme(xcodeProject, options.scheme);
log.info(`Using scheme: ${scheme}`);
// Resolve device
const device = resolveDevice(options.device);
log.info(`Target device: ${device.name} (${device.udid})`);
// Install pods if needed
if (options.install) {
installPods(options.projectRoot);
}
// Build the app
const buildArgs = getXcodeBuildArgs(xcodeProject, scheme, device, options);
const env = getProcessEnv(options);
try {
const buildOutput = await runXcodeBuild(buildArgs, env, options.verbose);
// Find the built binary
const binaryPath = extractBinaryPath(buildOutput);
if (binaryPath && fs.existsSync(binaryPath)) {
log.success(`Built app at: ${binaryPath}`);
// Start Metro bundler if needed
if (options.bundler && options.configuration === "Debug") {
startMetroBundler(options.projectRoot, options.port);
}
// Launch the app
await launchApp(binaryPath, device);
console.log("\n");
log.success("Build complete!");
if (options.bundler) {
log.info("Metro bundler is running. Press Ctrl+C to stop.");
}
} else {
log.warn("Built successfully but could not locate app binary");
log.info("Check the Xcode build output for the .app location");
}
} catch (_error) {
log.error("Development build failed");
process.exit(1);
}
}
main().catch((error) => {
log.error(error.message);
process.exit(1);
});