Improve mobile emulator start script (#6063)

* Enhance mobile emulator script with --port option and robust IP lookup

Introduce support for configuring the Metro bundler port via --port, and
allow overriding the CLI command name using the ORCA_CLI environment variable.

Additionally, improve LAN IP detection and verification so that Metro URLs
are correctly resolved and tested for reachability. Finally, fix the
worktree argument passed during the emulator attach step.

* Improve mobile emulator script shutdown
This commit is contained in:
Jinjing 2026-06-22 00:41:50 -07:00 committed by GitHub
parent e2b87dd864
commit 8503110761
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 155 additions and 25 deletions

View File

@ -9,6 +9,7 @@
* Options:
* --worktree <path> Worktree path (default: auto-detect)
* --device <name> Device name (default: 'iPhone 17 Pro')
* --port <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 <path> Worktree path (default: auto-detect)
--device <name> Device name (default: 'iPhone 17 Pro')
--port <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)