diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index e6d8245b6..7b71dd6a3 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -9,6 +9,7 @@ * Options: * --worktree Worktree path (default: auto-detect) * --device Device name (default: 'iPhone 17 Pro') + * --port Metro port (default: Expo default) * --no-open Don't open the app URL automatically * --wait-for-ready Wait for Metro to be ready before opening URL * --screenshot Take a screenshot after opening @@ -28,6 +29,7 @@ const args = process.argv.slice(2) const options = { worktree: null, device: 'iPhone 17 Pro', + port: null, open: true, waitForReady: false, screenshot: false @@ -39,6 +41,8 @@ for (let i = 0; i < args.length; i++) { 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 === '--wait-for-ready') { @@ -51,6 +55,7 @@ for (let i = 0; i < args.length; i++) { Options: --worktree Worktree path (default: auto-detect) --device Device name (default: 'iPhone 17 Pro') + --port Metro port (default: Expo default) --no-open Don't open the app URL automatically --wait-for-ready Wait for Metro to be ready before opening URL --screenshot Take a screenshot after opening @@ -60,6 +65,8 @@ Options: } } +const ORCA_CLI = process.env.ORCA_CLI || 'orca' + // Colors for output const colors = { reset: '\x1b[0m', @@ -101,7 +108,7 @@ function assertIosSimulatorPlatform() { // Execute orca CLI command async function orca(args, options = {}) { - const { stdout, stderr } = await execFileAsync('orca', args, { + const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, { cwd: options.cwd || process.cwd(), encoding: 'utf8', timeout: options.timeout || 30000 @@ -144,7 +151,7 @@ async function attachEmulator(worktree, device) { logStep('1', `Attaching to emulator: ${device.name}`) try { - await orca(['emulator', 'attach', device.udid, '--worktree', 'active', '--focus', '--json'], { + await orca(['emulator', 'attach', device.udid, '--worktree', worktree, '--focus', '--json'], { cwd: worktree }) logSuccess(`Attached to ${device.name}`) @@ -219,6 +226,67 @@ async function findBestDevice(requestedDevice) { 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)}` +} + // Start Metro bundler async function startMetro(worktree) { logStep('2', 'Starting Metro bundler...') @@ -233,8 +301,12 @@ async function startMetro(worktree) { // Use local expo CLI directly instead of pnpm start to avoid workspace issues const expoPath = path.join(mobileDir, 'node_modules', '.bin', 'expo') + const expoArgs = ['start', '--host', 'lan'] + if (options.port) { + expoArgs.push('--port', options.port) + } logInfo(`Using expo at: ${expoPath}`) - const metro = spawn(expoPath, ['start', '--host', 'lan'], { + const metro = spawn(expoPath, expoArgs, { cwd: mobileDir, env, stdio: ['pipe', 'pipe', 'pipe'] @@ -243,9 +315,26 @@ async function startMetro(worktree) { 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 - const rl = readline.createInterface({ input: metro.stdout }) + rl = readline.createInterface({ input: metro.stdout }) rl.on('line', (line) => { output += line + '\n' process.stdout.write(colors.dim + line + colors.reset + '\n') @@ -256,43 +345,42 @@ async function startMetro(worktree) { if (waitingMatch && !resolved) { const host = waitingMatch[1] const port = waitingMatch[2] - // Construct the dev-client URL with LAN IP - const lanIp = - Object.values(os.networkInterfaces()) - .flat() - .find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || host - url = `http://${lanIp}:${port}` + url = normalizeMetroUrl(`${host}:${port}`) logInfo(`Found Metro URL: ${url}`) if (!options.waitForReady) { resolved = true - resolve({ process: metro, url, output }) + resolve(metroResult()) } } // Also check for the dev-client URL format directly - const urlMatch = line.match(/exp\+orca-mobile:\/\/expo-development-client\/\?url=(.+)/) + const urlMatch = line.match(/exp\+orca-mobile:\/\/expo-development-client\/\?url=([^\s]+)/) if (urlMatch && !resolved) { - url = decodeURIComponent(urlMatch[1]) + url = normalizeMetroUrl(decodeURIComponent(urlMatch[1])) logInfo(`Found Metro URL: ${url}`) if (!options.waitForReady) { resolved = true - resolve({ process: metro, url, output }) + resolve(metroResult()) } } // Also check for "packager-status:running" or ready indicator - if (line.includes('packager-status:running') || line.includes('Metro waiting')) { + 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({ process: metro, url, output }) + resolve(metroResult()) } } }) // Also check stderr - const rlErr = readline.createInterface({ input: metro.stderr }) + rlErr = readline.createInterface({ input: metro.stderr }) rlErr.on('line', (line) => { output += line + '\n' process.stderr.write(colors.red + line + colors.reset + '\n') @@ -306,12 +394,13 @@ async function startMetro(worktree) { }) metro.on('exit', (code) => { + exited = true if (!resolved) { resolved = true if (code !== 0) { reject(new Error(`Metro exited with code ${code}`)) } else { - resolve({ process: metro, url, output }) + resolve(metroResult()) } } }) @@ -331,7 +420,7 @@ async function startMetro(worktree) { async function openInSimulator(url, deviceUdid) { logStep('3', 'Opening app in simulator...') - const fullUrl = `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(url)}` + const fullUrl = devClientUrlForMetroUrl(url) try { await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, fullUrl]) @@ -376,6 +465,15 @@ async function verifyMetro(url) { } } +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) @@ -400,9 +498,14 @@ async function main() { logSuccess('Metro is running') // Verify Metro is reachable - const isReachable = await verifyMetro(metro.url) - if (!isReachable) { - logError('Metro is not reachable. The URL may be using 127.0.0.1 instead of LAN IP.') + 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') @@ -420,6 +523,7 @@ async function main() { } } else { logInfo(`Metro URL: ${metro.url}`) + logInfo(`Dev-client URL: ${devClientUrlForMetroUrl(metro.url)}`) logInfo('Omit --no-open to automatically open in simulator') } @@ -428,11 +532,37 @@ async function main() { // Keep running until Metro exits await new Promise((resolve) => { - metro.process.on('exit', resolve) - process.on('SIGINT', () => { + 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?.() + 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) { logError(error.message) process.exit(1)