// `rnx ios` and `rnx android` — start a simulator on that platform. // // the platform picks the device; the command context picks where the sim runs // (this machine, a local box, or the `rnx remote` namespace). this file resolves // the device and hands the rest to the normal open path. import { createHash } from 'node:crypto' import { DEFAULT_DEVICE_BY_PLATFORM, devices, getRuntimePlatform, listSelectableDeviceModels, type DeviceModel, type RuntimePlatform, } from 'sootsim-engine/settings' import { settingsStore } from 'sootsim-engine/settings/store' import { rnxPublicBrand } from '../../src/public-brand' import { UnsupportedRemoteHostError } from '../cloud-client' import { rethrowIfExit } from '../run-rnx' import type { CloudSession } from '../cloud-session' const PLATFORM_LABEL: Record = { ios: 'iOS', android: 'Android', } function isDeviceModel(value: string): value is DeviceModel { return value in devices } // flags this command reads for itself rather than treating as the bundle. const REMOTE_VALUE_FLAGS = new Set(['--device', '--name', '--endpoint']) function remoteInputFromArgs( args: string[], platform: RuntimePlatform, ): { bundlePath: string name: string | null reuse: boolean fresh: boolean } { const positional: string[] = [] let name: string | null = null for (let index = 0; index < args.length; index++) { const arg = args[index] if (arg === '--remote' || arg === '--nano' || arg === '--reuse' || arg === '--new') { continue } if (REMOTE_VALUE_FLAGS.has(arg)) { const value = args[++index] ?? null if (!value) throw new Error(`rnx remote ${platform} ${arg} requires a value`) if (arg === '--name') name = value continue } const assigned = arg.match(/^(--[a-z-]+)=(.*)$/) if (assigned && REMOTE_VALUE_FLAGS.has(assigned[1] ?? '')) { const value = assigned[2] ?? '' if (!value) throw new Error(`rnx remote ${platform} ${assigned[1]} requires a value`) if (assigned[1] === '--name') name = value continue } if ( arg === '--sim' || arg === '--session' || arg === '--tab' || arg.startsWith('--sim=') || arg.startsWith('--session=') || arg.startsWith('--tab=') ) { throw new Error( `rnx remote ${platform} creates a new simulator and cannot target --sim`, ) } if (arg.startsWith('-')) { throw new Error(`rnx remote ${platform} does not support ${arg}`) } positional.push(arg) } if (positional.length !== 1 || !positional[0]) { throw new Error( `usage: rnx remote ${platform} [--device ] [--reuse|--new]`, ) } const reuse = args.includes('--reuse') const fresh = args.includes('--new') if (reuse && fresh) { throw new Error(`rnx remote ${platform} cannot combine --reuse and --new`) } return { bundlePath: positional[0], name, reuse, fresh } } /** * the box's name, taken from what was built. * * a bundle arrives as a file or a URL and neither carries a name, so the last * path segment without its extension is the one thing both have. box names are * lowercase words joined by dashes, so anything else in it becomes a dash. */ function boxNameForBundle(bundlePath: string): string { const segment = bundlePath.split(/[?#]/)[0]?.split('/').filter(Boolean).at(-1) ?? '' const stem = segment.replace(/\.[^.]+$/, '') const name = stem .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') if (!name) throw new Error(`rnx remote could not name a box after ${bundlePath}`) return name } export async function runPlatformCommand( platform: RuntimePlatform, args: string[], opts: { port?: number } = {}, ): Promise { // `--device` is applied to the settings store before dispatch, so a // contradiction shows up here as a platform mismatch. refuse it by name // rather than silently overriding whichever one the user meant. const assignedDevice = args.find((arg) => arg.startsWith('--device=')) const explicitDevice = args.find((_, i) => args[i - 1] === '--device') ?? assignedDevice?.slice(9) let selectedDevice: DeviceModel if (explicitDevice !== undefined) { if (!isDeviceModel(explicitDevice)) { console.error( ` ${rnxPublicBrand.commandName} ${platform}: unknown device "${explicitDevice}"\n` + ` run \`${rnxPublicBrand.commandName} device list\` to see valid models`, ) return 1 } const devicePlatform = getRuntimePlatform(explicitDevice) if (devicePlatform !== platform) { console.error( ` ${rnxPublicBrand.commandName} ${platform}: --device ${explicitDevice} is ` + `${PLATFORM_LABEL[devicePlatform]}, not ${PLATFORM_LABEL[platform]}.\n` + ` pick one of: ${listSelectableDeviceModels() .filter((model) => getRuntimePlatform(model) === platform) .join(', ')}`, ) return 1 } selectedDevice = explicitDevice } else { // keep the user's own device when it already belongs to the platform they // asked for, so `rnx ios` after `rnx device set iphone-16` does not // silently snap back to the default model. const current = settingsStore.get('deviceModel') if (getRuntimePlatform(current) !== platform) { settingsStore.apply({ deviceModel: DEFAULT_DEVICE_BY_PLATFORM[platform] }) selectedDevice = DEFAULT_DEVICE_BY_PLATFORM[platform] } else { selectedDevice = current } } if (args.includes('--remote')) { if (platform === 'android' && args.includes('--nano')) { const error = new UnsupportedRemoteHostError('android', 'nano') console.error(` ${error.message}`) return 1 } try { if (!args.includes('--nano')) { return await runRemoteBox(platform, args, selectedDevice) } return await runRemoteCreate(platform, args, selectedDevice) } catch (error) { rethrowIfExit(error) console.error( ` ${rnxPublicBrand.commandName} ${platform} --remote failed: ${ error instanceof Error ? error.message : String(error) }`, ) return 1 } } // leftover `--*` tokens are not bundle targets. parse the same flags `open` // consumes, then refuse anything still starting with `-`. const { parseBridgeCliArgs } = await import('../ws-bridge') const leftover = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: [ '--new', '--headless', '--headed', '--ephemeral', '--hot', '--no-hmr', '--no-describe', '--quiet', '--remote', ], stripValueFlags: [ '--base-url', '--replace', '--driver', '--profile', '--cdp-port', '--device', '--viewport', '--remap', ], }).positional.find((value) => value.startsWith('-')) if (leftover) { console.error(` unknown flag: ${leftover}`) return 1 } const { runOpenCommand } = await import('./control') await runOpenCommand(args, { port: opts.port }) return 0 } /** Runs the existing remote session path without changing the local platform commands. */ export function runRemotePlatformCommand( platform: RuntimePlatform, args: string[], opts: { port?: number } = {}, ): Promise { if (platform === 'android' && args.includes('--nano')) { const error = new UnsupportedRemoteHostError('android', 'nano') console.error(` ${error.message}`) return Promise.resolve(1) } return runPlatformCommand(platform, ['--remote', ...args], opts) } const REMOTE_COMMANDS_HELP = ' commands: describe, find, get tree|node|count|memory, wait ready|selector, do, reset, logs, state, screenshot' /** * `rnx remote ios|android ` on the one customer plane: one typed * create through the instance API for both platforms, and a reattach to the * shell's own simulator when it already runs this bundle. the box stays the * host behind the API and is never created or addressed here. */ async function runRemoteCreate( platform: RuntimePlatform, args: string[], selectedDevice: string, ): Promise { const input = remoteInputFromArgs(args, platform) const [ { authHeaderOrExit, cloudAccountIdOrExit }, { claimCloudSimulator, closeCloudBox, closeCloudSession, createRemoteSim, getRemoteSim, produceCloudArtifact, }, { createCloudSession, publishCloudSessionToShell, readCloudSession, requireCloudSessionShellUpdate, }, { openInBrowser }, ] = await Promise.all([ import('../auth'), import('../cloud-client'), import('../cloud-session'), import('./box'), ]) const artifact = await produceCloudArtifact(input.bundlePath) const descriptor = requireCloudSessionShellUpdate() const { auth, header } = authHeaderOrExit(`${platform} --remote`) const accountId = cloudAccountIdOrExit(auth) const existingSession = readCloudSession() if ( existingSession?.service === 'sim' && !input.fresh && existingSession.platform === platform && existingSession.artifact?.sha256 === artifact.sha256 ) { // an api key reads the simulator back through the account, which mints a // fresh watch url with the read. any other credential rereads with the // simulator's own token and keeps the stored watch url. const live = await getRemoteSim({ apiOrigin: existingSession.apiOrigin, simId: existingSession.simId, authorization: auth.kind === 'api-key' ? header : `Bearer ${existingSession.token}`, }) if (live && (live.status === 'open' || live.status === 'hibernated')) { const claimed = await claimCloudSimulator(existingSession) const streamUrl = live.streamUrl ?? existingSession.streamUrl ?? null const session = createCloudSession({ ...existingSession, claimId: claimed.claim.id, ...(streamUrl ? { streamUrl } : {}), }) publishCloudSessionToShell(session, descriptor) console.log( ` reconnected to remote simulator: ${existingSession.simId} (${platform})`, ) console.log(` claim expires: ${claimed.claim.expiresAt}`) console.log(REMOTE_COMMANDS_HELP) return 0 } } // the new session supersedes the old one, so the shell update channel // carries exactly one update: the shell accepts nothing else. if (existingSession?.service === 'box') await closeCloudBox(existingSession) else if (existingSession) await closeCloudSession(existingSession) const created = await createRemoteSim({ bundlePath: input.bundlePath, device: selectedDevice, authorization: header, accountId, platform, reuse: input.reuse, }) try { publishCloudSessionToShell(created.session, descriptor) } catch (error) { await closeCloudSession(created.session) throw error } const receipt = created.receipt const produced = created.artifact console.log(` remote simulator: ${receipt.simId}${created.reused ? ' (reused)' : ''}`) console.log(` platform: ${platform}`) console.log(` device: ${selectedDevice}`) console.log(` artifact: ${receipt.artifact.id} (${receipt.artifact.bytes} bytes)`) console.log( ` source bundle: sha256:${produced.source.sha256} (${produced.source.bytes} bytes)`, ) console.log( ` normalized modules: ${produced.modules}; javascript ${produced.javascript.sourceBytes} -> ${produced.javascript.minifiedBytes} bytes`, ) console.log( ` embedded assets: ${produced.assets.descriptors} descriptors, ${produced.assets.variants} scales, ${produced.assets.bytes} bytes (${produced.assets.encodedBytes} encoded)`, ) // the dest directory was chosen by resolving this bundle's descriptors // against it, so say which one won rather than leaving it invisible. if (created.assetsDirectory) { console.log(` asset dest: ${created.assetsDirectory}`) } console.log(` claim expires: ${receipt.claim.expiresAt}`) console.log(REMOTE_COMMANDS_HELP) for (const warning of created.warnings) console.warn(` warning: ${warning}`) if (!created.session.streamUrl) { console.log(' the service minted no watch url for this simulator') return 0 } return openInBrowser(created.session.streamUrl) } /** * `rnx ios --remote` and `rnx android --remote` — the app runs in a micro box. * * the bundle is prepared by the box plane's own producer, the one `rnx box * create --bundle` uses: assets embedded, nothing minified and no module * identity rewritten. the nano producer's artifact is built for the nano * runner, which imports it as a module; a box page evaluates the bytes with * `(0, eval)` as Script in global scope, and a minified artifact reaches it * with no react instance for the reconciler to bind to. there is no size cap * either way: the box holds the bundle in parts, so the single-message ceiling * a nano upload answers to does not apply. * * a box created from the already-built app boots it in its own page, and this * opens the bare shell watch url minted from the box's display grant — the same * /sootsim/#remoteEngine fragment the nano service mints, pointed at the box. */ async function runRemoteBox( platform: RuntimePlatform, args: string[], selectedDevice: string, ): Promise { const command = `${platform} --remote` const input = remoteInputFromArgs(args, platform) const [ { closeCloudBox, closeCloudSession, produceCloudBoxArtifact, requestBoxWatchUrl }, box, { cloudBoxConnectUrl, ensureCloudBoxBrowser, readCloudBoxSession }, { authHeaderOrExit, cloudAccountIdOrExit }, { createCloudBoxSession, publishCloudSessionToShell, readCloudSession, serializeCloudSession, RNX_CLOUD_SESSION_ENV, }, ] = await Promise.all([ import('../cloud-client'), import('./box'), import('rnx-cloud-box/client'), import('../auth'), import('../cloud-session'), ]) const { openInBrowser } = box const artifact = await produceCloudBoxArtifact(input.bundlePath) // the session records which bundle a box already holds, so reattaching can // tell the same app from a rebuilt one. the box producer returns no digest // of its own, so take it here over the bytes that actually crossed. const artifactSha256 = createHash('sha256').update(artifact.bytes).digest('hex') const endpoint = box.cloudBoxEndpoint(args, command) if (!endpoint) return 1 const existingSession = readCloudSession() if ( existingSession?.service === 'box' && !input.fresh && existingSession.apiOrigin === endpoint && existingSession.platform === platform && existingSession.device === selectedDevice && existingSession.artifact?.sha256 === artifactSha256 ) { const live = await readCloudBoxSession({ endpoint: existingSession.apiOrigin, boxId: existingSession.boxId, accessToken: existingSession.boxToken, }) if (live.sessionId) { // the simulator lives in the box's Browser Run tab, which lapses on its // own, so a reconnect restores it before promising commands will answer. await ensureCloudBoxBrowser({ connectUrl: cloudBoxConnectUrl(existingSession.apiOrigin, existingSession.boxId), accessToken: existingSession.boxToken, }) // grants are short-lived, so a reconnect mints a fresh one rather than // reopening whatever url the create printed. const watchUrl = await requestBoxWatchUrl({ apiOrigin: existingSession.apiOrigin, boxId: existingSession.boxId, boxToken: existingSession.boxToken, }) console.log( ` reconnected to remote simulator: ${existingSession.boxId} (${platform})`, ) console.log(` open ${watchUrl}`) console.log(REMOTE_COMMANDS_HELP) return openInBrowser(watchUrl) } } const { auth, header } = authHeaderOrExit(command) const accountId = cloudAccountIdOrExit(auth) if (existingSession?.service === 'box') { await closeCloudBox(existingSession) try { publishCloudSessionToShell(null) } catch { // no shell update channel is required outside an interactive rnx shell } } else if (existingSession) { await closeCloudSession(existingSession) } const created = await box.createPrebuiltBundleBox({ bundle: new Uint8Array(artifact.bytes), name: input.name ?? boxNameForBundle(input.bundlePath), endpoint, apiOrigin: box.cloudBoxApiOrigin(args, auth), apiKey: header.replace(/^Bearer /, ''), accountId, }) // creating a box starts no browser, and only its Browser Run tab hosts the // simulator, so without this the viewer opens onto a box nobody is running. await ensureCloudBoxBrowser({ connectUrl: cloudBoxConnectUrl(endpoint, created.id), accessToken: created.accessToken ?? '', }) const session = createCloudBoxSession({ boxId: created.id, boxToken: created.accessToken ?? '', apiOrigin: endpoint, platform, device: selectedDevice, artifact: { sha256: artifactSha256, bytes: artifact.bytes.length, }, }) try { publishCloudSessionToShell(session) } catch { // shell update fd not configured } process.env[RNX_CLOUD_SESSION_ENV] = serializeCloudSession(session) console.log(` box: ${created.id} (${artifact.bytes.length} bytes)`) console.log(` platform: ${platform}`) if (selectedDevice) console.log(` device: ${selectedDevice}`) console.log(` artifact: sha256:${artifactSha256}`) // the dest directory was chosen by resolving this bundle's descriptors // against it, so say which one won rather than leaving it invisible. if (artifact.assetsDirectory) console.log(` asset dest: ${artifact.assetsDirectory}`) for (const warning of artifact.warnings) console.warn(` warning: ${warning}`) console.log(REMOTE_COMMANDS_HELP) // the session is published before the grant is minted, so a grant that never // arrives still leaves the box addressable: re-running this command mints it. const watchUrl = await requestBoxWatchUrl({ apiOrigin: endpoint, boxId: created.id, boxToken: created.accessToken ?? '', }) console.log(` open ${watchUrl}`) return openInBrowser(watchUrl) } /** `rnx remote list`: the simulators this account runs that `rnx remote` made. */ export async function runRemoteList(args: string[]): Promise { try { let json = false for (const arg of args) { if (arg === '--json') { json = true continue } throw new Error(`usage: rnx remote list [--json]`) } const [{ authHeaderOrExit, cloudAccountIdOrExit }, { listRemoteSims }] = await Promise.all([import('../auth'), import('../cloud-client')]) const { auth, header } = authHeaderOrExit('remote list') const accountId = cloudAccountIdOrExit(auth) const sims = await listRemoteSims({ authorization: header, accountId }) if (json) { console.log(JSON.stringify({ sims })) return 0 } if (sims.length === 0) { console.log(' no remote simulators are running') return 0 } for (const sim of sims) { const platformLabel = sim.labels['rnx.platform'] ?? 'unknown' console.log( ` ${sim.simId} ${sim.status} ${platformLabel} sha256:${sim.artifact.sha256.slice(0, 12)}`, ) } return 0 } catch (error) { rethrowIfExit(error) console.error( ` ${rnxPublicBrand.commandName} remote list failed: ${ error instanceof Error ? error.message : String(error) }`, ) return 1 } } /** `rnx remote stop `: delete one remote simulator by id. */ export async function runRemoteStop(args: string[]): Promise { try { const positional = args.filter((arg) => !arg.startsWith('-')) if ( args.some((arg) => arg.startsWith('-')) || positional.length !== 1 || !positional[0] ) { throw new Error(`usage: rnx remote stop `) } const simId = positional[0] const [ { authHeaderOrExit, cloudAccountIdOrExit }, { deleteRemoteSim }, { publishCloudSessionToShell, readCloudSession, requireCloudSessionShellUpdate }, ] = await Promise.all([ import('../auth'), import('../cloud-client'), import('../cloud-session'), ]) let current: CloudSession | null = null try { current = readCloudSession() } catch { current = null } // stopping the shell's own simulator clears the shell with it, so the // update channel is required before anything is deleted. const descriptor = current?.service === 'sim' && current.simId === simId ? requireCloudSessionShellUpdate() : null const { auth, header } = authHeaderOrExit('remote stop') const accountId = cloudAccountIdOrExit(auth) const deleted = await deleteRemoteSim({ simId, authorization: header, accountId, }) if (!deleted) { console.error( ` ${rnxPublicBrand.commandName} remote stop: ${simId} does not exist`, ) return 1 } if (descriptor !== null) publishCloudSessionToShell(null, descriptor) console.log(` stopped remote simulator: ${deleted.simId}`) return 0 } catch (error) { rethrowIfExit(error) console.error( ` ${rnxPublicBrand.commandName} remote stop failed: ${ error instanceof Error ? error.message : String(error) }`, ) return 1 } }