import { spawn, spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { devices, listSelectableDeviceModels, type DeviceModel, } from 'sootsim-engine/settings' import { WebSocket } from 'ws' import { probePort } from '../../scripts/dev-server-scanner' import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url' import { isLoopbackHost } from '../../src/backend-origin' import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants' import { applyRNXConfigToUrl, mergeRNXConfig, RNX_CONFIG_QUERY_PARAM, type RNXConfig, } from '../../src/config' import { resolveConnectionInput, type ResolvedDevBundle, } from '../../src/dev-bundle-resolution' import { flowTimeoutScale } from '../../src/flow-timeout-scale' import { isDaemonLockfileFresh, isDevBridgeLockfileFresh, readActiveRuntime, readDaemonLockfile, readDevBridgeLockfiles, readLiveRuntimeVersions, readSimulatorDriverPreference, runtimeDir, } from '../../src/home-paths' import { applyHotMode } from '../../src/native-dev-bundle-url' import { ensureProfile } from '../../src/profiles' import { rnxPublicBrand } from '../../src/public-brand' import { resolveDevCheckoutRuntimeRoot, runtimeVersionHostname, } from '../../src/runtime-assets' import { rnxRuntime } from '../../src/runtime-delivery' import { loadRNXAppConfig } from '../app-config' import { type DiscoveredAppFontSpec, discoverNativeLinkedAppFonts, encodeAppFontsWire, resolveMetroProjectRoot, } from '../app-fonts' import { resolveAppProject, type ResolvedAppProject } from '../app-project' import { discoverConfigPluginAppSplash, encodeAppSplashWire } from '../app-splash' import { printBridgeFailureDiagnostics } from '../bridge-diagnostics' import { clearCurrentSimId, getStableOwnerPid, readCurrentSimId, saveCurrentSimId, } from '../current-sim' import { getDriver } from '../drivers/registry' import { openUrl as openUrlInBrowser } from '../open-url' import { rethrowIfExit, rnxExit } from '../run-rnx' import { rnxSelfInvocation } from '../self-invocation' import { BridgeSimLockedError, createBridge, createBridgeFromParsed, formatLockOwner, parseBridgeCliArgs, printBridgeWorldNotice, resolveBridgePortForPin, resolveBridgeWorld, worldForBridgePort, type BridgeSimInfo, } from '../ws-bridge' import { inspectDescribe, inspectWaitReady } from './inspect/core' import { isAgentEnv } from './inspect/env' import type { DriverId, DriverLaunchResult } from '../drivers/types' interface ControlCommandOptions { port?: number timeoutMs?: number runtimeConfig?: RNXConfig } interface DriverConnectWaitOptions { attempts: number intervalMs: number timeoutMs: number hostTimeoutMs: number } const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000 const DRIVER_CONNECT_INTERVAL_MS = 100 const DRIVER_CONNECT_HOST_GRACE_MS = 5_000 const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000 // built-in shell apps use pathname-based routing, not ?bundle= params const SHELL_APPS: Record = { rn: '/rn', connectrn: '/rn', 'connect-rn': '/rn', clock: '/app/clock', 'native-ui': '/app/native-ui', tamagui: '/app/tamagui', settings: '/app/settings', photos: '/app/photos', camera: '/app/camera', } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } function parsePositiveIntegerEnv(name: string): number | null { const value = process.env[name]?.trim() if (!value) return null const parsed = Number(value) return Number.isInteger(parsed) && parsed > 0 ? parsed : null } export function resolveDriverConnectWaitOptions(): DriverConnectWaitOptions { const timeoutMs = parsePositiveIntegerEnv('SOOTSIM_DRIVER_CONNECT_TIMEOUT_MS') ?? parsePositiveIntegerEnv('SOOTSIM_PW_CONNECT_TIMEOUT_MS') ?? DEFAULT_DRIVER_CONNECT_TIMEOUT_MS return { timeoutMs, hostTimeoutMs: timeoutMs + DRIVER_CONNECT_HOST_GRACE_MS, intervalMs: DRIVER_CONNECT_INTERVAL_MS, attempts: Math.max(1, Math.ceil(timeoutMs / DRIVER_CONNECT_INTERVAL_MS)), } } function isDriverId(value: string): value is DriverId { return value === 'electron' || value === 'playwright' } function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean { if (driverId === 'playwright') { return sim.meta?.sootsimHostDriver === 'playwright' } return sim.userAgent?.includes('Electron/') === true } // resolve headless for a driver launch. explicit flags always win // (--headless → true, --headed → false). otherwise: a headed default // on mac/windows. on linux with no DISPLAY/WAYLAND_DISPLAY there is no // X server, so the playwright driver auto-flips to headless with a // single-line note. electron ignores headless mode, so it defaults false. function resolveDriverHeadless(args: string[], driverId: string): boolean { if (args.includes('--headless')) return true if (args.includes('--headed')) return false if ( driverId === 'playwright' && process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY ) { console.error( ' no display detected → running playwright headless (override with --headed)', ) return true } return false } function normalizeKnownTarget(target: string): string { return target.trim() } export async function ensureBridgeForOpen( preferredPort: number, explicitPort = false, ): Promise { const lock = readDaemonLockfile() assertExplicitBridgeLock(preferredPort, explicitPort, lock) const preferredWorld = worldForBridgePort(preferredPort) if ( (explicitPort || preferredWorld !== null) && (await canConnectToBridge(preferredPort, 250)) ) { return preferredPort } if (explicitPort && preferredWorld !== null) { throw new Error(`rnx open: requested bridge port ${preferredPort} is not reachable.`) } if ( !explicitPort && lock && isDaemonLockfileFresh(lock) && (await canConnectToBridge(lock.bridgePort, 500)) ) { return lock.bridgePort } if (lock && isDaemonLockfileFresh(lock)) { throw new Error( `rnx bridge lockfile is fresh (port ${lock.bridgePort}) but the bridge is not reachable. run \`rnx daemon restart\` or \`rnx daemon uninstall\`.`, ) } await ensureRuntimeForOpen() const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)]) let childExit: string | null = null child.once('exit', (code, signal) => { childExit = String(code ?? signal ?? 'unknown') }) child.unref() const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS while (Date.now() < deadline) { const nextLock = readDaemonLockfile() assertExplicitBridgeLock(preferredPort, explicitPort, nextLock) const selectedPort = explicitPort ? preferredPort : nextLock?.bridgePort if ( nextLock && isDaemonLockfileFresh(nextLock) && selectedPort !== undefined && (await canConnectToBridge(selectedPort, 250)) ) { console.error( ` Started a local bridge on port ${selectedPort}. Run \`rnx daemon install\` to keep it ready between commands.`, ) return selectedPort } if (childExit) { throw new Error(`rnx bridge exited before becoming ready (exit ${childExit})`) } await sleep(150) } throw new Error( `rnx bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`, ) } function assertExplicitBridgeLock( preferredPort: number, explicitPort: boolean, lock: ReturnType, ): void { if ( !explicitPort || !lock || !isDaemonLockfileFresh(lock) || lock.bridgePort === preferredPort ) { return } if ( readDevBridgeLockfiles().some( (devBridge) => devBridge.bridgePort === preferredPort && isDevBridgeLockfileFresh(devBridge), ) ) { return } throw new Error( `${rnxPublicBrand.commandName} open: requested bridge port ${preferredPort}, but the active daemon owns port ${lock.bridgePort}. explicit --port never selects another bridge; stop that daemon or use an isolated RNX_HOME.`, ) } async function ensureRuntimeForOpen() { const active = readActiveRuntime() if (active && fs.existsSync(runtimeDir(active))) return console.error(' installing rnx engine runtime...') const { runRuntime } = await import('./runtime') await runRuntime(['install'], {}) } function spawnSootsim(args: string[]) { const { executable, prefixArgs } = rnxSelfInvocation() return spawn(executable, [...prefixArgs, ...args], { detached: true, stdio: 'ignore', env: process.env, }) } function canConnectToBridge(port: number, timeoutMs: number): Promise { return new Promise((resolve) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs }) let settled = false const finish = (ok: boolean) => { if (settled) return settled = true if (ws.readyState === WebSocket.OPEN) ws.close() else if (ws.readyState === WebSocket.CONNECTING) ws.terminate() resolve(ok) } ws.once('open', () => finish(true)) ws.on('error', () => finish(false)) setTimeout(() => finish(false), timeoutMs) }) } function looksLikeSootsimUrl(target: string): boolean { try { const url = new URL(target) const pathname = url.pathname.replace(/\/+$/, '') || '/' return ( url.searchParams.has('open') || url.searchParams.has('port') || url.searchParams.has('bundle') || url.searchParams.has('demo') || url.pathname.includes('/sootsim/index.html') || pathname === '/sootsim' || url.pathname === '/__soot' || url.pathname === '/__soot/' || pathname === '/rn' || /^\/rn\/[^/]+$/i.test(pathname) || /^\/app\/[^/]+$/i.test(pathname) || pathname === '/__soot/rn' || /^\/__soot\/rn\/[^/]+$/i.test(pathname) || /^\/__soot\/app\/[^/]+$/i.test(pathname) ) } catch { return false } } function looksLikeShellPathTarget(target: string): boolean { try { const url = new URL(target, 'http://sootsim.local') if (url.origin !== 'http://sootsim.local') return false const pathname = url.pathname.replace(/\/+$/, '') || '/' return ( pathname === '/rn' || /^\/rn\/[^/]+$/i.test(pathname) || /^\/app\/[^/]+$/i.test(pathname) || pathname === '/__soot/rn' || /^\/__soot\/rn\/[^/]+$/i.test(pathname) || /^\/__soot\/app\/[^/]+$/i.test(pathname) ) } catch { return false } } function buildShellPathOpenUrl(target: string, baseUrl: string): string { const targetUrl = new URL(target, 'http://sootsim.local') const url = new URL(baseUrl) const prefix = getShellRoutePrefix(url.pathname) const routePath = targetUrl.pathname.startsWith('/__soot/') ? targetUrl.pathname.slice('/__soot'.length) : targetUrl.pathname url.pathname = `${prefix}${routePath}` url.search = targetUrl.search url.hash = targetUrl.hash return url.toString() } // rnx share/preview/build links (e.g. https://rnxsim.com/preview/, // https://contrast.dev/build///) are full player pages // served by the Contrast app — NOT Metro bundles and NOT driveable shell sims. // passing one to `rnx open` used to fall through to the bundle resolver, // which fetched the HTML page and choked with "Unexpected token '<'". two // hard reasons they can't become a CLI-driveable sim: // 1. the engine only attaches to the local CLI bridge from a localhost / // 127.0.0.1 origin (see engine `mcp/ws-bridge.ts` connect gate), so a // remote preview page never registers a sim. // 2. a captured share's transformed bundle is pinned to the engine build // that produced it — its lazy `/engine-tenant/assets/*` chunk hashes // only exist on that deploy, so a remote share can't be re-hosted // through the local dev shell (the dynamic chunk import 404s). // recognize these URLs so `open` opens them for viewing instead. function looksLikeSootSimShareUrl(target: string): boolean { try { const pathname = new URL(target).pathname.replace(/\/+$/, '') || '/' return /^\/preview\/[^/]+$/.test(pathname) || /^\/build\/.+/.test(pathname) } catch { return false } } export async function resolveBundleTarget(target: string): Promise { const normalized = normalizeKnownTarget(target) const resolved = await resolveConnectionInputForCli(normalized) return resolved.bundleUrl } function localBasePort(target: string): number | null { try { const url = new URL(target.startsWith('http') ? target : `http://${target}`) const pathname = url.pathname || '/' if ((pathname !== '/' && pathname !== '') || url.search || url.hash) return null if (!isLoopbackHost(url.hostname)) return null const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80 return Number.isFinite(port) && port > 0 ? port : null } catch { return null } } // a dev server started alongside open connect may still be booting when the // first probe lands. retry briefly so staging does not fail on a server that // becomes probe-ready a few seconds later. const DEV_BUNDLE_RESOLVE_ATTEMPTS = 15 const DEV_BUNDLE_RESOLVE_INTERVAL_MS = 1000 async function resolveConnectionInputForCli(target: string): Promise { const normalized = normalizeKnownTarget(target) const registeredPort = /^\d+$/.test(normalized) ? Number(normalized) : localBasePort(normalized) let lastError: Error | null = null for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) { if (registeredPort && registeredPort > 0) { const discovered = await probePort(registeredPort) // a provisional bundle URL is enough to LIST the server but not to open // it; fall through to resolveConnectionInput, which asks the manifest // again with a budget that fits a real dev server. if (discovered && !discovered.bundleUrlProvisional) { return { bundleUrl: discovered.bundleUrl, port: discovered.port, framework: discovered.framework, projectName: discovered.projectName, } } } try { return await resolveConnectionInput(normalized) } catch (error) { if ( !(error instanceof Error) || !error.message.startsWith('could not resolve a native bundle') ) { throw error } lastError = error } await sleep(DEV_BUNDLE_RESOLVE_INTERVAL_MS) } throw ( lastError ?? new Error('could not resolve a native bundle for ' + normalized + ' after retrying.') ) } function getShellRoutePrefix(pathname: string): string { const normalized = pathname.replace(/\/+$/, '') || '/' if (normalized === '/__soot' || normalized.startsWith('/__soot/')) { return '/__soot' } return '' } function buildConnectOpenUrl(target: string, baseUrl: string): string { const normalizedTarget = normalizeKnownTarget(target) const url = new URL(baseUrl) const prefix = getShellRoutePrefix(url.pathname) url.pathname = `${prefix}/rn` url.searchParams.delete('bundle') url.searchParams.delete('demo') url.searchParams.delete('app') url.searchParams.delete('open') url.searchParams.delete('port') if (/^\d+$/.test(normalizedTarget)) { url.pathname = `${prefix}/rn/${normalizedTarget}` } else { url.pathname = `${prefix}/rn` url.searchParams.set('open', normalizedTarget) } return url.toString() } function shouldResolveBundleTargetInCli(target: string): boolean { const normalizedTarget = normalizeKnownTarget(target) if (/^\d+$/.test(normalizedTarget)) return true if (/^https?:\/\//i.test(normalizedTarget)) return true return /^(localhost|127\.0\.0\.1|\[::1\]|[^/]+\.localhost):\d+(?:\/.*)?$/i.test( normalizedTarget, ) } async function buildResolvedBundleOpenUrl( target: string, baseUrl: string, opts: BuildShellUrlOptions = {}, ): Promise { const resolved = opts.resolvedBundle ?? (await resolveConnectionInputForCli(target)) const url = new URL(baseUrl) const prefix = getShellRoutePrefix(url.pathname) url.pathname = `${prefix}/rn` url.searchParams.delete('open') url.searchParams.delete('port') url.searchParams.delete('demo') url.searchParams.delete('app') const bundleUrl = opts.hot === undefined ? resolved.bundleUrl : applyHotMode(resolved.bundleUrl, opts.hot) url.searchParams.set('bundle', bundleUrl) return url.toString() } function normalizeReplacementPath(filePath: string): string { if (filePath.startsWith('~/')) { return path.join(os.homedir(), filePath.slice(2)) } if (path.isAbsolute(filePath)) return filePath return path.resolve(process.cwd(), filePath) } function parseOpenRuntimeConfig(args: string[]): RNXConfig | undefined { const modules: NonNullable = {} const remap: Record = {} for (let i = 0; i < args.length; i++) { if (args[i] === '--replace') { const raw = args[i + 1] if (!raw) { console.error(' rnx open: --replace expects =') rnxExit(1) } const eqIndex = raw.indexOf('=') if (eqIndex <= 0 || eqIndex === raw.length - 1) { console.error(' rnx open: --replace expects =') rnxExit(1) } const moduleName = raw.slice(0, eqIndex).trim() const filePath = normalizeReplacementPath(raw.slice(eqIndex + 1).trim()) if (!fs.existsSync(filePath)) { console.error(` rnx open: replacement file not found: ${filePath}`) rnxExit(1) } modules[moduleName] = { file: filePath } i++ continue } // --remap = rewrites a guest fetch's host:port at // runtime (config.network.remap). repeatable. lets a flow against an app // that hardcodes a port reach a differently-bound service. if (args[i] === '--remap') { const raw = args[i + 1] const eqIndex = raw ? raw.indexOf('=') : -1 if (!raw || eqIndex <= 0 || eqIndex === raw.length - 1) { console.error(' rnx open: --remap expects =') rnxExit(1) } remap[raw.slice(0, eqIndex).trim()] = raw.slice(eqIndex + 1).trim() i++ continue } } const config: RNXConfig = {} if (Object.keys(modules).length > 0) config.modules = modules if (Object.keys(remap).length > 0) config.network = { remap } return config.modules || config.network ? config : undefined } async function resolveAppProjectForBundleUrl( bundleUrl?: string, ): Promise { if (!bundleUrl) return null const advertisedRoot = await resolveMetroProjectRoot(bundleUrl) return advertisedRoot ? resolveAppProject(advertisedRoot) : null } async function ensureConfiguredRuntime(version: string): Promise { if (fs.existsSync(path.join(runtimeDir(version), 'index.html'))) return console.error(` installing configured engine runtime ${version}...`) const result = await rnxRuntime.install({ version, setActive: false, protectVersions: readLiveRuntimeVersions(), }) console.error(` installed engine runtime ${result.version}`) } export function applyRuntimeGenerationOrigin(baseUrl: string, version?: string): string { const selectedVersion = version ?? (resolveDevCheckoutRuntimeRoot() ? undefined : (readActiveRuntime() ?? undefined)) if (!selectedVersion) return baseUrl const url = new URL(baseUrl) if (!isLoopbackHost(url.hostname)) { if (!version) return baseUrl throw new Error( `runtimeVersion ${selectedVersion} requires a local rnx host; got ${url.origin}`, ) } url.hostname = runtimeVersionHostname(selectedVersion) return url.toString() } export function resolveDefaultShellBaseUrl(): string { return resolveBridgeWorld().shellBaseUrl } /** the shell that belongs to a bridge port: the world a fresh lockfile claims * for it, otherwise the shell that shares the port's own offset. every caller * that has already settled on a bridge port goes through this, so a page can * never open in one stack's shell while the CLI polls another's bridge. the * development bridge registry keeps every live port addressable, while this * function preserves an explicit caller's already-selected world. */ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string { return ( worldForBridgePort(bridgePort)?.shellBaseUrl ?? resolveSootsimShellBaseUrlForBridgePort(bridgePort) ?? resolveDefaultShellBaseUrl() ) } // stamp the engine page url with `?appFonts=,...` so the // engine's app-font-loader registers native config-only fonts at boot. fonts // loaded by JS already flow through normal runtime fetch capture; this covers // UIAppFonts-style assets that Metro can serve but the guest bundle never // fetches itself. auto-discovery only — scans the repo for expo-font config // plugin declarations, no user-facing flag. // // each discovered font is HEAD-checked against the metro host before we // include it on the URL. fonts whose metro asset URL 404s (typo in the // expo-font plugin config, missing file, or a wrong app.config picked up // by the scan) are dropped silently so the engine never tries to fetch a // known-bad URL at boot — that would just log a registration failure and // confuse anyone reading the page console. async function stampAppFonts( urlStr: string, project?: ResolvedAppProject | null, ): Promise { try { const url = new URL(urlStr) const bundleUrl = url.searchParams.get('bundle') || '' if (!bundleUrl || !project) return urlStr const specs = discoverNativeLinkedAppFonts({ bundleUrl, projectDir: project.appDir, }) if (specs.length === 0) return urlStr const reachable = await filterReachableAppFonts(specs) if (reachable.length === 0) return urlStr url.searchParams.set('appFonts', encodeAppFontsWire(reachable)) return url.toString() } catch { return urlStr } } // HEAD-check each font URL with a short timeout. metro returns 200 (or // sometimes a redirect) for assets it can resolve and 500 with an ENOENT // trace for paths it cannot. anything not in the 2xx-3xx range gets dropped. async function filterReachableAppFonts( specs: DiscoveredAppFontSpec[], ): Promise { const results = await Promise.all( specs.map(async (spec): Promise => { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 1500) try { const res = await fetch(spec.url, { method: 'HEAD', signal: controller.signal, }) if (res.status >= 200 && res.status < 400) return spec return null } catch { return null } finally { clearTimeout(timer) } }), ) const reachable: DiscoveredAppFontSpec[] = [] for (const spec of results) { if (spec !== null) reachable.push(spec) } return reachable } async function resolveShellUrl( target: string, baseUrl: string, opts: BuildShellUrlOptions = {}, ): Promise { // no target = open home screen if (!target) { return new URL(baseUrl).toString() } if (looksLikeSootsimUrl(target)) { const url = new URL(target) const base = new URL(baseUrl) const baseConfig = base.searchParams.get(RNX_CONFIG_QUERY_PARAM) if (baseConfig && !url.searchParams.has(RNX_CONFIG_QUERY_PARAM)) { url.searchParams.set(RNX_CONFIG_QUERY_PARAM, baseConfig) } return url.toString() } if (looksLikeShellPathTarget(target)) { return buildShellPathOpenUrl(target, baseUrl) } // check if this is a shell app (pathname-based route, not bundle) const shellPath = SHELL_APPS[target.toLowerCase()] if (shellPath) { const url = new URL(baseUrl) const prefix = getShellRoutePrefix(url.pathname) url.pathname = `${prefix}${shellPath}` return url.toString() } if (shouldResolveBundleTargetInCli(target)) { return buildResolvedBundleOpenUrl(target, baseUrl, opts) } return buildConnectOpenUrl(target, baseUrl) } // stamp the engine page url with `?appSplash=` so the // engine paints the app's REAL configured splash image + background on boot // (instead of the contrast device-default chrome). same discovery path as // fonts: scan the bundle's project for the expo-splash-screen / // react-native-bootsplash config plugin (or legacy `expo.splash`). the metro // manifest fallback in the engine covers the typed-URL case; this wire is the // instant-first-paint optimization. async function stampAppSplash( urlStr: string, project?: ResolvedAppProject | null, ): Promise { try { const url = new URL(urlStr) const bundleUrl = url.searchParams.get('bundle') || '' if (!bundleUrl || !project) return urlStr const spec = discoverConfigPluginAppSplash({ bundleUrl, projectDir: project.appDir, }) if (!spec) return urlStr // HEAD-check the image before stamping so a typo'd/missing splash path never // makes the engine fetch a known-bad URL at boot. if (spec.imageUrl) { const reachable = await splashImageReachable(spec.imageUrl) if (!reachable) return urlStr } url.searchParams.set('appSplash', encodeAppSplashWire(spec)) return url.toString() } catch { return urlStr } } async function splashImageReachable(imageUrl: string): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 1500) try { const res = await fetch(imageUrl, { method: 'HEAD', signal: controller.signal }) return res.status >= 200 && res.status < 400 } catch { return false } finally { clearTimeout(timer) } } export interface BuildShellUrlOptions { // when set, forces the metro `hot` query on the resolved bundle URL. true // for interactive opens (HMR runtime in bundle, edits hot-apply), false // for `--driver playwright`/agent runs (no in-bundle HMR wiring, no ws // chatter, deterministic e2e). undefined leaves whatever value metro's // manifest emitted — matches real iOS sims so metro's bundle cache is // shared. set by `runOpenCommand` only. hot?: boolean // device model slug (e.g. iphone-16) stamped onto the engine URL as // ?device= — the shell reads it at boot (main.tsx) and live-switch // uses the same param. without this, every driver-opened sim silently ran // whatever profile the shell last persisted. device?: string resolvedBundle?: ResolvedDevBundle project?: ResolvedAppProject | null // deterministic-capture mode, stamped onto the engine URL as ?proof=1. the // shell worker, compositor worker and tenant worker each read it off the // host page URL at boot (syncProofModeFromInitUrl) and pin everything that // would otherwise differ frame to frame: the TextInput caret stops blinking // and stays visible, and notifications stop auto-dismissing. capture-frame // also reads the raw param and stops clipping the device corners, so a // capture matches simctl's rectangular framebuffer. without it a // screen holding a focused text field never reaches three identical idle // frames, so the screenshot guard burns all 12 attempts and then fails. proof?: boolean } function bundleUrlOf(urlStr: string): string | undefined { try { return new URL(urlStr).searchParams.get('bundle') || undefined } catch { return undefined } } export async function buildShellUrl( target: string, baseUrl: string = resolveDefaultShellBaseUrl(), opts: BuildShellUrlOptions = {}, ): Promise { const resolved = await resolveShellUrl(target, baseUrl, opts) // callers that hand us a bare port or url (the flow/maestro runner, the // electron companion) have no project to pass. derive it from the bundle // metro advertises so those paths get the same UIAppFonts and splash // stamping an `rnx open` gets. without it a flow renders the app's text in a // fallback typeface, which then reads as a conformance diff. const project = opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved))) const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project) if (!opts.device && !opts.proof) return stamped const url = new URL(stamped) if (opts.device) url.searchParams.set('device', opts.device) if (opts.proof) url.searchParams.set('proof', '1') return url.toString() } function deriveCurrentSimBaseUrl( sim: BridgeSimInfo | null | undefined, fallback: string, ): string { const source = sim?.url || sim?.origin || fallback try { const url = new URL(source) url.searchParams.delete('bundle') url.searchParams.delete('demo') url.searchParams.delete('app') url.searchParams.delete('open') url.searchParams.delete('port') url.searchParams.delete('inspectOpen') // appFonts is target-specific (it points at the metro asset endpoint of // the bundle being launched). carrying it over from a prior sim URL // means a stale font URL from an earlier launch leaks into the new one, // and `stampAppFonts` doesn't get the chance to re-derive against the // current bundle. drop it so the rebuild starts clean. url.searchParams.delete('appFonts') // appSplash is target-specific in the same way — drop it so the configured // splash re-derives against the current bundle. url.searchParams.delete('appSplash') return url.toString() } catch { return fallback } } export async function buildOpenUrl( target: string, baseUrl: string, openToken: string, opts: BuildShellUrlOptions = {}, ): Promise { const url = new URL(await buildShellUrl(target, baseUrl, opts)) url.searchParams.set('inspectOpen', openToken) return url.toString() } async function waitForSimReady( wsPort: number, commandTimeoutMs: number, simId: string, opts: { attempts?: number intervalMs?: number minNodeCount?: number } = {}, ) { // the ceiling here is attempts * intervalMs. a hosted launchApp re-evals the // whole guest bundle over a tunnel, which routinely outlasts the local // default, so scale the poll ceiling by the same factor the flow runner // scales its own waits. this still returns the moment the sim reports enough // nodes; only the deadline moves, and locally the scale is 1. const intervalMs = opts.intervalMs ?? 500 const attempts = opts.attempts ?? Math.round(30 * flowTimeoutScale()) const minNodeCount = opts.minNodeCount ?? 10 // a poll that only ever reports "timed out" cannot say whether the guest was // still booting, never installed the test hook, or was unreachable, so keep // the last observation and hand it to the caller for the failure message. let lastCount: unknown let lastError = '' for (let i = 0; i < attempts; i++) { const bridge = createBridge(wsPort, { commandTimeoutMs, simId, simIdSource: 'flag', }) try { const count = await bridge.send({ type: 'evaluate', code: '(async () => (await window.__sootsimTest?.getNodeCount()) ?? null)()', }) lastCount = count lastError = '' if (typeof count === 'number' && count > minNodeCount) { return { bridge, count } } } catch (err) { lastError = err instanceof Error ? err.message : String(err) } bridge.close() await sleep(intervalMs) } return { bridge: null, attempts, intervalMs, minNodeCount, lastCount, lastError } } export async function waitForSimMatch( wsPort: number, commandTimeoutMs: number, predicate: (sim: BridgeSimInfo) => boolean, opts: { attempts?: number; intervalMs?: number } = {}, ) { const attempts = opts.attempts ?? 30 const intervalMs = opts.intervalMs ?? 500 for (let i = 0; i < attempts; i++) { const bridge = createBridge(wsPort, { commandTimeoutMs }) try { const sims = await bridge.listSims() const match = sims.find(predicate) if (match) return match } catch { // server/sim may still be booting } finally { bridge.close() } await sleep(intervalMs) } return null } // same as waitForSimMatch, but also watches a detached host PID so a host // crash mid-connect is surfaced as a distinct outcome rather than waiting // out the full timeout (and silently treating a stale bridge entry as // success). 'host-exit' wins as soon as the host process is gone, even if // the bridge still lists the sim. export type WaitForSimMatchOrHostExitResult = | { kind: 'match'; sim: BridgeSimInfo } | { kind: 'host-exit' } | { kind: 'timeout' } export async function waitForSimMatchOrHostExit( wsPort: number, commandTimeoutMs: number, predicate: (sim: BridgeSimInfo) => boolean, hostPid: number | undefined, opts: { attempts?: number; intervalMs?: number } = {}, ): Promise { const attempts = opts.attempts ?? 30 const intervalMs = opts.intervalMs ?? 500 const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1 const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid) for (let i = 0; i < attempts; i++) { if (hostExited()) { return { kind: 'host-exit' } } const bridge = createBridge(wsPort, { commandTimeoutMs }) try { const sims = await bridge.listSims() const match = sims.find(predicate) if (match) { if (hostExited()) return { kind: 'host-exit' } return { kind: 'match', sim: match } } } catch { // server/sim may still be booting } finally { bridge.close() } if (hostExited()) { return { kind: 'host-exit' } } await sleep(intervalMs) } return { kind: 'timeout' } } async function waitForSimGone( wsPort: number, commandTimeoutMs: number, simId: string, opts: { attempts?: number; intervalMs?: number } = {}, ) { const attempts = opts.attempts ?? 20 const intervalMs = opts.intervalMs ?? 250 for (let i = 0; i < attempts; i++) { const bridge = createBridge(wsPort, { commandTimeoutMs }) try { const sims = await bridge.listSims() const sim = sims.find((entry) => entry.id === simId) if (!sim || sim.readyState !== 'open') { return true } } catch { return true } finally { bridge.close() } await sleep(intervalMs) } return false } export function playwrightHostPidForSim(sim: BridgeSimInfo): number | null { const meta = sim.meta if (!meta || meta.sootsimHostDriver !== 'playwright') return null const pid = Number(meta.sootsimHostPid) if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return null return pid } export function signalDriverLaunchConnected(result: DriverLaunchResult): boolean { if (!result.connectAckFile) return false try { fs.writeFileSync( result.connectAckFile, `${JSON.stringify({ connectedAt: Date.now(), pid: result.pid ?? null })}\n`, { flag: 'w' }, ) return true } catch { return false } } function printDriverDiagnosticLogTail(result: DriverLaunchResult) { if (!result.diagnosticLogPath) return try { const detail = fs.readFileSync(result.diagnosticLogPath, 'utf8').trim() if (!detail) { console.error(` ${path.basename(result.diagnosticLogPath)} was empty`) return } const shown = detail.length > 4_000 ? `…\n${detail.slice(-4_000)}` : detail console.error(` ${path.basename(result.diagnosticLogPath)}:`) console.error(shown) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error( ` failed to read ${path.basename(result.diagnosticLogPath)}: ${message}`, ) } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0) return true } catch (err) { return (err as NodeJS.ErrnoException)?.code === 'EPERM' } } async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (!isProcessAlive(pid)) return true await sleep(100) } return !isProcessAlive(pid) } // a sim record's host pid can outlive its process; after pid reuse the number // may point at an unrelated process (real incident 2026-07-14: a stale record // made `rnx close` SIGTERM the shared vite shell dev server, taking the // whole dev stack down). only signal a pid whose argv proves it is still a // rnx playwright host launcher. function isPlaywrightHostProcess(pid: number): boolean { // `ps eww` prints argv then the process ENVIRONMENT for same-user // processes. the marker must live in the env SUFFIX (everything past the // plain argv), because argv text can contain the string coincidentally — // e.g. a shell wrapper that exports SOOTSIM_PW_* vars in its script body. const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) if (plain.status !== 0 || withEnv.status !== 0) return false const argv = (plain.stdout || '').trimEnd() const full = (withEnv.stdout || '').trimEnd() const envSuffix = full.startsWith(argv) ? full.slice(argv.length) : full return envSuffix.includes('SOOTSIM_PW_URL=') } export async function terminatePlaywrightHostsForSims( sims: BridgeSimInfo[], ids: string[], ) { const wanted = new Set(ids) const pids = new Set() for (const sim of sims) { if (!wanted.has(sim.id)) continue const pid = playwrightHostPidForSim(sim) if (pid) pids.add(pid) } for (const pid of pids) { if (!isPlaywrightHostProcess(pid)) { console.log(` skipped pid ${pid}: not a rnx playwright host (stale sim record)`) continue } try { process.kill(pid, 'SIGTERM') } catch {} if (await waitForProcessExit(pid)) { console.log(` closed playwright host process ${pid}`) continue } try { process.kill(pid, 'SIGKILL') console.log(` force-closed playwright host process ${pid}`) } catch {} } } function resolveTargetSim(sims: BridgeSimInfo[], requestedId?: string): BridgeSimInfo { if (requestedId) { const normalizedId = requestedId.trim() const match = sims.find((sim) => sim.id === normalizedId) if (!match) { throw new Error(`no sim connected with id ${normalizedId}`) } return match } const primary = sims.find((sim) => sim.isPrimary && sim.readyState === 'open') if (primary) return primary const firstOpen = sims.find((sim) => sim.readyState === 'open') if (firstOpen) return firstOpen throw new Error('no sim connected') } export function printOpenedSim( openUrl: string, sim: BridgeSimInfo, mode: | 'desktop companion' | 'bridge' | 'direct shell open' | 'current sim' | `${string} driver`, quiet = false, ) { const [printableOpenUrl, printableSimUrl] = [openUrl, sim.url].map((value) => { if (!value) return value try { const parsed = new URL(value) let changed = false if (parsed.searchParams.has(RNX_CONFIG_QUERY_PARAM)) { parsed.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]') changed = true } const bundle = parsed.searchParams.get('bundle') if (bundle) { try { const parsedBundle = new URL(bundle) if (parsedBundle.searchParams.has(RNX_CONFIG_QUERY_PARAM)) { parsedBundle.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]') parsed.searchParams.set('bundle', parsedBundle.toString()) changed = true } } catch {} } if (changed) return parsed.toString() } catch {} return value }) if (quiet) { console.log(` Opened ConnectRN [${mode}]`) return } console.log( ` ${mode === 'current sim' ? 'loaded' : 'opened'}: ${printableOpenUrl} [${mode}]`, ) // agents pipe around the JSON to grab the id; humans just want to know // the current sim got updated without scanning the JSON blob. console.log(` current sim: ${sim.id}`) console.log(JSON.stringify({ simId: sim.id, url: printableSimUrl }, null, 2)) } // after a successful open, agents almost always want to know what's on the // screen. running describe inline saves a separate roundtrip. tightly // bounded — a cold guest bundle takes 5-15s to fully route, so we don't // try to wait for that. instead: settle for a moment to let the first // paint land, then describe whatever's on screen. for cold loads that's // a splash; the caller can `rnx describe` again a second later. for // warm loads it's the real screen. guarded by --no-describe. async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) { if (args.includes('--no-describe')) return const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1' try { // brief settle, then dump the tree directly. don't go through // runDescribeSubcommand — its screen-transition wait can block for many // seconds on a still-routing guest app, which is exactly what we want // to skip for the open-side describe. caller can `rnx describe` // again later for post-routing state. await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 }) const bridge = createBridge(wsPort, { commandTimeoutMs: 3000, simId, cliLabel: 'open --describe', simIdSource: 'flag', }) try { let readyProbe: Awaited> | null = null try { readyProbe = await inspectWaitReady(bridge, 1) } catch {} if (readyProbe) { if (!readyProbe.ready) { const reason = readyProbe.loadingText ? `still showing "${readyProbe.loadingText}"` : readyProbe.flag !== true ? 'guest ready event has not fired' : readyProbe.targets <= 0 ? 'no visible app content is inspectable yet' : 'app tree is still stabilizing' console.log( ` app still loading: ${reason} (nodes: ${readyProbe.nodes}, targets: ${readyProbe.targets})`, ) console.log(' before interacting, run: rnx wait ready --max-ms 120000') } } const result = await inspectDescribe(bridge, { describe: true, verbose: false, filter: '', compact: true, hideXy: false, maxDepth: 12, }) if (!result?.tree) return console.log('') console.log(result.tree) } finally { bridge.close() } } catch { // describe is a convenience — never fail the open command on it } finally { if (prevQuiet === undefined) { delete process.env.SOOTSIM_QUIET_TARGET_NOTICE } else { process.env.SOOTSIM_QUIET_TARGET_NOTICE = prevQuiet } } } // poll node count and return as soon as two samples match across `stableMs`, // capped by `maxMs`. brief and bounded so this never blocks the open command. async function waitForTreeStable( wsPort: number, simId: string, opts: { stableMs: number; maxMs: number }, ): Promise { const deadline = Date.now() + opts.maxMs const probeCode = `(async () => (await window.__sootsimTest?.getNodeCount?.()) || 0)()` const bridge = createBridge(wsPort, { commandTimeoutMs: 2000, simId, simIdSource: 'flag', }) try { let lastCount = -1 let stableSince = 0 while (Date.now() < deadline) { let count = -1 try { const c = await bridge.send({ type: 'evaluate', code: probeCode }) if (typeof c === 'number') count = c } catch { // transient — try again } if (count >= 0 && count === lastCount) { if (Date.now() - stableSince >= opts.stableMs) return } else { lastCount = count stableSince = Date.now() } await sleep(50) } } finally { bridge.close() } } export async function runLinkCommand(args: string[], opts: ControlCommandOptions = {}) { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: opts.timeoutMs, }) if (parsed.positional.length !== 1) { console.error(' rnx open: expected exactly one ') console.error(' examples: rnx open /settings | rnx open myapp://settings') rnxExit(1) } const bridge = createBridgeFromParsed(parsed) const simHint = parsed.simId ? ` --sim ${parsed.simId}` : '' try { const target = await openAppUrl(bridge, parsed.positional[0] ?? '') console.log(` opened deep link: ${target}`) } catch (err) { console.error(` link failed: ${err instanceof Error ? err.message : String(err)}`) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } finally { bridge.close() } } export async function runOpenCommand( args: string[], opts: ControlCommandOptions = {}, ): Promise<{ simId: string; launched: boolean } | undefined> { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: opts.timeoutMs, stripBooleanFlags: [ '--new', '--headless', '--headed', '--ephemeral', '--hot', '--no-hmr', '--no-describe', '--quiet', '--proof', ], stripValueFlags: [ '--base-url', '--replace', '--driver', '--profile', '--cdp-port', '--device', '--viewport', ], }) const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile') const quiet = args.includes('--quiet') const ephemeralProfile = args.includes('--ephemeral') // --cdp-port exposes Chrome's remote-debugging endpoint on the opened sim so // it can be cpu-profiled while bridge-driven (playwright driver only). const cdpPortArg = args.find((_, i) => args[i - 1] === '--cdp-port') const cdpPort = cdpPortArg ? Number(cdpPortArg) : undefined if (cdpPortArg && (!Number.isFinite(cdpPort) || cdpPort! <= 0)) { console.error( ` rnx open: --cdp-port must be a positive port number, got "${cdpPortArg}"`, ) rnxExit(1) } if (requestedProfileId && ephemeralProfile) { console.error(' rnx open: --profile cannot be combined with --ephemeral') rnxExit(1) } const profileId = requestedProfileId ? ensureProfile(requestedProfileId).id : undefined const openInNewSim = args.includes('--new') || !!profileId || ephemeralProfile const commandConfig = parseOpenRuntimeConfig(args) if (openInNewSim && parsed.simIdSource === 'flag') { console.error( ' rnx open: --new, --profile, and --ephemeral cannot be combined with --sim', ) rnxExit(1) } const target = parsed.positional[0] || '' // `--port` is the WS BRIDGE port; the app/dev-server port is positional. the // two read identically at a glance, so `rnx open --new --port 4260` used to // launch the Connect launcher against the default bridge and report success, // and the probe that followed measured whatever sim happened to be selected. // there is no meaningful `open --port` without a target, so refuse it. if (!target && args.includes('--port')) { const portValue = args[args.indexOf('--port') + 1] ?? '' console.error( ` rnx open: --port is the WS bridge port, not the app port. to open the app on ${portValue}, pass it positionally: \`rnx open ${portValue}\``, ) rnxExit(1) } if (isAppDeepLinkTarget(target)) { if (openInNewSim) { console.error( ' rnx open: deep links target the current app; remove --new, --profile, or --ephemeral', ) rnxExit(1) } await runLinkCommand(args, opts) return } // a preview/build link is a viewing page, not a driveable sim target — // open it in the browser and explain rather than mis-loading it as a // bundle. see looksLikeSootSimShareUrl for why these can't be driven. if (looksLikeSootSimShareUrl(target)) { const isLocal = (() => { try { return isLoopbackHost(new URL(target).hostname) } catch { return false } })() console.log( ' that’s an rnx preview/build link — a full player page, not a driveable sim.', ) console.log(' opening it in your browser for viewing…') try { await openUrlInBrowser(target, { background: false }) } catch (err) { console.error( ` could not launch a browser: ${err instanceof Error ? err.message : String(err)}`, ) console.error(` open it yourself: ${target}`) rnxExit(1) } console.log( isLocal ? ' to drive it under the CLI, point `rnx open` at the app’s dev port instead (e.g. `rnx open 8081`).' : ' the CLI can’t drive a remote preview (the engine only attaches to the local bridge from localhost). to drive the app, run it locally and `rnx open `.', ) return } const resolvedBundle = shouldResolveBundleTargetInCli(target) ? await resolveConnectionInputForCli(target) : undefined const project = await resolveAppProjectForBundleUrl(resolvedBundle?.bundleUrl) const appConfig = await loadRNXAppConfig(project) const configuredRuntimeVersion = appConfig?.runtimeVersion const runtimeConfig = mergeRNXConfig( mergeRNXConfig( appConfig ? { ...appConfig, runtimeVersion: undefined } : undefined, opts.runtimeConfig, ), commandConfig, ) if (configuredRuntimeVersion) { await ensureConfiguredRuntime(configuredRuntimeVersion) } // keep the bridge port and the shell base URL in one world: --base-url // retargets polling at THAT world's bridge (otherwise open builds the page // against one runtime while polling the other bridge for registration and // times out after ~125s), and a pin nothing serves is refused rather than // falling back to the discovered default, which used to send // ensureBridgeForOpen below off to start a second shell. const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url') if (explicitBaseUrl) { const pinned = resolveBridgePortForPin({ baseUrl: explicitBaseUrl, wsPort: parsed.wsPort, explicitPort: parsed.explicitPort, }) if (pinned.error !== undefined) { console.error(` error: ${pinned.error}`) rnxExit(1) } else { parsed.wsPort = pinned.port } } parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort) // playwright is the browser-sim launcher. it owns an isolated profile and // can reap exactly its own browser tree. electron remains available for the // native desktop surface. neither path opens the user's browser profile. const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || '' if (requestedDriverId && !isDriverId(requestedDriverId)) { console.error(` unknown driver "${requestedDriverId}" — run \`rnx list --drivers\``) rnxExit(1) } const driverId: DriverId = isDriverId(requestedDriverId) ? requestedDriverId : process.env.CI === '1' || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' || isAgentEnv() || !process.stdin.isTTY ? 'playwright' : readSimulatorDriverPreference() || 'playwright' const driverHeadless = resolveDriverHeadless(args, driverId) if (cdpPort && driverId !== 'playwright') { console.error(' rnx open: --cdp-port is only honored by the playwright driver') rnxExit(1) } // metro HMR mode. interactive opens default to hot=true so edits hot-apply // through rnx's HmrClient — expo CLI's createBundleUrlSearchParams // hardcodes hot=false on every launchAsset URL (a leftover toggle real iOS // sims tolerate but rnx couldn't until now), so without this flip the // bundle prelude doesn't include metro's HMR runtime + react-refresh and // edits silently no-op. `--driver playwright` (agent / CI runs) defaults // to hot=false for deterministic e2e. explicit `--hot` / `--no-hmr` win. const hotFlag = args.includes('--hot') const noHmrFlag = args.includes('--no-hmr') if (hotFlag && noHmrFlag) { console.error(' rnx open: --hot and --no-hmr cannot be combined') rnxExit(1) } const hotMode = hotFlag ? true : noHmrFlag ? false : driverId !== 'playwright' // --device seeds the device profile for the opened sim (stamped on // the engine URL, so both playwright and electron honor it). without it // the sim runs whatever profile the // shell last persisted, which silently skews browser comparisons. const deviceArg = args.find((_, i) => args[i - 1] === '--device') if (deviceArg && !(deviceArg in devices)) { console.error(` rnx open: unknown device "${deviceArg}"`) console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`) rnxExit(1) } const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined // --viewport sizes the driver-launched page explicitly (film and // promo captures frame the 3d stage in landscape). takes precedence over // the device-derived window; without either, the driver keeps its own // default. const viewportArg = args.find((_, i) => args[i - 1] === '--viewport') const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null if (viewportArg && !viewportMatch) { console.error(` rnx open: --viewport must be x, got "${viewportArg}"`) rnxExit(1) } const explicitViewport = viewportMatch ? { width: Number(viewportMatch[1]), height: Number(viewportMatch[2]) } : undefined const buildShellUrlOpts: BuildShellUrlOptions = { hot: hotMode, device: deviceArg, resolvedBundle, project, proof: args.includes('--proof'), } // resolve the shell base URL from the bridge port we settled on, so the page // and the polling never split across worlds. const selectedShellBaseUrl = configuredRuntimeVersion ? `http://localhost:${parsed.wsPort}/` : explicitBaseUrl || resolveShellBaseUrlForBridgePort(parsed.wsPort) const baseUrl = applyRuntimeGenerationOrigin( selectedShellBaseUrl, configuredRuntimeVersion, ) const hasExplicitBaseUrl = args.includes('--base-url') printBridgeWorldNotice(parsed.wsPort, baseUrl) const savedSimId = readCurrentSimId() const shouldReuseCurrentSim = !openInNewSim // every open stamps its page URL with a one-off token and matches the sim by // it. a sim id alone cannot tell the page this open loaded from the page it // replaced, which stays registered until the browser commits the navigation. const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` const simMatchesToken = (sim: BridgeSimInfo) => sim.url?.includes(`inspectOpen=${token}`) ?? false if (shouldReuseCurrentSim) { const bridge = createBridgeFromParsed(parsed) const simHint = parsed.simId ? ` --sim ${parsed.simId}` : '' let fallBackToNewSim = false let navigatedShellUrl: string | null = null try { let targetSim: BridgeSimInfo | null = null let targetShellUrl: string | null = null try { const sims = await bridge.listSims() if (parsed.simIdSource === 'saved') { targetSim = sims.find( (sim) => sim.id === savedSimId && sim.readyState === 'open' && simUsesDriver(sim, driverId), ) ?? null if (!targetSim) { clearCurrentSimId() } } else if (parsed.simIdSource === 'flag') { targetSim = resolveTargetSim(sims, parsed.simId) if (!simUsesDriver(targetSim, driverId)) { throw new Error( `sim ${targetSim.id} is not managed by the ${driverId} driver`, ) } } if (!targetSim && parsed.simIdSource !== 'flag') { targetShellUrl = await buildShellUrl( target, applyRNXConfigToUrl(baseUrl, runtimeConfig), buildShellUrlOpts, ) const normalizedTarget = new URL(targetShellUrl) normalizedTarget.searchParams.delete('inspectOpen') targetSim = sims.find((sim) => { if ( sim.readyState !== 'open' || !sim.url || !simUsesDriver(sim, driverId) ) { return false } try { const normalizedCurrent = new URL(sim.url) normalizedCurrent.searchParams.delete('inspectOpen') return normalizedCurrent.toString() === normalizedTarget.toString() } catch { return false } }) ?? null } if (!targetSim) { if (parsed.simIdSource !== 'flag') { fallBackToNewSim = true } else { throw new Error('no sim connected') } } if (!fallBackToNewSim && targetSim) { const resolvedBaseUrl = hasExplicitBaseUrl || looksLikeSootsimUrl(target) ? baseUrl : deriveCurrentSimBaseUrl(targetSim, baseUrl) navigatedShellUrl = targetShellUrl ?? (await buildShellUrl( target, applyRNXConfigToUrl(resolvedBaseUrl, runtimeConfig), buildShellUrlOpts, )) const navigationUrl = new URL(navigatedShellUrl) navigationUrl.searchParams.set('inspectOpen', token) bridge .send({ type: 'evaluate', simId: targetSim.id, code: `window.location.href = ${JSON.stringify(navigationUrl.toString())}`, }) .catch(() => {}) } } catch (err) { console.error( ` open failed: ${err instanceof Error ? err.message : String(err)}`, ) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } if (!fallBackToNewSim && targetSim && navigatedShellUrl) { // the navigated page may keep the sim id (restored from session // storage) or register a new one, so match by token, never by id. const navigatedSim = await waitForSimMatch( parsed.wsPort, parsed.commandTimeoutMs, (sim) => sim.readyState === 'open' && simUsesDriver(sim, driverId) && simMatchesToken(sim), { attempts: Math.round(30 * flowTimeoutScale()) }, ) if (!navigatedSim) { console.error( ` timed out waiting for sim ${targetSim.id} to load ${navigatedShellUrl}`, ) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } targetSim = navigatedSim const ready = await waitForSimReady( parsed.wsPort, parsed.commandTimeoutMs, targetSim.id, ) if (!ready.bridge) { const observed = ready.lastError ? `last bridge error: ${ready.lastError}` : ready.lastCount === null ? 'guest never installed window.__sootsimTest' : `last node count: ${String(ready.lastCount)} (needs > ${ready.minNodeCount})` console.error( ` timed out waiting for current sim to load target after ${ready.attempts} polls over ${Math.round((ready.attempts * ready.intervalMs) / 1000)}s; ${observed}`, ) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } ready.bridge.close() saveCurrentSimId(targetSim.id) printOpenedSim( navigatedShellUrl, { ...targetSim, url: navigatedShellUrl }, 'current sim', quiet, ) await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args) return { simId: targetSim.id, launched: false } } } finally { bridge.close() } } const configuredBaseUrl = applyRNXConfigToUrl(baseUrl, runtimeConfig) const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts) const ownerPid = getStableOwnerPid() if (!ownerPid) { console.error( ' could not resolve a stable owning process; refusing unsupervised sim host', ) rnxExit(1) } const driver = getDriver(driverId) if (!driver) { console.error(` unknown driver "${driverId}" — run \`rnx list --drivers\``) rnxExit(1) } const connectWait = resolveDriverConnectWaitOptions() const result = await driver.launch({ url: openUrl, // the shell serves the sim page but its WS bridge may have drifted off // the shell-port-implied default. inject the bridge this command settled // on so the page does not reconnect to a stale world. re-deriving it from // the shell URL could name a third port, since a bridge with no lockfile // resolves back to the default shell URL and that shell may belong to a // live world of its own. bridgePort: parsed.wsPort, headless: driverHeadless, profileId, ephemeralProfile, cdpPort, device: deviceArg, ownerPid, viewport: explicitViewport ?? (deviceSpec ? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 } : undefined), connectTimeoutMs: connectWait.hostTimeoutMs, }) if (!result.launched) { console.error(` ${driver.name} driver: ${result.message}`) rnxExit(1) } const outcome = await waitForSimMatchOrHostExit( parsed.wsPort, parsed.commandTimeoutMs, simMatchesToken, result.pid, { attempts: connectWait.attempts, intervalMs: connectWait.intervalMs }, ) if (outcome.kind === 'host-exit') { printDriverDiagnosticLogTail(result) console.error(` ${driver.name} host crashed — sim is no longer attached`) rnxExit(1) } if (outcome.kind === 'timeout') { if (result.pid) { try { process.kill(result.pid, 'SIGTERM') console.error(` closed ${driver.name} host process ${result.pid}`) } catch {} } printDriverDiagnosticLogTail(result) console.error( ` timed out after ${connectWait.timeoutMs}ms waiting for opened sim to connect`, ) rnxExit(1) } const match = outcome.sim saveCurrentSimId(match.id) signalDriverLaunchConnected(result) printOpenedSim(openUrl, match, `${driver.name} driver`, quiet) await maybeDescribeAfterOpen(parsed.wsPort, match.id, args) return { simId: match.id, launched: true } } async function runUseLikeCommand(args: string[], opts: ControlCommandOptions = {}) { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: opts.timeoutMs, stripBooleanFlags: ['--force'], }) const force = args.includes('--force') const bridge = createBridgeFromParsed(parsed) const simHint = parsed.simId ? ` --sim ${parsed.simId}` : '' try { try { const sims = await bridge.listSims() const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId) // `use`/`focus` retarget the CLI at a sim — silently grabbing one a // human is actively driving is the same footgun `claim` guards against, // so honor the lock here too (this command historically did not, which // let `rnx use ` steal the user's live tab). if (target.lockedBy && target.lockExpiresAt && target.lockExpiresAt > Date.now()) { const secs = Math.max(0, Math.round((target.lockExpiresAt - Date.now()) / 1000)) if (target.lockedByKind === 'user-active') { // the active human is interacting with this tab. never steal it, // even with --force — mirrors `claim`, which refuses to even // report a user-active takeover. open a fresh sim instead. console.error( ` refused: ${target.id} is locked by the active user (${secs}s) — that's a live human tab`, ) console.error(` run \`rnx open --new\` for a fresh investigation sim`) rnxExit(1) } if (!force) { // another CLI agent leases it. don't hijack its target silently; // a cli lease is short (60s, refreshes per command) so this is // usually a real conflict, not a stale lock. console.error( ` refused: ${target.id} is leased by ${target.lockedBy} (${secs}s)`, ) console.error( ` \`rnx use ${target.id} --force\` to take it, or \`rnx open --new\` for a fresh sim`, ) rnxExit(1) } } await bridge.focusSim(target.id) saveCurrentSimId(target.id) console.log(` using: ${target.id}`) } catch (err) { rethrowIfExit(err) console.error(` use failed: ${err instanceof Error ? err.message : String(err)}`) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } } finally { bridge.close() } } export async function runUseCommand(args: string[], opts: ControlCommandOptions = {}) { await runUseLikeCommand(args, opts) } export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) { await runUseLikeCommand(args, opts) } export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: opts.timeoutMs, stripBooleanFlags: ['--force'], }) const force = args.includes('--force') const bridge = createBridgeFromParsed(parsed) try { try { const sims = await bridge.listSims() const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId) const priorHolder = force && target.lockedBy && target.lockedByKind !== 'user-active' ? target.lockedBy : null const result = await bridge.claim(target.id, { force }) saveCurrentSimId(target.id) const ttl = Math.max(0, Math.round((result.lockExpiresAt - Date.now()) / 1000)) const booted = result.bootedCount > 0 ? ` (booted ${result.bootedCount})` : '' console.log(` claimed: ${result.simId} [${ttl}s]${booted}`) if (priorHolder) { console.log(` took over from: ${priorHolder}`) } } catch (err) { if (err instanceof BridgeSimLockedError) { const secs = Math.max(0, Math.round(err.lock.expiresInMs / 1000)) console.error(` claim failed: locked by ${err.lock.by} for ${secs}s more`) console.error(` use --force to take it, or \`rnx open --new\` for a fresh sim`) rnxExit(1) } console.error(` claim failed: ${err instanceof Error ? err.message : String(err)}`) rnxExit(1) } } finally { bridge.close() } } // close many sims at once. fires every close, then polls listSims until the // whole set is gone or the timeout elapses — far faster than awaiting // waitForSimGone serially when clearing dozens of leaked sims. export async function closeSimsBulk( bridge: ReturnType, wsPort: number, commandTimeoutMs: number, ids: string[], ): Promise<{ closed: string[]; remaining: string[] }> { if (ids.length === 0) return { closed: [], remaining: [] } await Promise.all(ids.map((id) => bridge.closeSim(id).catch(() => {}))) const wanted = new Set(ids) for (let i = 0; i < 40; i++) { let stillOpen: string[] = [] try { const probe = createBridge(wsPort, { commandTimeoutMs }) try { const sims = await probe.listSims() stillOpen = sims .filter((s) => wanted.has(s.id) && s.readyState === 'open') .map((s) => s.id) } finally { probe.close() } } catch { stillOpen = [] } if (stillOpen.length === 0) { return { closed: [...wanted], remaining: [] } } if (i === 39) { return { closed: [...wanted].filter((id) => !stillOpen.includes(id)), remaining: stillOpen, } } await sleep(250) } return { closed: [...wanted], remaining: [] } } export interface BulkCloseRefusal { id: string lockedBy: string kind: 'cli' | 'user-active' expiresInMs: number } export interface BulkClosePlan { openIds: string[] keepId: string | null targets: string[] staleSavedId: string | null missingExplicitKeepId: string | null fallbackKeepId: string | null refused: BulkCloseRefusal[] } export function planBulkCloseTargets( sims: BridgeSimInfo[], opts: { closeOthers: boolean explicitKeepId?: string savedKeepId?: string | null force?: boolean now?: number }, ): BulkClosePlan { const open = sims.filter((sim) => sim.readyState === 'open') const openIds = open.map((sim) => sim.id) const explicitKeepId = opts.explicitKeepId?.trim() || '' const savedKeepId = opts.savedKeepId?.trim() || '' const savedIsOpen = savedKeepId ? open.some((sim) => sim.id === savedKeepId) : false const staleSavedId = savedKeepId && !savedIsOpen ? savedKeepId : null let keepId: string | null = null let missingExplicitKeepId: string | null = null let fallbackKeepId: string | null = null if (opts.closeOthers) { if (explicitKeepId) { const match = open.find((sim) => sim.id === explicitKeepId) if (match) { keepId = match.id } else { missingExplicitKeepId = explicitKeepId } } else if (savedKeepId && savedIsOpen) { keepId = savedKeepId } else { const fallback = open.find((sim) => sim.isPrimary) ?? open[0] ?? null if (fallback) { keepId = fallback.id if (savedKeepId && !savedIsOpen) fallbackKeepId = fallback.id } } } // a sim another agent holds is not ours to close. `use`/`focus`/`claim` all // refuse a live lease; bulk close historically did not look at leases at // all, so `close --all` silently killed other agents' sims mid-capture. const now = opts.now ?? Date.now() const refused: BulkCloseRefusal[] = [] for (const sim of open) { if (sim.id === keepId) continue const lockedBy = sim.lockedBy const expiresAt = sim.lockExpiresAt if (!lockedBy || !expiresAt || expiresAt <= now || sim.lockedByMe) continue const kind = sim.lockedByKind ?? 'cli' // --force takes over another agent's cli lease, the same escape hatch // `use --force` offers. a `user-active` lease is a live human tab and is // never closable, matching `claim`, which refuses to report a takeover. if (kind === 'cli' && opts.force) continue refused.push({ id: sim.id, lockedBy, kind, expiresInMs: Math.max(0, expiresAt - now), }) } const refusedIds = new Set(refused.map((entry) => entry.id)) return { openIds, keepId, targets: missingExplicitKeepId ? [] : openIds.filter((id) => id !== keepId && !refusedIds.has(id)), staleSavedId, missingExplicitKeepId, fallbackKeepId, refused, } } export async function runCloseCommand(args: string[], opts: ControlCommandOptions = {}) { const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: opts.timeoutMs, stripBooleanFlags: ['--all', '--others', '--force'], }) const bridge = createBridgeFromParsed(parsed) const simHint = parsed.simId ? ` --sim ${parsed.simId}` : '' const closeAll = args.includes('--all') const closeOthers = args.includes('--others') const force = args.includes('--force') if (closeAll || closeOthers) { try { const sims = await bridge.listSims() const savedId = readCurrentSimId() const explicitKeepId = parsed.positional[0] || (parsed.simIdSource === 'flag' ? parsed.simId : undefined) // `--others` keeps the explicitly-targeted sim, else the saved/current // sim, else primary. if an explicit keep target is gone, abort instead // of treating "keep nothing" as permission to close every open sim. const plan = planBulkCloseTargets(sims, { closeOthers, explicitKeepId, savedKeepId: savedId, force, }) for (const entry of plan.refused) { const secs = Math.round(entry.expiresInMs / 1000) console.error( entry.kind === 'user-active' ? ` skipped: ${entry.id} is locked by the active user (${secs}s) — that's a live human tab` : ` skipped: ${entry.id} is leased by ${formatLockOwner(entry.lockedBy)} (${secs}s)`, ) } if (plan.refused.some((entry) => entry.kind === 'cli') && !force) { console.error(` \`rnx close --all --force\` to close leased sims too`) } if (plan.staleSavedId) { clearCurrentSimId() } if (plan.fallbackKeepId) { saveCurrentSimId(plan.fallbackKeepId) console.log( ` saved sim ${plan.staleSavedId} is gone — keeping primary ${plan.fallbackKeepId}`, ) } if (plan.missingExplicitKeepId) { console.error( ` close failed: keep sim ${plan.missingExplicitKeepId} is not connected; not closing other sims`, ) rnxExit(1) } if (plan.targets.length === 0) { console.log( plan.keepId ? ` nothing to close — only the kept sim ${plan.keepId} is connected` : plan.refused.length > 0 ? ` nothing to close — every other connected sim is leased by someone else` : plan.staleSavedId ? ` nothing to close — no sims connected (cleared stale current sim ${plan.staleSavedId})` : ' nothing to close — no sims connected', ) return } const result = await closeSimsBulk( bridge, parsed.wsPort, parsed.commandTimeoutMs, plan.targets, ) await terminatePlaywrightHostsForSims(sims, plan.targets) // saved current sim may have just been closed — repoint or clear it. const nextSavedId = readCurrentSimId() if (nextSavedId && result.closed.includes(nextSavedId)) { if (plan.keepId) saveCurrentSimId(plan.keepId) else clearCurrentSimId() } const summary = ` closed ${result.closed.length} sim(s)${ plan.keepId ? ` (kept ${plan.keepId})` : '' }` console.log(summary) if (result.remaining.length > 0) { console.error(` close failed: still connected: ${result.remaining.join(', ')}`) rnxExit(1) } } catch (err) { rethrowIfExit(err) console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`) rnxExit(1) } finally { bridge.close() } return } try { try { const sims = await bridge.listSims() const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId) const fallbackTarget = sims.find( (sim) => sim.id !== target.id && sim.readyState === 'open', ) await bridge.closeSim(target.id) const closed = await waitForSimGone( parsed.wsPort, parsed.commandTimeoutMs, target.id, ) if (!closed) { await terminatePlaywrightHostsForSims(sims, [target.id]) console.error(` close failed: ${target.id} is still connected`) rnxExit(1) } await terminatePlaywrightHostsForSims(sims, [target.id]) if (readCurrentSimId() === target.id) { if (fallbackTarget) saveCurrentSimId(fallbackTarget.id) else clearCurrentSimId() } console.log(` closed: ${target.id}`) } catch (err) { rethrowIfExit(err) console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`) await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${simHint}`, warningsCommand: `rnx get warnings 5${simHint}`, requestsCommand: `rnx get requests 5${simHint}`, }) rnxExit(1) } } finally { bridge.close() } }