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:
parent
e2b87dd864
commit
8503110761
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue