#!/usr/bin/env node /** * Start Orca Mobile server and load it in the iOS emulator. * Looks for emulators in the given worktree. * * Usage: * node scripts/start-emulator.mjs [--worktree ] [--device ] * * Options: * --worktree Worktree path (default: auto-detect) * --device Device name (default: 'iPhone 17 Pro') * --port Metro port (default: first available from 8081) * --no-open Don't open the app URL automatically * --no-pair Don't create a temporary paired desktop runtime * --wait-for-ready Wait for Metro to be ready before opening URL * --screenshot Take a screenshot after opening */ import { spawn, execFile } from 'node:child_process' import net from 'node:net' import os from 'node:os' import { promisify } from 'node:util' import path from 'node:path' import process from 'node:process' import readline from 'node:readline' import { registerWorktreeForPairingRuntime, startHeadlessPairingRuntime } from './start-emulator-pairing-runtime.mjs' import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs' const execFileAsync = promisify(execFile) const DEFAULT_METRO_PORT = 8081 const METRO_PORT_SEARCH_LIMIT = 100 // Parse CLI arguments const args = process.argv.slice(2) const options = { worktree: null, device: 'iPhone 17 Pro', port: null, open: true, pair: true, waitForReady: false, screenshot: false } for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === '--worktree' && i + 1 < args.length) { options.worktree = args[++i] } else if (arg === '--device' && i + 1 < args.length) { options.device = args[++i] } else if (arg === '--port' && i + 1 < args.length) { options.port = args[++i] } else if (arg === '--no-open') { options.open = false } else if (arg === '--no-pair') { options.pair = false } else if (arg === '--wait-for-ready') { options.waitForReady = true } else if (arg === '--screenshot') { options.screenshot = true } else if (arg === '--help' || arg === '-h') { console.log(`Usage: node scripts/start-emulator.mjs [options] Options: --worktree Worktree path (default: auto-detect) --device Device name (default: 'iPhone 17 Pro') --port Metro port (default: first available from 8081) --no-open Don't open the app URL automatically --no-pair Don't create a temporary paired desktop runtime --wait-for-ready Wait for Metro to be ready before opening URL --screenshot Take a screenshot after opening --help, -h Show this help message `) process.exit(0) } } const ORCA_CLI = process.env.ORCA_CLI || 'orca' // Colors for output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' } function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`) } function logStep(step, message) { log(`[${step}] ${message}`, 'cyan') } function logError(message) { log(`[error] ${message}`, 'red') } function logSuccess(message) { log(`[ok] ${message}`, 'green') } function logInfo(message) { log(`[info] ${message}`, 'yellow') } function assertIosSimulatorPlatform() { if (process.platform !== 'darwin') { throw new Error('iOS Simulator automation requires macOS and Xcode.') } } // Execute orca CLI command async function orca(args, options = {}) { const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, { cwd: options.cwd || process.cwd(), env: options.env || process.env, encoding: 'utf8', timeout: options.timeout || 30000 }) return { stdout: stdout.trim(), stderr: stderr.trim() } } // Get worktree path - either from CLI or auto-detect async function getWorktree() { if (options.worktree) { return path.resolve(options.worktree) } try { const { stdout } = await orca(['worktree', 'current', '--json']) const result = JSON.parse(stdout) // Handle both response formats const worktreePath = result.worktree?.path || result.result?.worktree?.path if (worktreePath) { return worktreePath } } catch { // Fall through to current directory } return process.cwd() } // Get mobile directory path (worktree/mobile or current directory if already in mobile) function getMobileDir(worktree) { const currentDir = process.cwd() if (!options.worktree && path.basename(currentDir) === 'mobile') { return currentDir } return path.join(worktree, 'mobile') } async function ensureMobileDependencies(worktree) { const mobileDir = getMobileDir(worktree) await ensureMobileExpoCli(mobileDir, { logStep, logSuccess }) } // Attach to emulator async function attachEmulator(worktree, device, runtime) { logStep('1', `Attaching to emulator: ${device.name}`) try { await orca(['emulator', 'attach', device.udid, '--worktree', worktree, '--focus', '--json'], { cwd: worktree, env: runtime?.env || process.env, timeout: 60000 }) logSuccess(`Attached to ${device.name}`) } catch (error) { logError(`Failed to attach to emulator: ${error.message}`) throw error } } // List available simulators async function listSimulators() { try { const { stdout } = await execFileAsync('xcrun', ['simctl', 'list', 'devices', 'available'], { encoding: 'utf8' }) // Parse the output to find iPhone devices const lines = stdout.split('\n') const devices = [] let currentRuntime = '' for (const line of lines) { const runtimeMatch = line.match(/^-- (.+) --$/) if (runtimeMatch) { currentRuntime = runtimeMatch[1] } else { const deviceMatch = line.match(/^\s+(.+?) \(([A-F0-9-]+)\)\s*(\(.*\))?\s*$/) if (deviceMatch && currentRuntime.includes('iOS')) { devices.push({ name: deviceMatch[1].trim(), udid: deviceMatch[2], runtime: currentRuntime, status: deviceMatch[3] || '' }) } } } return devices } catch (error) { logError(`Failed to list simulators: ${error.message}`) return [] } } // Find the best device to use async function findBestDevice(requestedDevice) { const devices = await listSimulators() if (devices.length === 0) { throw new Error('No iOS simulators found. Make sure Xcode is installed.') } // First try exact match let device = devices.find((d) => d.name === requestedDevice) // Then try partial match if (!device) { device = devices.find((d) => d.name.toLowerCase().includes(requestedDevice.toLowerCase())) } // Fall back to first iPhone if (!device) { device = devices.find((d) => d.name.includes('iPhone')) } // Last resort: first available if (!device) { device = devices[0] } return device } function lanIpCandidates() { const entries = Object.entries(os.networkInterfaces()).flatMap(([name, interfaces]) => (interfaces || []).map((iface) => ({ name, iface })) ) return entries .filter(({ name, iface }) => { if (!iface || iface.family !== 'IPv4' || iface.internal) { return false } if (iface.address.startsWith('169.254.')) { return false } return !/^(awdl|bridge|gif|llw|p2p|stf|utun)/.test(name) }) .sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name)) .map(({ iface }) => iface.address) } function interfaceRank(name) { if (/^(en|eth|wlan)/.test(name)) { return 0 } return 1 } function isLoopbackHost(hostname) { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0' } function normalizeMetroUrl(rawUrl) { try { const url = new URL(rawUrl) const lanIp = lanIpCandidates()[0] if (lanIp && isLoopbackHost(url.hostname)) { url.hostname = lanIp } return url.toString().replace(/\/$/, '') } catch { return rawUrl } } function metroUrlCandidates(initialUrl) { try { const url = new URL(initialUrl) const hosts = [url.hostname, ...lanIpCandidates(), 'localhost', '127.0.0.1'] const uniqueHosts = [...new Set(hosts.filter(Boolean))] return uniqueHosts.map((host) => { const candidate = new URL(url.toString()) candidate.hostname = host return candidate.toString().replace(/\/$/, '') }) } catch { return [initialUrl] } } function devClientUrlForMetroUrl(url) { return `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(url)}` } function canListenOnPort(port) { return new Promise((resolve, reject) => { const server = net.createServer() server.unref() server.on('error', (error) => { if (error.code === 'EADDRINUSE' || error.code === 'EACCES') { resolve(false) return } reject(error) }) server.listen({ port, host: '0.0.0.0' }, () => { server.close(() => resolve(true)) }) }) } async function findAvailableMetroPort(startPort) { const endPort = startPort + METRO_PORT_SEARCH_LIMIT for (let port = startPort; port < endPort; port++) { if (await canListenOnPort(port)) { return port } } throw new Error(`No available Metro port found from ${startPort} to ${endPort - 1}`) } async function resolveMetroPort() { if (options.port) { const requestedPort = Number(options.port) if (!Number.isInteger(requestedPort) || requestedPort <= 0 || requestedPort > 65535) { throw new Error(`Invalid Metro port: ${options.port}`) } return requestedPort } const port = await findAvailableMetroPort(DEFAULT_METRO_PORT) if (port !== DEFAULT_METRO_PORT) { logInfo(`Port ${DEFAULT_METRO_PORT} is already in use; using ${port} instead`) } return port } // Start Metro bundler async function startMetro(worktree) { logStep('2', 'Starting Metro bundler...') const mobileDir = getMobileDir(worktree) const metroPort = await resolveMetroPort() return new Promise((resolve, reject) => { const env = { ...process.env, EXPO_NO_TELEMETRY: '1' } // Use local expo CLI directly instead of pnpm start to avoid workspace issues const expoPath = getMobileExpoExecutablePath(mobileDir) if (!expoPath) { reject(new Error('Mobile Expo CLI is missing after dependency setup.')) return } const expoArgs = ['start', '--host', 'lan', '--port', String(metroPort)] logInfo(`Using expo at: ${expoPath}`) const metro = spawn(expoPath, expoArgs, { cwd: mobileDir, env, stdio: ['pipe', 'pipe', 'pipe'] }) let output = '' let url = null let resolved = false let exited = false let rl = null let rlErr = null const metroResult = () => ({ process: metro, url, output, isExited: () => exited, closeOutput: () => { rl?.close() rlErr?.close() metro.stdin?.destroy() metro.stdout?.destroy() metro.stderr?.destroy() } }) // Parse Metro output for the development URL rl = readline.createInterface({ input: metro.stdout }) rl.on('line', (line) => { output += line + '\n' process.stdout.write(colors.dim + line + colors.reset + '\n') // Look for "Waiting on" message from Metro // When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL const waitingMatch = line.match(/Waiting on (http:\/\/[^:]+):(\d+)/) if (waitingMatch && !resolved) { const host = waitingMatch[1] const port = waitingMatch[2] url = normalizeMetroUrl(`${host}:${port}`) logInfo(`Found Metro URL: ${url}`) if (!options.waitForReady) { resolved = true resolve(metroResult()) } } // Also check for the dev-client URL format directly const urlMatch = line.match(/exp\+orca-mobile:\/\/expo-development-client\/\?url=([^\s]+)/) if (urlMatch && !resolved) { url = normalizeMetroUrl(decodeURIComponent(urlMatch[1])) logInfo(`Found Metro URL: ${url}`) if (!options.waitForReady) { resolved = true resolve(metroResult()) } } // Also check for "packager-status:running" or ready indicator if ( line.includes('packager-status:running') || line.includes('Metro waiting') || line.includes('Logs for your project will appear below') ) { if (url && !resolved) { resolved = true resolve(metroResult()) } } }) // Also check stderr rlErr = readline.createInterface({ input: metro.stderr }) rlErr.on('line', (line) => { output += line + '\n' process.stderr.write(colors.red + line + colors.reset + '\n') }) metro.on('error', (error) => { if (!resolved) { resolved = true reject(new Error(`Failed to start Metro: ${error.message}`)) } }) metro.on('exit', (code) => { exited = true if (!resolved) { resolved = true if (code !== 0) { reject(new Error(`Metro exited with code ${code}`)) } else { resolve(metroResult()) } } }) // Timeout after 2 minutes setTimeout(() => { if (!resolved) { resolved = true metro.kill() reject(new Error('Timeout waiting for Metro to start')) } }, 120000) }) } // Open the app in the simulator async function openInSimulator(url, deviceUdid) { logStep('3', 'Opening app in simulator...') const fullUrl = devClientUrlForMetroUrl(url) try { await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, fullUrl]) logSuccess('Opened app in simulator') } catch (error) { logError(`Failed to open app: ${error.message}`) throw error } } async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktree) { if (!pairingUrl || !options.open) { return } logStep('4', 'Pairing mobile app to temporary desktop runtime...') await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) await new Promise((resolve) => setTimeout(resolve, 2000)) // Why: the mobile app intentionally asks for a trust confirmation before // saving a host. This lands on the Pair button on current iPhone simulators. await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], { cwd: worktree, env: runtime?.env || process.env, timeout: 30000 }) logSuccess('Opened pairing link and confirmed Pair') } // Take a screenshot async function takeScreenshot( deviceUdid, outputPath = path.join(os.tmpdir(), 'orca-mobile-ios.png') ) { logStep('4', 'Taking screenshot...') try { await execFileAsync('xcrun', ['simctl', 'io', deviceUdid, 'screenshot', outputPath]) logSuccess(`Screenshot saved to: ${outputPath}`) return outputPath } catch (error) { logError(`Failed to take screenshot: ${error.message}`) return null } } // Verify Metro is reachable async function verifyMetro(url) { const urlObj = new URL(url) const statusUrl = new URL('/status', `${urlObj.protocol}//${urlObj.host}`).toString() const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) try { const response = await fetch(statusUrl, { signal: controller.signal }) return (await response.text()).includes('packager-status:running') } catch { return false } finally { clearTimeout(timeout) } } async function findReachableMetroUrl(initialUrl) { for (const candidate of metroUrlCandidates(initialUrl)) { if (await verifyMetro(candidate)) { return { url: candidate, reachable: true } } } return { url: initialUrl, reachable: false } } // Main function async function main() { log(colors.bright + 'Starting Orca Mobile in Emulator\n' + colors.reset) let pairingRuntime = null try { assertIosSimulatorPlatform() // Get worktree const worktree = await getWorktree() logInfo(`Using worktree: ${worktree}`) await ensureMobileDependencies(worktree) pairingRuntime = await startHeadlessPairingRuntime({ enabled: options.pair, orcaCli: ORCA_CLI, cwd: process.cwd(), lanIpCandidates, logStep, logSuccess }) await registerWorktreeForPairingRuntime(pairingRuntime, worktree, { orca, logStep, logSuccess }) // Find best device const device = await findBestDevice(options.device) logInfo(`Using device: ${device.name} (${device.runtime})`) // Why: emulator helpers are worktree-scoped in Orca; attach is idempotent // for the active worktree, while a global helper list cannot prove that. await attachEmulator(worktree, device, pairingRuntime) // Start Metro const metro = await startMetro(worktree) logSuccess('Metro is running') // Verify Metro is reachable const reachableMetro = await findReachableMetroUrl(metro.url) if (reachableMetro.url !== metro.url) { logInfo(`Using reachable Metro URL: ${reachableMetro.url}`) } metro.url = reachableMetro.url if (!reachableMetro.reachable) { logError('Metro is not reachable from this machine.') logInfo('The app may still work if the simulator can access the LAN IP.') } else { logSuccess('Metro is reachable') } // Open in simulator if (options.open) { await openInSimulator(metro.url, device.udid) await openPairingUrlInSimulator( pairingRuntime?.pairingUrl, device.udid, pairingRuntime, worktree ) // Take screenshot if requested if (options.screenshot) { // Wait a moment for the app to load await new Promise((r) => setTimeout(r, 3000)) await takeScreenshot(device.udid) } } else { logInfo(`Metro URL: ${metro.url}`) logInfo(`Dev-client URL: ${devClientUrlForMetroUrl(metro.url)}`) logInfo('Omit --no-open to automatically open in simulator') } log(colors.bright + '\nSetup complete!' + colors.reset) logInfo('Press Ctrl+C to stop Metro and the temporary desktop runtime') // Keep running until Metro exits await new Promise((resolve) => { let stopping = false let stopTimeout = null const finish = () => { if (stopTimeout) { clearTimeout(stopTimeout) } metro.process.off('exit', finish) process.off('SIGINT', stopMetro) process.off('SIGTERM', stopMetro) metro.closeOutput?.() pairingRuntime?.stop() resolve() } const stopMetro = () => { if (stopping) { finish() return } stopping = true metro.process.kill('SIGINT') stopTimeout = setTimeout(finish, 2000) stopTimeout.unref?.() } metro.process.once('exit', finish) if (metro.isExited()) { finish() return } process.once('SIGINT', stopMetro) process.once('SIGTERM', stopMetro) }) process.exit(0) } catch (error) { pairingRuntime?.stop() logError(error.message) process.exit(1) } } main()