mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
648 lines
18 KiB
JavaScript
Executable File
648 lines
18 KiB
JavaScript
Executable File
#!/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 <path>] [--device <name>]
|
||
*
|
||
* Options:
|
||
* --worktree <path> Worktree path (default: auto-detect)
|
||
* --device <name> Device name (default: 'iPhone 17 Pro')
|
||
* --port <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
|
||
|
||
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 <path> Worktree path (default: auto-detect)
|
||
--device <name> Device name (default: 'iPhone 17 Pro')
|
||
--port <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'
|
||
|
||
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.')
|
||
}
|
||
}
|
||
|
||
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() }
|
||
}
|
||
|
||
async function getWorktree() {
|
||
if (options.worktree) {
|
||
return path.resolve(options.worktree)
|
||
}
|
||
|
||
try {
|
||
const { stdout } = await orca(['worktree', 'current', '--json'])
|
||
const result = JSON.parse(stdout)
|
||
const worktreePath = result.worktree?.path || result.result?.worktree?.path
|
||
if (worktreePath) {
|
||
return worktreePath
|
||
}
|
||
} catch {}
|
||
|
||
return process.cwd()
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
async function listSimulators() {
|
||
try {
|
||
const { stdout } = await execFileAsync('xcrun', ['simctl', 'list', 'devices', 'available'], {
|
||
encoding: 'utf8'
|
||
})
|
||
|
||
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 []
|
||
}
|
||
}
|
||
|
||
async function findBestDevice(requestedDevice) {
|
||
const devices = await listSimulators()
|
||
|
||
if (devices.length === 0) {
|
||
throw new Error('No iOS simulators found. Make sure Xcode is installed.')
|
||
}
|
||
|
||
let device = devices.find((d) => d.name === requestedDevice)
|
||
|
||
if (!device) {
|
||
device = devices.find((d) => d.name.toLowerCase().includes(requestedDevice.toLowerCase()))
|
||
}
|
||
|
||
if (!device) {
|
||
device = devices.find((d) => d.name.includes('iPhone'))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
// Invoke local Expo CLI directly to avoid pnpm workspace resolution.
|
||
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()
|
||
}
|
||
})
|
||
|
||
rl = readline.createInterface({ input: metro.stdout })
|
||
rl.on('line', (line) => {
|
||
output += line + '\n'
|
||
process.stdout.write(colors.dim + line + colors.reset + '\n')
|
||
|
||
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())
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
}
|
||
})
|
||
|
||
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())
|
||
}
|
||
}
|
||
})
|
||
|
||
setTimeout(() => {
|
||
if (!resolved) {
|
||
resolved = true
|
||
metro.kill()
|
||
reject(new Error('Timeout waiting for Metro to start'))
|
||
}
|
||
}, 120000)
|
||
})
|
||
}
|
||
|
||
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))
|
||
|
||
// Resend deep link after JS router mounts; freshly opened Expo can miss the first one.
|
||
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
|
||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||
|
||
// Confirm explicit pairing trust at current simulator’s Pair button.
|
||
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')
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
async function main() {
|
||
log(colors.bright + 'Starting Orca Mobile in Emulator\n' + colors.reset)
|
||
let pairingRuntime = null
|
||
|
||
try {
|
||
assertIosSimulatorPlatform()
|
||
|
||
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
|
||
})
|
||
|
||
const device = await findBestDevice(options.device)
|
||
logInfo(`Using device: ${device.name} (${device.runtime})`)
|
||
|
||
// Attach idempotently to active worktree; global emulator list cannot prove worktree-scoped ownership.
|
||
await attachEmulator(worktree, device, pairingRuntime)
|
||
|
||
const metro = await startMetro(worktree)
|
||
logSuccess('Metro is running')
|
||
|
||
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')
|
||
}
|
||
|
||
if (options.open) {
|
||
await openInSimulator(metro.url, device.udid)
|
||
await openPairingUrlInSimulator(
|
||
pairingRuntime?.pairingUrl,
|
||
device.udid,
|
||
pairingRuntime,
|
||
worktree
|
||
)
|
||
|
||
if (options.screenshot) {
|
||
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')
|
||
|
||
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()
|