rnxsim 0.1.313 → 0.1.314

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
  146. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
  166. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
@@ -0,0 +1,2142 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import {
6
+ devices,
7
+ listSelectableDeviceModels,
8
+ type DeviceModel,
9
+ } from 'sootsim-engine/settings'
10
+ import { WebSocket } from 'ws'
11
+ import { probePort } from '../../scripts/dev-server-scanner'
12
+ import { isLoopbackHost } from '../../src/backend-origin'
13
+ import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants'
14
+ import {
15
+ applySootSimConfigToUrl,
16
+ mergeSootSimConfig,
17
+ type SootSimConfig,
18
+ } from '../../src/config'
19
+ import {
20
+ resolveConnectionInput,
21
+ type ResolvedDevBundle,
22
+ } from '../../src/dev-bundle-resolution'
23
+ import {
24
+ isDaemonLockfileFresh,
25
+ readActiveRuntime,
26
+ readDaemonLockfile,
27
+ readLiveRuntimeVersions,
28
+ runtimeDir,
29
+ } from '../../src/home-paths'
30
+ import { applyHotMode } from '../../src/native-dev-bundle-url'
31
+ import { ensureProfile } from '../../src/profiles'
32
+ import { rnxPublicBrand } from '../../src/public-brand'
33
+ import {
34
+ resolveDevCheckoutRuntimeRoot,
35
+ runtimeVersionHostname,
36
+ } from '../../src/runtime-assets'
37
+ import { sootsimRuntime } from '../../src/runtime-delivery'
38
+ import { loadSootSimAppConfig } from '../app-config'
39
+ import {
40
+ type DiscoveredAppFontSpec,
41
+ discoverNativeLinkedAppFonts,
42
+ encodeAppFontsWire,
43
+ resolveMetroProjectRoot,
44
+ } from '../app-fonts'
45
+ import { resolveAppProject, type ResolvedAppProject } from '../app-project'
46
+ import { discoverConfigPluginAppSplash, encodeAppSplashWire } from '../app-splash'
47
+ import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
48
+ import {
49
+ clearCurrentSimId,
50
+ getStableOwnerPid,
51
+ readCurrentSimId,
52
+ saveCurrentSimId,
53
+ } from '../current-sim'
54
+ import { getDriver } from '../drivers/registry'
55
+ import { openUrl as openUrlInBrowser } from '../open-url'
56
+ import {
57
+ BridgeSimLockedError,
58
+ bridgePortForShellBaseUrl,
59
+ createBridge,
60
+ createBridgeFromParsed,
61
+ parseBridgeCliArgs,
62
+ printBridgeWorldNotice,
63
+ resolveBridgeWorld,
64
+ worldForBridgePort,
65
+ type BridgeSimInfo,
66
+ } from '../ws-bridge'
67
+ import { WAIT_READY_PROBE, readyProbeHasContent, type ReadyProbe } from './inspect/core'
68
+ import type { DriverId, DriverLaunchResult } from '../drivers/types'
69
+
70
+ interface ControlCommandOptions {
71
+ port?: number
72
+ timeoutMs?: number
73
+ }
74
+
75
+ interface DriverConnectWaitOptions {
76
+ attempts: number
77
+ intervalMs: number
78
+ timeoutMs: number
79
+ hostTimeoutMs: number
80
+ }
81
+
82
+ const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
83
+ const DRIVER_CONNECT_INTERVAL_MS = 100
84
+ const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
85
+ const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
86
+
87
+ // built-in shell apps use pathname-based routing, not ?bundle= params
88
+ const SHELL_APPS: Record<string, string> = {
89
+ rn: '/rn',
90
+ connectrn: '/rn',
91
+ 'connect-rn': '/rn',
92
+ clock: '/app/clock',
93
+ 'native-ui': '/app/native-ui',
94
+ tamagui: '/app/tamagui',
95
+ settings: '/app/settings',
96
+ photos: '/app/photos',
97
+ camera: '/app/camera',
98
+ }
99
+
100
+ function sleep(ms: number) {
101
+ return new Promise((resolve) => setTimeout(resolve, ms))
102
+ }
103
+
104
+ function parsePositiveIntegerEnv(name: string): number | null {
105
+ const value = process.env[name]?.trim()
106
+ if (!value) return null
107
+ const parsed = Number(value)
108
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null
109
+ }
110
+
111
+ export function resolveDriverConnectWaitOptions(): DriverConnectWaitOptions {
112
+ const timeoutMs =
113
+ parsePositiveIntegerEnv('SOOTSIM_DRIVER_CONNECT_TIMEOUT_MS') ??
114
+ parsePositiveIntegerEnv('SOOTSIM_PW_CONNECT_TIMEOUT_MS') ??
115
+ DEFAULT_DRIVER_CONNECT_TIMEOUT_MS
116
+ return {
117
+ timeoutMs,
118
+ hostTimeoutMs: timeoutMs + DRIVER_CONNECT_HOST_GRACE_MS,
119
+ intervalMs: DRIVER_CONNECT_INTERVAL_MS,
120
+ attempts: Math.max(1, Math.ceil(timeoutMs / DRIVER_CONNECT_INTERVAL_MS)),
121
+ }
122
+ }
123
+
124
+ function isDriverId(value: string): value is DriverId {
125
+ return value === 'electron' || value === 'playwright'
126
+ }
127
+
128
+ function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean {
129
+ if (driverId === 'playwright') {
130
+ return sim.meta?.sootsimHostDriver === 'playwright'
131
+ }
132
+ return sim.userAgent?.includes('Electron/') === true
133
+ }
134
+
135
+ // resolve headless for a driver launch. explicit flags always win
136
+ // (--headless → true, --headed → false). otherwise: a headed default
137
+ // on mac/windows. on linux with no DISPLAY/WAYLAND_DISPLAY there is no
138
+ // X server, so the playwright driver auto-flips to headless with a
139
+ // single-line note. electron ignores headless mode, so it defaults false.
140
+ function resolveDriverHeadless(args: string[], driverId: string): boolean {
141
+ if (args.includes('--headless')) return true
142
+ if (args.includes('--headed')) return false
143
+ if (
144
+ driverId === 'playwright' &&
145
+ process.platform === 'linux' &&
146
+ !process.env.DISPLAY &&
147
+ !process.env.WAYLAND_DISPLAY
148
+ ) {
149
+ console.error(
150
+ ' no display detected → running playwright headless (override with --headed)',
151
+ )
152
+ return true
153
+ }
154
+ return false
155
+ }
156
+
157
+ function normalizeKnownTarget(target: string): string {
158
+ return target.trim()
159
+ }
160
+
161
+ const DEEP_LINK_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/
162
+
163
+ export function normalizeDeepLinkTarget(target: string): string {
164
+ const trimmed = target.trim()
165
+ if (!trimmed) {
166
+ throw new Error('expected <url-or-route>')
167
+ }
168
+ if (DEEP_LINK_SCHEME_RE.test(trimmed) || trimmed.startsWith('/')) {
169
+ return trimmed
170
+ }
171
+ return `/${trimmed}`
172
+ }
173
+
174
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
175
+ return value !== null && typeof value === 'object'
176
+ }
177
+
178
+ function shouldOpenAsDeepLink(target: string): boolean {
179
+ const trimmed = target.trim()
180
+ if (!trimmed) return false
181
+ if (trimmed.startsWith('/')) return true
182
+ if (!DEEP_LINK_SCHEME_RE.test(trimmed)) return false
183
+ return !/^https?:\/\//i.test(trimmed)
184
+ }
185
+
186
+ export async function ensureBridgeForOpen(
187
+ preferredPort: number,
188
+ explicitPort = false,
189
+ ): Promise<number> {
190
+ if (await canConnectToBridge(preferredPort, 250)) return preferredPort
191
+
192
+ const lock = readDaemonLockfile()
193
+ assertExplicitBridgeLock(preferredPort, explicitPort, lock)
194
+ if (
195
+ lock &&
196
+ isDaemonLockfileFresh(lock) &&
197
+ (await canConnectToBridge(lock.bridgePort, 500))
198
+ ) {
199
+ return lock.bridgePort
200
+ }
201
+ if (lock && isDaemonLockfileFresh(lock)) {
202
+ throw new Error(
203
+ `rnx bridge lockfile is fresh (port ${lock.bridgePort}) but the bridge is not reachable. run \`rnxsim daemon restart\` or \`rnxsim daemon uninstall\`.`,
204
+ )
205
+ }
206
+
207
+ await ensureRuntimeForOpen()
208
+ const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)])
209
+ let childExit: string | null = null
210
+ child.once('exit', (code, signal) => {
211
+ childExit = String(code ?? signal ?? 'unknown')
212
+ })
213
+ child.unref()
214
+
215
+ const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS
216
+ while (Date.now() < deadline) {
217
+ const nextLock = readDaemonLockfile()
218
+ assertExplicitBridgeLock(preferredPort, explicitPort, nextLock)
219
+ if (
220
+ nextLock &&
221
+ isDaemonLockfileFresh(nextLock) &&
222
+ (await canConnectToBridge(nextLock.bridgePort, 250))
223
+ ) {
224
+ console.error(
225
+ ` started local bridge on port ${nextLock.bridgePort}; run \`sootsim setup\` to install the background daemon`,
226
+ )
227
+ return nextLock.bridgePort
228
+ }
229
+ if (await canConnectToBridge(preferredPort, 250)) {
230
+ console.error(
231
+ ` started local bridge on port ${preferredPort}; run \`sootsim setup\` to install the background daemon`,
232
+ )
233
+ return preferredPort
234
+ }
235
+ if (childExit) {
236
+ throw new Error(`rnx bridge exited before becoming ready (exit ${childExit})`)
237
+ }
238
+ await sleep(150)
239
+ }
240
+
241
+ throw new Error(
242
+ `rnx bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`,
243
+ )
244
+ }
245
+
246
+ function assertExplicitBridgeLock(
247
+ preferredPort: number,
248
+ explicitPort: boolean,
249
+ lock: ReturnType<typeof readDaemonLockfile>,
250
+ ): void {
251
+ if (
252
+ !explicitPort ||
253
+ !lock ||
254
+ !isDaemonLockfileFresh(lock) ||
255
+ lock.bridgePort === preferredPort
256
+ ) {
257
+ return
258
+ }
259
+ throw new Error(
260
+ `${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 SOOTSIM_HOME.`,
261
+ )
262
+ }
263
+
264
+ async function ensureRuntimeForOpen() {
265
+ const active = readActiveRuntime()
266
+ if (active && fs.existsSync(runtimeDir(active))) return
267
+ console.error(' installing rnx engine runtime...')
268
+ const { runRuntime } = await import('./runtime')
269
+ await runRuntime(['install'], {})
270
+ }
271
+
272
+ function spawnSootsim(args: string[]) {
273
+ const entry = process.argv[1]
274
+ if (entry && fs.existsSync(entry)) {
275
+ return spawn(process.execPath, [entry, ...args], {
276
+ detached: true,
277
+ stdio: 'ignore',
278
+ env: process.env,
279
+ })
280
+ }
281
+ return spawn(rnxPublicBrand.commandName, args, {
282
+ detached: true,
283
+ stdio: 'ignore',
284
+ env: process.env,
285
+ })
286
+ }
287
+
288
+ function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
289
+ return new Promise((resolve) => {
290
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
291
+ let settled = false
292
+ const finish = (ok: boolean) => {
293
+ if (settled) return
294
+ settled = true
295
+ if (ws.readyState === WebSocket.OPEN) ws.close()
296
+ else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
297
+ resolve(ok)
298
+ }
299
+ ws.once('open', () => finish(true))
300
+ ws.on('error', () => finish(false))
301
+ setTimeout(() => finish(false), timeoutMs)
302
+ })
303
+ }
304
+
305
+ function looksLikeSootsimUrl(target: string): boolean {
306
+ try {
307
+ const url = new URL(target)
308
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
309
+ return (
310
+ url.searchParams.has('open') ||
311
+ url.searchParams.has('port') ||
312
+ url.searchParams.has('bundle') ||
313
+ url.searchParams.has('demo') ||
314
+ url.pathname.includes('/sootsim/index.html') ||
315
+ pathname === '/sootsim' ||
316
+ url.pathname === '/__soot' ||
317
+ url.pathname === '/__soot/' ||
318
+ pathname === '/rn' ||
319
+ /^\/rn\/[^/]+$/i.test(pathname) ||
320
+ /^\/app\/[^/]+$/i.test(pathname) ||
321
+ pathname === '/__soot/rn' ||
322
+ /^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
323
+ /^\/__soot\/app\/[^/]+$/i.test(pathname)
324
+ )
325
+ } catch {
326
+ return false
327
+ }
328
+ }
329
+
330
+ function looksLikeShellPathTarget(target: string): boolean {
331
+ try {
332
+ const url = new URL(target, 'http://sootsim.local')
333
+ if (url.origin !== 'http://sootsim.local') return false
334
+ const pathname = url.pathname.replace(/\/+$/, '') || '/'
335
+ return (
336
+ pathname === '/rn' ||
337
+ /^\/rn\/[^/]+$/i.test(pathname) ||
338
+ /^\/app\/[^/]+$/i.test(pathname) ||
339
+ pathname === '/__soot/rn' ||
340
+ /^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
341
+ /^\/__soot\/app\/[^/]+$/i.test(pathname)
342
+ )
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+
348
+ function buildShellPathOpenUrl(target: string, baseUrl: string): string {
349
+ const targetUrl = new URL(target, 'http://sootsim.local')
350
+ const url = new URL(baseUrl)
351
+ const prefix = getShellRoutePrefix(url.pathname)
352
+ const routePath = targetUrl.pathname.startsWith('/__soot/')
353
+ ? targetUrl.pathname.slice('/__soot'.length)
354
+ : targetUrl.pathname
355
+
356
+ url.pathname = `${prefix}${routePath}`
357
+ url.search = targetUrl.search
358
+ url.hash = targetUrl.hash
359
+ return url.toString()
360
+ }
361
+
362
+ // SootSim share/preview/build links (e.g. https://sootsim.com/preview/<id>,
363
+ // https://contrast.dev/build/<owner>/<repo>/<branch>) are full player pages
364
+ // served by the Contrast app — NOT Metro bundles and NOT driveable shell sims.
365
+ // passing one to `sootsim open` used to fall through to the bundle resolver,
366
+ // which fetched the HTML page and choked with "Unexpected token '<'". two
367
+ // hard reasons they can't become a CLI-driveable sim:
368
+ // 1. the engine only attaches to the local CLI bridge from a localhost /
369
+ // 127.0.0.1 origin (see engine `mcp/ws-bridge.ts` connect gate), so a
370
+ // remote preview page never registers a sim.
371
+ // 2. a captured share's transformed bundle is pinned to the engine build
372
+ // that produced it — its lazy `/engine-tenant/assets/*` chunk hashes
373
+ // only exist on that deploy, so a remote share can't be re-hosted
374
+ // through the local dev shell (the dynamic chunk import 404s).
375
+ // recognize these URLs so `open` opens them for viewing instead.
376
+ function looksLikeSootSimShareUrl(target: string): boolean {
377
+ try {
378
+ const pathname = new URL(target).pathname.replace(/\/+$/, '') || '/'
379
+ return /^\/preview\/[^/]+$/.test(pathname) || /^\/build\/.+/.test(pathname)
380
+ } catch {
381
+ return false
382
+ }
383
+ }
384
+
385
+ export async function resolveBundleTarget(target: string): Promise<string> {
386
+ const normalized = normalizeKnownTarget(target)
387
+ const resolved = await resolveConnectionInputForCli(normalized)
388
+ return resolved.bundleUrl
389
+ }
390
+
391
+ function localBasePort(target: string): number | null {
392
+ try {
393
+ const url = new URL(target.startsWith('http') ? target : `http://${target}`)
394
+ const pathname = url.pathname || '/'
395
+ if ((pathname !== '/' && pathname !== '') || url.search || url.hash) return null
396
+ if (!isLoopbackHost(url.hostname)) return null
397
+ const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
398
+ return Number.isFinite(port) && port > 0 ? port : null
399
+ } catch {
400
+ return null
401
+ }
402
+ }
403
+
404
+ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDevBundle> {
405
+ const normalized = normalizeKnownTarget(target)
406
+ const registeredPort = /^\d+$/.test(normalized)
407
+ ? Number(normalized)
408
+ : localBasePort(normalized)
409
+
410
+ if (registeredPort && registeredPort > 0) {
411
+ const discovered = await probePort(registeredPort)
412
+ if (discovered) {
413
+ return {
414
+ bundleUrl: discovered.bundleUrl,
415
+ port: discovered.port,
416
+ framework: discovered.framework,
417
+ projectName: discovered.projectName,
418
+ }
419
+ }
420
+ }
421
+
422
+ return resolveConnectionInput(normalized)
423
+ }
424
+
425
+ function getShellRoutePrefix(pathname: string): string {
426
+ const normalized = pathname.replace(/\/+$/, '') || '/'
427
+ if (normalized === '/__soot' || normalized.startsWith('/__soot/')) {
428
+ return '/__soot'
429
+ }
430
+ return ''
431
+ }
432
+
433
+ function buildConnectOpenUrl(target: string, baseUrl: string): string {
434
+ const normalizedTarget = normalizeKnownTarget(target)
435
+ const url = new URL(baseUrl)
436
+ const prefix = getShellRoutePrefix(url.pathname)
437
+
438
+ url.pathname = `${prefix}/rn`
439
+ url.searchParams.delete('bundle')
440
+ url.searchParams.delete('demo')
441
+ url.searchParams.delete('app')
442
+ url.searchParams.delete('open')
443
+ url.searchParams.delete('port')
444
+
445
+ if (/^\d+$/.test(normalizedTarget)) {
446
+ url.pathname = `${prefix}/rn/${normalizedTarget}`
447
+ } else {
448
+ url.pathname = `${prefix}/rn`
449
+ url.searchParams.set('open', normalizedTarget)
450
+ }
451
+
452
+ return url.toString()
453
+ }
454
+
455
+ function shouldResolveBundleTargetInCli(target: string): boolean {
456
+ const normalizedTarget = normalizeKnownTarget(target)
457
+ if (/^\d+$/.test(normalizedTarget)) return true
458
+ if (/^https?:\/\//i.test(normalizedTarget)) return true
459
+ return /^(localhost|127\.0\.0\.1|\[::1\]|[^/]+\.localhost):\d+(?:\/.*)?$/i.test(
460
+ normalizedTarget,
461
+ )
462
+ }
463
+
464
+ async function buildResolvedBundleOpenUrl(
465
+ target: string,
466
+ baseUrl: string,
467
+ opts: BuildShellUrlOptions = {},
468
+ ): Promise<string> {
469
+ const resolved = opts.resolvedBundle ?? (await resolveConnectionInputForCli(target))
470
+ const url = new URL(baseUrl)
471
+ const prefix = getShellRoutePrefix(url.pathname)
472
+ url.pathname = `${prefix}/rn`
473
+ url.searchParams.delete('open')
474
+ url.searchParams.delete('port')
475
+ url.searchParams.delete('demo')
476
+ url.searchParams.delete('app')
477
+ const bundleUrl =
478
+ opts.hot === undefined
479
+ ? resolved.bundleUrl
480
+ : applyHotMode(resolved.bundleUrl, opts.hot)
481
+ url.searchParams.set('bundle', bundleUrl)
482
+ return url.toString()
483
+ }
484
+
485
+ function normalizeReplacementPath(filePath: string): string {
486
+ if (filePath.startsWith('~/')) {
487
+ return path.join(os.homedir(), filePath.slice(2))
488
+ }
489
+ if (path.isAbsolute(filePath)) return filePath
490
+ return path.resolve(process.cwd(), filePath)
491
+ }
492
+
493
+ function parseOpenRuntimeConfig(args: string[]): SootSimConfig | undefined {
494
+ const modules: NonNullable<SootSimConfig['modules']> = {}
495
+ const remap: Record<string, string> = {}
496
+
497
+ for (let i = 0; i < args.length; i++) {
498
+ if (args[i] === '--replace') {
499
+ const raw = args[i + 1]
500
+ if (!raw) {
501
+ console.error(' rnxsim open: --replace expects <module>=<file>')
502
+ process.exit(1)
503
+ }
504
+ const eqIndex = raw.indexOf('=')
505
+ if (eqIndex <= 0 || eqIndex === raw.length - 1) {
506
+ console.error(' rnxsim open: --replace expects <module>=<file>')
507
+ process.exit(1)
508
+ }
509
+ const moduleName = raw.slice(0, eqIndex).trim()
510
+ const filePath = normalizeReplacementPath(raw.slice(eqIndex + 1).trim())
511
+ if (!fs.existsSync(filePath)) {
512
+ console.error(` rnxsim open: replacement file not found: ${filePath}`)
513
+ process.exit(1)
514
+ }
515
+ modules[moduleName] = { file: filePath }
516
+ i++
517
+ continue
518
+ }
519
+ // --remap <host:port>=<host:port> rewrites a guest fetch's host:port at
520
+ // runtime (config.network.remap). repeatable. lets a flow against an app
521
+ // that hardcodes a port reach a differently-bound service.
522
+ if (args[i] === '--remap') {
523
+ const raw = args[i + 1]
524
+ const eqIndex = raw ? raw.indexOf('=') : -1
525
+ if (!raw || eqIndex <= 0 || eqIndex === raw.length - 1) {
526
+ console.error(' rnxsim open: --remap expects <host:port>=<host:port>')
527
+ process.exit(1)
528
+ }
529
+ remap[raw.slice(0, eqIndex).trim()] = raw.slice(eqIndex + 1).trim()
530
+ i++
531
+ continue
532
+ }
533
+ }
534
+
535
+ const config: SootSimConfig = {}
536
+ if (Object.keys(modules).length > 0) config.modules = modules
537
+ if (Object.keys(remap).length > 0) config.network = { remap }
538
+ return config.modules || config.network ? config : undefined
539
+ }
540
+
541
+ async function resolveOpenAppProject(
542
+ resolvedBundle?: ResolvedDevBundle,
543
+ ): Promise<ResolvedAppProject | null> {
544
+ if (resolvedBundle) {
545
+ const advertisedRoot = await resolveMetroProjectRoot(resolvedBundle.bundleUrl)
546
+ if (advertisedRoot) {
547
+ return resolveAppProject(advertisedRoot, { trustStart: true })
548
+ }
549
+ }
550
+ return resolveAppProject(process.env.CONTRAST_REPO_DIR || process.cwd())
551
+ }
552
+
553
+ async function ensureConfiguredRuntime(version: string): Promise<void> {
554
+ if (fs.existsSync(path.join(runtimeDir(version), 'index.html'))) return
555
+ console.error(` installing configured engine runtime ${version}...`)
556
+ const result = await sootsimRuntime.install({
557
+ version,
558
+ setActive: false,
559
+ protectVersions: readLiveRuntimeVersions(),
560
+ })
561
+ console.error(` installed engine runtime ${result.version}`)
562
+ }
563
+
564
+ export function applyRuntimeGenerationOrigin(baseUrl: string, version?: string): string {
565
+ const selectedVersion =
566
+ version ??
567
+ (resolveDevCheckoutRuntimeRoot() ? undefined : (readActiveRuntime() ?? undefined))
568
+ if (!selectedVersion) return baseUrl
569
+ const url = new URL(baseUrl)
570
+ if (!isLoopbackHost(url.hostname)) {
571
+ if (!version) return baseUrl
572
+ throw new Error(
573
+ `runtimeVersion ${selectedVersion} requires a local rnx host; got ${url.origin}`,
574
+ )
575
+ }
576
+ url.hostname = runtimeVersionHostname(selectedVersion)
577
+ return url.toString()
578
+ }
579
+
580
+ export function resolveDefaultShellBaseUrl(): string {
581
+ return resolveBridgeWorld().shellBaseUrl
582
+ }
583
+
584
+ /** the shell that belongs to a bridge port: the world a fresh lockfile claims
585
+ * for it, otherwise the shell that shares the port's own offset. every caller
586
+ * that has already settled on a bridge port goes through this, so a page can
587
+ * never open in one stack's shell while the CLI polls another's bridge. the
588
+ * development bridge registry keeps every live port addressable, while this
589
+ * function preserves an explicit caller's already-selected world. */
590
+ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
591
+ return (
592
+ worldForBridgePort(bridgePort)?.shellBaseUrl ??
593
+ resolveSootsimShellBaseUrlForBridgePort(bridgePort) ??
594
+ resolveDefaultShellBaseUrl()
595
+ )
596
+ }
597
+
598
+ // stamp the engine page url with `?appFonts=<url[::family]>,...` so the
599
+ // engine's app-font-loader registers native config-only fonts at boot. fonts
600
+ // loaded by JS already flow through normal runtime fetch capture; this covers
601
+ // UIAppFonts-style assets that Metro can serve but the guest bundle never
602
+ // fetches itself. auto-discovery only — scans the repo for expo-font config
603
+ // plugin declarations, no user-facing flag.
604
+ //
605
+ // each discovered font is HEAD-checked against the metro host before we
606
+ // include it on the URL. fonts whose metro asset URL 404s (typo in the
607
+ // expo-font plugin config, missing file, or a wrong app.config picked up
608
+ // by the scan) are dropped silently so the engine never tries to fetch a
609
+ // known-bad URL at boot — that would just log a registration failure and
610
+ // confuse anyone reading the page console.
611
+ async function stampAppFonts(
612
+ urlStr: string,
613
+ project?: ResolvedAppProject | null,
614
+ ): Promise<string> {
615
+ try {
616
+ const url = new URL(urlStr)
617
+ const bundleUrl = url.searchParams.get('bundle') || ''
618
+ if (!bundleUrl) return urlStr
619
+ // the dev server's project root identifies the exact app in a monorepo.
620
+ // CONTRAST_REPO_DIR is only the fallback for servers that do not advertise
621
+ // their root.
622
+ const repoDir =
623
+ project?.appDir ?? (await resolveMetroProjectRoot(bundleUrl)) ?? undefined
624
+ const specs = discoverNativeLinkedAppFonts({
625
+ bundleUrl,
626
+ repoDir,
627
+ exactProjectDir: !!project || !!repoDir,
628
+ })
629
+ if (specs.length === 0) return urlStr
630
+ const reachable = await filterReachableAppFonts(specs)
631
+ if (reachable.length === 0) return urlStr
632
+ url.searchParams.set('appFonts', encodeAppFontsWire(reachable))
633
+ return url.toString()
634
+ } catch {
635
+ return urlStr
636
+ }
637
+ }
638
+
639
+ // HEAD-check each font URL with a short timeout. metro returns 200 (or
640
+ // sometimes a redirect) for assets it can resolve and 500 with an ENOENT
641
+ // trace for paths it cannot. anything not in the 2xx-3xx range gets dropped.
642
+ async function filterReachableAppFonts(
643
+ specs: DiscoveredAppFontSpec[],
644
+ ): Promise<DiscoveredAppFontSpec[]> {
645
+ const results = await Promise.all(
646
+ specs.map(async (spec): Promise<DiscoveredAppFontSpec | null> => {
647
+ const controller = new AbortController()
648
+ const timer = setTimeout(() => controller.abort(), 1500)
649
+ try {
650
+ const res = await fetch(spec.url, {
651
+ method: 'HEAD',
652
+ signal: controller.signal,
653
+ })
654
+ if (res.status >= 200 && res.status < 400) return spec
655
+ return null
656
+ } catch {
657
+ return null
658
+ } finally {
659
+ clearTimeout(timer)
660
+ }
661
+ }),
662
+ )
663
+ const reachable: DiscoveredAppFontSpec[] = []
664
+ for (const spec of results) {
665
+ if (spec !== null) reachable.push(spec)
666
+ }
667
+ return reachable
668
+ }
669
+
670
+ async function resolveShellUrl(
671
+ target: string,
672
+ baseUrl: string,
673
+ opts: BuildShellUrlOptions = {},
674
+ ): Promise<string> {
675
+ // no target = open home screen
676
+ if (!target) {
677
+ return new URL(baseUrl).toString()
678
+ }
679
+
680
+ if (looksLikeSootsimUrl(target)) {
681
+ return new URL(target).toString()
682
+ }
683
+
684
+ if (looksLikeShellPathTarget(target)) {
685
+ return buildShellPathOpenUrl(target, baseUrl)
686
+ }
687
+
688
+ // check if this is a shell app (pathname-based route, not bundle)
689
+ const shellPath = SHELL_APPS[target.toLowerCase()]
690
+ if (shellPath) {
691
+ const url = new URL(baseUrl)
692
+ const prefix = getShellRoutePrefix(url.pathname)
693
+ url.pathname = `${prefix}${shellPath}`
694
+ return url.toString()
695
+ }
696
+
697
+ if (shouldResolveBundleTargetInCli(target)) {
698
+ return buildResolvedBundleOpenUrl(target, baseUrl, opts)
699
+ }
700
+
701
+ return buildConnectOpenUrl(target, baseUrl)
702
+ }
703
+
704
+ // stamp the engine page url with `?appSplash=<encodeURIComponent(JSON)>` so the
705
+ // engine paints the app's REAL configured splash image + background on boot
706
+ // (instead of the contrast device-default chrome). same discovery path as
707
+ // fonts: scan the bundle's project for the expo-splash-screen /
708
+ // react-native-bootsplash config plugin (or legacy `expo.splash`). the metro
709
+ // manifest fallback in the engine covers the typed-URL case; this wire is the
710
+ // instant-first-paint optimization.
711
+ async function stampAppSplash(
712
+ urlStr: string,
713
+ project?: ResolvedAppProject | null,
714
+ ): Promise<string> {
715
+ try {
716
+ const url = new URL(urlStr)
717
+ const bundleUrl = url.searchParams.get('bundle') || ''
718
+ if (!bundleUrl) return urlStr
719
+ const repoDir =
720
+ project?.appDir ?? (await resolveMetroProjectRoot(bundleUrl)) ?? undefined
721
+ const spec = discoverConfigPluginAppSplash({
722
+ bundleUrl,
723
+ repoDir,
724
+ exactProjectDir: !!project || !!repoDir,
725
+ })
726
+ if (!spec) return urlStr
727
+ // HEAD-check the image before stamping so a typo'd/missing splash path never
728
+ // makes the engine fetch a known-bad URL at boot.
729
+ if (spec.imageUrl) {
730
+ const reachable = await splashImageReachable(spec.imageUrl)
731
+ if (!reachable) return urlStr
732
+ }
733
+ url.searchParams.set('appSplash', encodeAppSplashWire(spec))
734
+ return url.toString()
735
+ } catch {
736
+ return urlStr
737
+ }
738
+ }
739
+
740
+ async function splashImageReachable(imageUrl: string): Promise<boolean> {
741
+ const controller = new AbortController()
742
+ const timer = setTimeout(() => controller.abort(), 1500)
743
+ try {
744
+ const res = await fetch(imageUrl, { method: 'HEAD', signal: controller.signal })
745
+ return res.status >= 200 && res.status < 400
746
+ } catch {
747
+ return false
748
+ } finally {
749
+ clearTimeout(timer)
750
+ }
751
+ }
752
+
753
+ export interface BuildShellUrlOptions {
754
+ // when set, forces the metro `hot` query on the resolved bundle URL. true
755
+ // for interactive opens (HMR runtime in bundle, edits hot-apply), false
756
+ // for `--driver playwright`/agent runs (no in-bundle HMR wiring, no ws
757
+ // chatter, deterministic e2e). undefined leaves whatever value metro's
758
+ // manifest emitted — matches real iOS sims so metro's bundle cache is
759
+ // shared. set by `runOpenCommand` only.
760
+ hot?: boolean
761
+ // device model slug (e.g. iphone-16) stamped onto the engine URL as
762
+ // ?device=<model> — the shell reads it at boot (main.tsx) and live-switch
763
+ // uses the same param. without this, every driver-opened sim silently ran
764
+ // whatever profile the shell last persisted.
765
+ device?: string
766
+ resolvedBundle?: ResolvedDevBundle
767
+ project?: ResolvedAppProject | null
768
+ }
769
+
770
+ export async function buildShellUrl(
771
+ target: string,
772
+ baseUrl: string = resolveDefaultShellBaseUrl(),
773
+ opts: BuildShellUrlOptions = {},
774
+ ): Promise<string> {
775
+ const resolved = await resolveShellUrl(target, baseUrl, opts)
776
+ const stamped = await stampAppSplash(
777
+ await stampAppFonts(resolved, opts.project),
778
+ opts.project,
779
+ )
780
+ if (!opts.device) return stamped
781
+ const url = new URL(stamped)
782
+ url.searchParams.set('device', opts.device)
783
+ return url.toString()
784
+ }
785
+
786
+ function deriveCurrentSimBaseUrl(
787
+ sim: BridgeSimInfo | null | undefined,
788
+ fallback: string,
789
+ ): string {
790
+ const source = sim?.url || sim?.origin || fallback
791
+ try {
792
+ const url = new URL(source)
793
+ url.searchParams.delete('bundle')
794
+ url.searchParams.delete('demo')
795
+ url.searchParams.delete('app')
796
+ url.searchParams.delete('open')
797
+ url.searchParams.delete('port')
798
+ url.searchParams.delete('inspectOpen')
799
+ // appFonts is target-specific (it points at the metro asset endpoint of
800
+ // the bundle being launched). carrying it over from a prior sim URL
801
+ // means a stale font URL from an earlier launch leaks into the new one,
802
+ // and `stampAppFonts` doesn't get the chance to re-derive against the
803
+ // current bundle. drop it so the rebuild starts clean.
804
+ url.searchParams.delete('appFonts')
805
+ // appSplash is target-specific in the same way — drop it so the configured
806
+ // splash re-derives against the current bundle.
807
+ url.searchParams.delete('appSplash')
808
+ return url.toString()
809
+ } catch {
810
+ return fallback
811
+ }
812
+ }
813
+
814
+ export async function buildOpenUrl(
815
+ target: string,
816
+ baseUrl: string,
817
+ openToken: string,
818
+ opts: BuildShellUrlOptions = {},
819
+ ): Promise<string> {
820
+ const url = new URL(await buildShellUrl(target, baseUrl, opts))
821
+ url.searchParams.set('inspectOpen', openToken)
822
+ return url.toString()
823
+ }
824
+
825
+ async function waitForSimReady(
826
+ wsPort: number,
827
+ commandTimeoutMs: number,
828
+ simId: string,
829
+ opts: {
830
+ attempts?: number
831
+ intervalMs?: number
832
+ minNodeCount?: number
833
+ } = {},
834
+ ) {
835
+ const attempts = opts.attempts ?? 30
836
+ const intervalMs = opts.intervalMs ?? 500
837
+ const minNodeCount = opts.minNodeCount ?? 10
838
+
839
+ for (let i = 0; i < attempts; i++) {
840
+ const bridge = createBridge(wsPort, {
841
+ commandTimeoutMs,
842
+ simId,
843
+ simIdSource: 'flag',
844
+ })
845
+ try {
846
+ const count = await bridge.send({
847
+ type: 'evaluate',
848
+ code: '(async () => (await window.__sootsimTest?.getNodeCount()) || 0)()',
849
+ })
850
+ if (typeof count === 'number' && count > minNodeCount) {
851
+ return { bridge, count }
852
+ }
853
+ } catch {
854
+ // still loading or disconnected
855
+ }
856
+ bridge.close()
857
+ await sleep(intervalMs)
858
+ }
859
+
860
+ return null
861
+ }
862
+
863
+ // returns a short, human-friendly description of what a sim is showing —
864
+ // e.g. "bundle host/...path", "connect :8081", "demo bluesky". returns null
865
+ // when there's no useful summary beyond the raw URL itself, so the caller
866
+ // can skip the redundant `loaded:` line in that case.
867
+ export function summarizeSimUrl(url: string | undefined): string | null {
868
+ if (!url) return null
869
+ try {
870
+ const parsed = new URL(url)
871
+ if (parsed.searchParams.has('bundle')) {
872
+ const bundleUrl = parsed.searchParams.get('bundle') || ''
873
+ try {
874
+ const bundle = new URL(bundleUrl)
875
+ const shortPath =
876
+ bundle.pathname.length > 36
877
+ ? `...${bundle.pathname.slice(-36)}`
878
+ : bundle.pathname
879
+ return `bundle ${bundle.host}${shortPath}`
880
+ } catch {
881
+ return 'bundle'
882
+ }
883
+ }
884
+ if (parsed.searchParams.has('port')) {
885
+ return `connect :${parsed.searchParams.get('port') || ''}`
886
+ }
887
+ if (parsed.searchParams.has('open')) {
888
+ return `connect ${parsed.searchParams.get('open') || ''}`
889
+ }
890
+ if (parsed.searchParams.has('demo')) {
891
+ return `demo ${parsed.searchParams.get('demo') || 'default'}`
892
+ }
893
+ if (
894
+ parsed.pathname.includes('/sootsim/index.html') ||
895
+ parsed.pathname === '/sootsim/' ||
896
+ parsed.pathname === '/sootsim'
897
+ ) {
898
+ return 'embedded rnx'
899
+ }
900
+ return null
901
+ } catch {
902
+ return null
903
+ }
904
+ }
905
+
906
+ // a lock owner is either a friendly cli label ("active user") or a raw cli
907
+ // identity key ("CLAUDE_CODE_SESSION_ID:<uuid>", "gppid-1234"). keep the list
908
+ // readable by collapsing a "<source>:<value>" identity to a short form that
909
+ // still says which agent holds it.
910
+ function formatLockOwner(owner: string): string {
911
+ const colon = owner.indexOf(':')
912
+ if (colon <= 0) return owner.length > 24 ? `${owner.slice(0, 21)}…` : owner
913
+ const source = owner.slice(0, colon)
914
+ const value = owner.slice(colon + 1)
915
+ const short = value.length > 12 ? `${value.slice(0, 8)}…` : value
916
+ if (
917
+ source === 'TM_SESSION' ||
918
+ source === 'CLAUDE_CODE_SESSION_ID' ||
919
+ source === 'CODEX_THREAD_ID'
920
+ ) {
921
+ return `agent ${short}`
922
+ }
923
+ return `${source} ${short}`
924
+ }
925
+
926
+ export function printConnectedSims(sims: BridgeSimInfo[], selectedId?: string) {
927
+ if (sims.length === 0) {
928
+ console.log(' no sims connected')
929
+ return
930
+ }
931
+ console.log(` connected sims (${sims.length}):\n`)
932
+ for (const sim of sims) {
933
+ const lockTag =
934
+ sim.lockedBy && sim.lockExpiresAt
935
+ ? `locked by ${formatLockOwner(sim.lockedBy)} (${Math.max(0, Math.round((sim.lockExpiresAt - Date.now()) / 1000))}s)`
936
+ : ''
937
+ const tags = [
938
+ sim.isPrimary ? 'primary' : '',
939
+ sim.id === selectedId ? 'selected' : '',
940
+ sim.readyState,
941
+ sim.userVisible === false ? 'hidden' : sim.userVisible === true ? 'visible' : '',
942
+ sim.attachedCliCount && sim.attachedCliCount > 0 ? 'in use' : '',
943
+ sim.userFocused ? 'focused' : '',
944
+ lockTag,
945
+ ].filter(Boolean)
946
+ console.log(` ${sim.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`)
947
+ const loaded = summarizeSimUrl(sim.url)
948
+ if (loaded) {
949
+ console.log(` loaded: ${loaded}`)
950
+ } else if (sim.url) {
951
+ // no friendly summary — show the raw url so the caller can still copy it.
952
+ // truncate aggressively so a 400-char metro url doesn't dominate the list.
953
+ const shown = sim.url.length > 96 ? `${sim.url.slice(0, 93)}…` : sim.url
954
+ console.log(` url: ${shown}`)
955
+ } else if (sim.origin) {
956
+ console.log(` origin: ${sim.origin}`)
957
+ }
958
+ const engine = describeEngineSource(sim)
959
+ if (engine) console.log(` engine: ${engine}`)
960
+ if (sim.title) console.log(` title: ${sim.title}`)
961
+ if (sim.visibilityState && sim.visibilityState !== 'visible') {
962
+ console.log(` visibility: ${sim.visibilityState}`)
963
+ }
964
+ console.log(` connected: ${formatRelativeAge(Date.now() - sim.connectedAt)}`)
965
+ if (sim.lastActiveAt && sim.lastActiveAt > 0) {
966
+ console.log(` last active: ${formatRelativeAge(Date.now() - sim.lastActiveAt)}`)
967
+ }
968
+ }
969
+ }
970
+
971
+ // classify which engine a sim is running so dev-vs-published is never ambiguous
972
+ // to agents or humans reading `sootsim list`. dev shell serves local source on
973
+ // :5173; the Contrast app embeds sootsim on :3000; the bridge/daemon and the
974
+ // /__soot plugin serve the active runtime dir — which, inside a dev checkout, is
975
+ // the fresh dev-stack build (resolveDevCheckoutRuntimeRoot mode boundary), not a
976
+ // published runtime. keep this label honest so it never says "published" while
977
+ // actually serving local build output.
978
+ function describeEngineSource(sim: { url?: string; origin?: string }): string | null {
979
+ const ref = sim.url || sim.origin || ''
980
+ if (!ref) return null
981
+ try {
982
+ const port = new URL(ref).port
983
+ if (port === '5173') return 'dev — local source (:5173)'
984
+ if (port === '3000') return 'Contrast app embedded (:3000)'
985
+ if (resolveDevCheckoutRuntimeRoot()) return `dev — local build (:${port || '?'})`
986
+ return `runtime — published engine (:${port || '?'})`
987
+ } catch {
988
+ return null
989
+ }
990
+ }
991
+
992
+ function formatRelativeAge(ms: number): string {
993
+ const sec = Math.max(0, Math.round(ms / 1000))
994
+ if (sec < 60) return `${sec}s ago`
995
+ if (sec < 3600) return `${Math.round(sec / 60)}m ago`
996
+ if (sec < 86400) return `${Math.round(sec / 3600)}h ago`
997
+ return `${Math.round(sec / 86400)}d ago`
998
+ }
999
+
1000
+ export async function waitForSimMatch(
1001
+ wsPort: number,
1002
+ commandTimeoutMs: number,
1003
+ predicate: (sim: BridgeSimInfo) => boolean,
1004
+ opts: { attempts?: number; intervalMs?: number } = {},
1005
+ ) {
1006
+ const attempts = opts.attempts ?? 30
1007
+ const intervalMs = opts.intervalMs ?? 500
1008
+ for (let i = 0; i < attempts; i++) {
1009
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
1010
+ try {
1011
+ const sims = await bridge.listSims()
1012
+ const match = sims.find(predicate)
1013
+ if (match) return match
1014
+ } catch {
1015
+ // server/sim may still be booting
1016
+ } finally {
1017
+ bridge.close()
1018
+ }
1019
+ await sleep(intervalMs)
1020
+ }
1021
+ return null
1022
+ }
1023
+
1024
+ // same as waitForSimMatch, but also watches a detached host PID so a host
1025
+ // crash mid-connect is surfaced as a distinct outcome rather than waiting
1026
+ // out the full timeout (and silently treating a stale bridge entry as
1027
+ // success). 'host-exit' wins as soon as the host process is gone, even if
1028
+ // the bridge still lists the sim.
1029
+ export type WaitForSimMatchOrHostExitResult =
1030
+ | { kind: 'match'; sim: BridgeSimInfo }
1031
+ | { kind: 'host-exit' }
1032
+ | { kind: 'timeout' }
1033
+
1034
+ export async function waitForSimMatchOrHostExit(
1035
+ wsPort: number,
1036
+ commandTimeoutMs: number,
1037
+ predicate: (sim: BridgeSimInfo) => boolean,
1038
+ hostPid: number | undefined,
1039
+ opts: { attempts?: number; intervalMs?: number } = {},
1040
+ ): Promise<WaitForSimMatchOrHostExitResult> {
1041
+ const attempts = opts.attempts ?? 30
1042
+ const intervalMs = opts.intervalMs ?? 500
1043
+ const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
1044
+ const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
1045
+ for (let i = 0; i < attempts; i++) {
1046
+ if (hostExited()) {
1047
+ return { kind: 'host-exit' }
1048
+ }
1049
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
1050
+ try {
1051
+ const sims = await bridge.listSims()
1052
+ const match = sims.find(predicate)
1053
+ if (match) {
1054
+ if (hostExited()) return { kind: 'host-exit' }
1055
+ return { kind: 'match', sim: match }
1056
+ }
1057
+ } catch {
1058
+ // server/sim may still be booting
1059
+ } finally {
1060
+ bridge.close()
1061
+ }
1062
+ if (hostExited()) {
1063
+ return { kind: 'host-exit' }
1064
+ }
1065
+ await sleep(intervalMs)
1066
+ }
1067
+ return { kind: 'timeout' }
1068
+ }
1069
+
1070
+ async function waitForSimGone(
1071
+ wsPort: number,
1072
+ commandTimeoutMs: number,
1073
+ simId: string,
1074
+ opts: { attempts?: number; intervalMs?: number } = {},
1075
+ ) {
1076
+ const attempts = opts.attempts ?? 20
1077
+ const intervalMs = opts.intervalMs ?? 250
1078
+ for (let i = 0; i < attempts; i++) {
1079
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
1080
+ try {
1081
+ const sims = await bridge.listSims()
1082
+ const sim = sims.find((entry) => entry.id === simId)
1083
+ if (!sim || sim.readyState !== 'open') {
1084
+ return true
1085
+ }
1086
+ } catch {
1087
+ return true
1088
+ } finally {
1089
+ bridge.close()
1090
+ }
1091
+ await sleep(intervalMs)
1092
+ }
1093
+ return false
1094
+ }
1095
+
1096
+ export function playwrightHostPidForSim(sim: BridgeSimInfo): number | null {
1097
+ const meta = sim.meta
1098
+ if (!meta || meta.sootsimHostDriver !== 'playwright') return null
1099
+ const pid = Number(meta.sootsimHostPid)
1100
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return null
1101
+ return pid
1102
+ }
1103
+
1104
+ export function signalDriverLaunchConnected(result: DriverLaunchResult): boolean {
1105
+ if (!result.connectAckFile) return false
1106
+ try {
1107
+ fs.writeFileSync(
1108
+ result.connectAckFile,
1109
+ `${JSON.stringify({ connectedAt: Date.now(), pid: result.pid ?? null })}\n`,
1110
+ { flag: 'w' },
1111
+ )
1112
+ return true
1113
+ } catch {
1114
+ return false
1115
+ }
1116
+ }
1117
+
1118
+ function printDriverDiagnosticLogTail(result: DriverLaunchResult) {
1119
+ if (!result.diagnosticLogPath) return
1120
+ try {
1121
+ const detail = fs.readFileSync(result.diagnosticLogPath, 'utf8').trim()
1122
+ if (!detail) {
1123
+ console.error(` ${path.basename(result.diagnosticLogPath)} was empty`)
1124
+ return
1125
+ }
1126
+ const shown = detail.length > 4_000 ? `…\n${detail.slice(-4_000)}` : detail
1127
+ console.error(` ${path.basename(result.diagnosticLogPath)}:`)
1128
+ console.error(shown)
1129
+ } catch (err) {
1130
+ const message = err instanceof Error ? err.message : String(err)
1131
+ console.error(
1132
+ ` failed to read ${path.basename(result.diagnosticLogPath)}: ${message}`,
1133
+ )
1134
+ }
1135
+ }
1136
+
1137
+ function isProcessAlive(pid: number): boolean {
1138
+ try {
1139
+ process.kill(pid, 0)
1140
+ return true
1141
+ } catch (err) {
1142
+ return (err as NodeJS.ErrnoException)?.code === 'EPERM'
1143
+ }
1144
+ }
1145
+
1146
+ async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boolean> {
1147
+ const deadline = Date.now() + timeoutMs
1148
+ while (Date.now() < deadline) {
1149
+ if (!isProcessAlive(pid)) return true
1150
+ await sleep(100)
1151
+ }
1152
+ return !isProcessAlive(pid)
1153
+ }
1154
+
1155
+ // a sim record's host pid can outlive its process; after pid reuse the number
1156
+ // may point at an unrelated process (real incident 2026-07-14: a stale record
1157
+ // made `sootsim close` SIGTERM the shared vite shell dev server, taking the
1158
+ // whole dev stack down). only signal a pid whose argv proves it is still a
1159
+ // sootsim playwright host launcher.
1160
+ function isPlaywrightHostProcess(pid: number): boolean {
1161
+ // `ps eww` prints argv then the process ENVIRONMENT for same-user
1162
+ // processes. the marker must live in the env SUFFIX (everything past the
1163
+ // plain argv), because argv text can contain the string coincidentally —
1164
+ // e.g. a shell wrapper that exports SOOTSIM_PW_* vars in its script body.
1165
+ const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
1166
+ encoding: 'utf8',
1167
+ stdio: ['ignore', 'pipe', 'pipe'],
1168
+ })
1169
+ const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], {
1170
+ encoding: 'utf8',
1171
+ stdio: ['ignore', 'pipe', 'pipe'],
1172
+ })
1173
+ if (plain.status !== 0 || withEnv.status !== 0) return false
1174
+ const argv = (plain.stdout || '').trimEnd()
1175
+ const full = (withEnv.stdout || '').trimEnd()
1176
+ const envSuffix = full.startsWith(argv) ? full.slice(argv.length) : full
1177
+ return envSuffix.includes('SOOTSIM_PW_URL=')
1178
+ }
1179
+
1180
+ export async function terminatePlaywrightHostsForSims(
1181
+ sims: BridgeSimInfo[],
1182
+ ids: string[],
1183
+ ) {
1184
+ const wanted = new Set(ids)
1185
+ const pids = new Set<number>()
1186
+ for (const sim of sims) {
1187
+ if (!wanted.has(sim.id)) continue
1188
+ const pid = playwrightHostPidForSim(sim)
1189
+ if (pid) pids.add(pid)
1190
+ }
1191
+ for (const pid of pids) {
1192
+ if (!isPlaywrightHostProcess(pid)) {
1193
+ console.log(` skipped pid ${pid}: not a rnx playwright host (stale sim record)`)
1194
+ continue
1195
+ }
1196
+ try {
1197
+ process.kill(pid, 'SIGTERM')
1198
+ } catch {}
1199
+ if (await waitForProcessExit(pid)) {
1200
+ console.log(` closed playwright host process ${pid}`)
1201
+ continue
1202
+ }
1203
+ try {
1204
+ process.kill(pid, 'SIGKILL')
1205
+ console.log(` force-closed playwright host process ${pid}`)
1206
+ } catch {}
1207
+ }
1208
+ }
1209
+
1210
+ function resolveTargetSim(sims: BridgeSimInfo[], requestedId?: string): BridgeSimInfo {
1211
+ if (requestedId) {
1212
+ const normalizedId = requestedId.trim()
1213
+ const match = sims.find((sim) => sim.id === normalizedId)
1214
+ if (!match) {
1215
+ throw new Error(`no sim connected with id ${normalizedId}`)
1216
+ }
1217
+ return match
1218
+ }
1219
+ const primary = sims.find((sim) => sim.isPrimary && sim.readyState === 'open')
1220
+ if (primary) return primary
1221
+ const firstOpen = sims.find((sim) => sim.readyState === 'open')
1222
+ if (firstOpen) return firstOpen
1223
+ throw new Error('no sim connected')
1224
+ }
1225
+
1226
+ function printOpenedSim(
1227
+ openUrl: string,
1228
+ sim: BridgeSimInfo,
1229
+ mode:
1230
+ | 'desktop companion'
1231
+ | 'bridge'
1232
+ | 'direct shell open'
1233
+ | 'current sim'
1234
+ | `${string} driver`,
1235
+ ) {
1236
+ console.log(` ${mode === 'current sim' ? 'loaded' : 'opened'}: ${openUrl} [${mode}]`)
1237
+ // agents pipe around the JSON to grab the id; humans just want to know
1238
+ // the current sim got updated without scanning the JSON blob.
1239
+ console.log(` current sim: ${sim.id}`)
1240
+ console.log(JSON.stringify({ simId: sim.id, url: sim.url }, null, 2))
1241
+ }
1242
+
1243
+ // after a successful open, agents almost always want to know what's on the
1244
+ // screen. running describe inline saves a separate roundtrip. tightly
1245
+ // bounded — a cold guest bundle takes 5-15s to fully route, so we don't
1246
+ // try to wait for that. instead: settle for a moment to let the first
1247
+ // paint land, then describe whatever's on screen. for cold loads that's
1248
+ // a splash; the caller can `sootsim describe` again a second later. for
1249
+ // warm loads it's the real screen. guarded by --no-describe.
1250
+ async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) {
1251
+ if (args.includes('--no-describe')) return
1252
+ const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE
1253
+ process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1'
1254
+ try {
1255
+ // brief settle, then dump the tree directly. don't go through
1256
+ // runDescribeSubcommand — its screen-transition wait can block for many
1257
+ // seconds on a still-routing guest app, which is exactly what we want
1258
+ // to skip for the open-side describe. caller can `sootsim describe`
1259
+ // again later for post-routing state.
1260
+ await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
1261
+ const bridge = createBridge(wsPort, {
1262
+ commandTimeoutMs: 3000,
1263
+ simId,
1264
+ cliLabel: 'open --describe',
1265
+ simIdSource: 'flag',
1266
+ })
1267
+ try {
1268
+ let readyProbe: ReadyProbe | null = null
1269
+ try {
1270
+ readyProbe = (await bridge.send({
1271
+ type: 'evaluate',
1272
+ code: WAIT_READY_PROBE,
1273
+ })) as ReadyProbe | null
1274
+ } catch {}
1275
+ if (readyProbe) {
1276
+ const readyEnough =
1277
+ readyProbe.flag === true &&
1278
+ !readyProbe.loadingText &&
1279
+ readyProbeHasContent(readyProbe)
1280
+ if (!readyEnough) {
1281
+ const reason = readyProbe.loadingText
1282
+ ? `still showing "${readyProbe.loadingText}"`
1283
+ : readyProbe.flag !== true
1284
+ ? 'guest ready event has not fired'
1285
+ : readyProbe.targets <= 0
1286
+ ? 'no visible app content is inspectable yet'
1287
+ : 'app tree is still stabilizing'
1288
+ console.log(
1289
+ ` app still loading: ${reason} (nodes: ${readyProbe.nodes}, targets: ${readyProbe.targets})`,
1290
+ )
1291
+ console.log(' before interacting, run: rnxsim wait ready --max-ms 120000')
1292
+ }
1293
+ }
1294
+ const dumpOpts = { describe: true, verbose: false, filter: '' }
1295
+ const code = `(async () => {
1296
+ const t = window.__sootsimTest
1297
+ const ms = window.SootSim?.bridges?.mainShell
1298
+ if (!t) return null
1299
+ let shell = null
1300
+ try { shell = ms?.getState ? await ms.getState() : null } catch {}
1301
+ const tree = await t.dumpTree(12, ${JSON.stringify(dumpOpts)})
1302
+ return { tree, shell }
1303
+ })()`
1304
+ const result = (await bridge.send({ type: 'evaluate', code })) as {
1305
+ tree: string | null
1306
+ shell: { state?: string; activeApp?: string } | null
1307
+ } | null
1308
+ if (!result?.tree) return
1309
+ console.log('')
1310
+ if (result.shell?.state) {
1311
+ const parts = [
1312
+ `state=${result.shell.state}`,
1313
+ result.shell.activeApp ? `app=${result.shell.activeApp}` : null,
1314
+ ].filter(Boolean)
1315
+ console.log(` shell: ${parts.join(' ')}`)
1316
+ }
1317
+ console.log(result.tree)
1318
+ } finally {
1319
+ bridge.close()
1320
+ }
1321
+ } catch {
1322
+ // describe is a convenience — never fail the open command on it
1323
+ } finally {
1324
+ if (prevQuiet === undefined) {
1325
+ delete process.env.SOOTSIM_QUIET_TARGET_NOTICE
1326
+ } else {
1327
+ process.env.SOOTSIM_QUIET_TARGET_NOTICE = prevQuiet
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ // poll node count and return as soon as two samples match across `stableMs`,
1333
+ // capped by `maxMs`. brief and bounded so this never blocks the open command.
1334
+ async function waitForTreeStable(
1335
+ wsPort: number,
1336
+ simId: string,
1337
+ opts: { stableMs: number; maxMs: number },
1338
+ ): Promise<void> {
1339
+ const deadline = Date.now() + opts.maxMs
1340
+ const probeCode = `(async () => (await window.__sootsimTest?.getNodeCount?.()) || 0)()`
1341
+ const bridge = createBridge(wsPort, {
1342
+ commandTimeoutMs: 2000,
1343
+ simId,
1344
+ simIdSource: 'flag',
1345
+ })
1346
+ try {
1347
+ let lastCount = -1
1348
+ let stableSince = 0
1349
+ while (Date.now() < deadline) {
1350
+ let count = -1
1351
+ try {
1352
+ const c = await bridge.send({ type: 'evaluate', code: probeCode })
1353
+ if (typeof c === 'number') count = c
1354
+ } catch {
1355
+ // transient — try again
1356
+ }
1357
+ if (count >= 0 && count === lastCount) {
1358
+ if (Date.now() - stableSince >= opts.stableMs) return
1359
+ } else {
1360
+ lastCount = count
1361
+ stableSince = Date.now()
1362
+ }
1363
+ await sleep(50)
1364
+ }
1365
+ } finally {
1366
+ bridge.close()
1367
+ }
1368
+ }
1369
+
1370
+ export async function runLinkCommand(args: string[], opts: ControlCommandOptions = {}) {
1371
+ const parsed = parseBridgeCliArgs(args, {
1372
+ port: opts.port,
1373
+ commandTimeoutMs: opts.timeoutMs,
1374
+ })
1375
+ if (parsed.positional.length !== 1) {
1376
+ console.error(' rnxsim open: expected exactly one <url-or-route>')
1377
+ console.error(' examples: rnxsim open /settings | rnxsim open myapp://settings')
1378
+ process.exit(1)
1379
+ }
1380
+
1381
+ let target: string
1382
+ try {
1383
+ target = normalizeDeepLinkTarget(parsed.positional[0] ?? '')
1384
+ } catch (err) {
1385
+ console.error(` rnxsim open: ${err instanceof Error ? err.message : String(err)}`)
1386
+ process.exit(1)
1387
+ }
1388
+
1389
+ const bridge = createBridgeFromParsed(parsed)
1390
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1391
+ try {
1392
+ const result = await bridge.send({
1393
+ type: 'call',
1394
+ path: 'SootSim.bridges.test.openDeepLink',
1395
+ args: [target],
1396
+ })
1397
+ if (isObjectRecord(result) && result.ok === false) {
1398
+ throw new Error(
1399
+ typeof result.error === 'string' ? result.error : 'deep-link dispatch failed',
1400
+ )
1401
+ }
1402
+ console.log(` opened deep link: ${target}`)
1403
+ } catch (err) {
1404
+ console.error(` link failed: ${err instanceof Error ? err.message : String(err)}`)
1405
+ await printBridgeFailureDiagnostics(bridge, {
1406
+ errorsCommand: `rnxsim get errors 5${simHint}`,
1407
+ warningsCommand: `rnxsim get warnings 5${simHint}`,
1408
+ requestsCommand: `rnxsim get requests 5${simHint}`,
1409
+ })
1410
+ process.exit(1)
1411
+ } finally {
1412
+ bridge.close()
1413
+ }
1414
+ }
1415
+
1416
+ export async function runOpenCommand(args: string[], opts: ControlCommandOptions = {}) {
1417
+ const parsed = parseBridgeCliArgs(args, {
1418
+ port: opts.port,
1419
+ commandTimeoutMs: opts.timeoutMs,
1420
+ stripBooleanFlags: [
1421
+ '--new',
1422
+ '--headless',
1423
+ '--headed',
1424
+ '--ephemeral',
1425
+ '--hot',
1426
+ '--no-hmr',
1427
+ '--no-describe',
1428
+ ],
1429
+ stripValueFlags: [
1430
+ '--base-url',
1431
+ '--replace',
1432
+ '--driver',
1433
+ '--profile',
1434
+ '--cdp-port',
1435
+ '--device',
1436
+ '--viewport',
1437
+ ],
1438
+ })
1439
+ const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
1440
+ const ephemeralProfile = args.includes('--ephemeral')
1441
+ // --cdp-port exposes Chrome's remote-debugging endpoint on the opened sim so
1442
+ // it can be cpu-profiled while bridge-driven (playwright driver only).
1443
+ const cdpPortArg = args.find((_, i) => args[i - 1] === '--cdp-port')
1444
+ const cdpPort = cdpPortArg ? Number(cdpPortArg) : undefined
1445
+ if (cdpPortArg && (!Number.isFinite(cdpPort) || cdpPort! <= 0)) {
1446
+ console.error(
1447
+ ` rnxsim open: --cdp-port must be a positive port number, got "${cdpPortArg}"`,
1448
+ )
1449
+ process.exit(1)
1450
+ }
1451
+ if (requestedProfileId && ephemeralProfile) {
1452
+ console.error(' rnxsim open: --profile cannot be combined with --ephemeral')
1453
+ process.exit(1)
1454
+ }
1455
+ const profileId = requestedProfileId ? ensureProfile(requestedProfileId).id : undefined
1456
+ const openInNewSim = args.includes('--new') || !!profileId || ephemeralProfile
1457
+ const commandConfig = parseOpenRuntimeConfig(args)
1458
+ if (openInNewSim && parsed.simIdSource === 'flag') {
1459
+ console.error(
1460
+ ' rnxsim open: --new, --profile, and --ephemeral cannot be combined with --sim',
1461
+ )
1462
+ process.exit(1)
1463
+ }
1464
+ const target = parsed.positional[0] || ''
1465
+
1466
+ if (shouldOpenAsDeepLink(target)) {
1467
+ if (openInNewSim) {
1468
+ console.error(
1469
+ ' rnxsim open: deep links target the current app; remove --new, --profile, or --ephemeral',
1470
+ )
1471
+ process.exit(1)
1472
+ }
1473
+ await runLinkCommand(args, opts)
1474
+ return
1475
+ }
1476
+
1477
+ // a preview/build link is a viewing page, not a driveable sim target —
1478
+ // open it in the browser and explain rather than mis-loading it as a
1479
+ // bundle. see looksLikeSootSimShareUrl for why these can't be driven.
1480
+ if (looksLikeSootSimShareUrl(target)) {
1481
+ const isLocal = (() => {
1482
+ try {
1483
+ return isLoopbackHost(new URL(target).hostname)
1484
+ } catch {
1485
+ return false
1486
+ }
1487
+ })()
1488
+ console.log(
1489
+ ' that’s an rnxsim preview/build link — a full player page, not a driveable sim.',
1490
+ )
1491
+ console.log(' opening it in your browser for viewing…')
1492
+ try {
1493
+ await openUrlInBrowser(target, { background: false })
1494
+ } catch (err) {
1495
+ console.error(
1496
+ ` could not launch a browser: ${err instanceof Error ? err.message : String(err)}`,
1497
+ )
1498
+ console.error(` open it yourself: ${target}`)
1499
+ process.exit(1)
1500
+ }
1501
+ console.log(
1502
+ isLocal
1503
+ ? ' to drive it under the CLI, point `rnxsim open` at the app’s dev port instead (e.g. `rnxsim open 8081`).'
1504
+ : ' 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 `rnxsim open <port>`.',
1505
+ )
1506
+ return
1507
+ }
1508
+
1509
+ const resolvedBundle = shouldResolveBundleTargetInCli(target)
1510
+ ? await resolveConnectionInputForCli(target)
1511
+ : undefined
1512
+ const project = await resolveOpenAppProject(resolvedBundle)
1513
+ const appConfig = await loadSootSimAppConfig(project)
1514
+ const configuredRuntimeVersion = appConfig?.runtimeVersion
1515
+ const runtimeConfig = mergeSootSimConfig(
1516
+ appConfig ? { ...appConfig, runtimeVersion: undefined } : undefined,
1517
+ commandConfig,
1518
+ )
1519
+ if (configuredRuntimeVersion) {
1520
+ await ensureConfiguredRuntime(configuredRuntimeVersion)
1521
+ }
1522
+
1523
+ // keep the bridge port and the shell base URL in one world: an explicit
1524
+ // --base-url without --port retargets polling at THAT world's bridge
1525
+ // (otherwise open builds the page against one runtime while polling the
1526
+ // other bridge for registration and times out after ~125s).
1527
+ const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
1528
+ if (explicitBaseUrl && !parsed.explicitPort) {
1529
+ const matched = bridgePortForShellBaseUrl(explicitBaseUrl)
1530
+ if (matched) parsed.wsPort = matched
1531
+ }
1532
+
1533
+ parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
1534
+
1535
+ // playwright is the browser-sim launcher. it owns an isolated profile and
1536
+ // can reap exactly its own browser tree. electron remains available for the
1537
+ // native desktop surface. neither path opens the user's browser profile.
1538
+ const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || ''
1539
+ if (requestedDriverId && !isDriverId(requestedDriverId)) {
1540
+ console.error(
1541
+ ` unknown driver "${requestedDriverId}" — run \`rnxsim list --drivers\``,
1542
+ )
1543
+ process.exit(1)
1544
+ }
1545
+ const driverId: DriverId = isDriverId(requestedDriverId)
1546
+ ? requestedDriverId
1547
+ : 'playwright'
1548
+ const driverHeadless = resolveDriverHeadless(args, driverId)
1549
+ if (cdpPort && driverId !== 'playwright') {
1550
+ console.error(' rnxsim open: --cdp-port is only honored by the playwright driver')
1551
+ process.exit(1)
1552
+ }
1553
+
1554
+ // metro HMR mode. interactive opens default to hot=true so edits hot-apply
1555
+ // through sootsim's HmrClient — expo CLI's createBundleUrlSearchParams
1556
+ // hardcodes hot=false on every launchAsset URL (a leftover toggle real iOS
1557
+ // sims tolerate but sootsim couldn't until now), so without this flip the
1558
+ // bundle prelude doesn't include metro's HMR runtime + react-refresh and
1559
+ // edits silently no-op. `--driver playwright` (agent / CI runs) defaults
1560
+ // to hot=false for deterministic e2e. explicit `--hot` / `--no-hmr` win.
1561
+ const hotFlag = args.includes('--hot')
1562
+ const noHmrFlag = args.includes('--no-hmr')
1563
+ if (hotFlag && noHmrFlag) {
1564
+ console.error(' rnxsim open: --hot and --no-hmr cannot be combined')
1565
+ process.exit(1)
1566
+ }
1567
+ const hotMode = hotFlag ? true : noHmrFlag ? false : requestedDriverId !== 'playwright'
1568
+
1569
+ // --device <model> seeds the device profile for the opened sim (stamped on
1570
+ // the engine URL, so both playwright and electron honor it). without it
1571
+ // the sim runs whatever profile the
1572
+ // shell last persisted, which silently skews browser comparisons.
1573
+ const deviceArg = args.find((_, i) => args[i - 1] === '--device')
1574
+ if (deviceArg && !(deviceArg in devices)) {
1575
+ console.error(` rnxsim open: unknown device "${deviceArg}"`)
1576
+ console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`)
1577
+ process.exit(1)
1578
+ }
1579
+ const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined
1580
+
1581
+ // --viewport <WxH> sizes the driver-launched page explicitly (film and
1582
+ // promo captures frame the 3d stage in landscape). takes precedence over
1583
+ // the device-derived window; without either, the driver keeps its own
1584
+ // default.
1585
+ const viewportArg = args.find((_, i) => args[i - 1] === '--viewport')
1586
+ const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null
1587
+ if (viewportArg && !viewportMatch) {
1588
+ console.error(
1589
+ ` rnxsim open: --viewport must be <width>x<height>, got "${viewportArg}"`,
1590
+ )
1591
+ process.exit(1)
1592
+ }
1593
+ const explicitViewport = viewportMatch
1594
+ ? { width: Number(viewportMatch[1]), height: Number(viewportMatch[2]) }
1595
+ : undefined
1596
+
1597
+ const buildShellUrlOpts: BuildShellUrlOptions = {
1598
+ hot: hotMode,
1599
+ device: deviceArg,
1600
+ resolvedBundle,
1601
+ project,
1602
+ }
1603
+
1604
+ // resolve the shell base URL from the bridge port we settled on, so the page
1605
+ // and the polling never split across worlds.
1606
+ const selectedShellBaseUrl = configuredRuntimeVersion
1607
+ ? `http://localhost:${parsed.wsPort}/`
1608
+ : explicitBaseUrl || resolveShellBaseUrlForBridgePort(parsed.wsPort)
1609
+ const baseUrl = applyRuntimeGenerationOrigin(
1610
+ selectedShellBaseUrl,
1611
+ configuredRuntimeVersion,
1612
+ )
1613
+ const hasExplicitBaseUrl = args.includes('--base-url')
1614
+ printBridgeWorldNotice(parsed.wsPort, baseUrl)
1615
+ const savedSimId = readCurrentSimId()
1616
+ const shouldReuseCurrentSim = !openInNewSim
1617
+
1618
+ if (shouldReuseCurrentSim) {
1619
+ const bridge = createBridgeFromParsed(parsed)
1620
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1621
+ let fallBackToNewSim = false
1622
+
1623
+ try {
1624
+ let targetSim: BridgeSimInfo | null = null
1625
+ let targetShellUrl: string | null = null
1626
+ try {
1627
+ const sims = await bridge.listSims()
1628
+ if (parsed.simIdSource === 'saved') {
1629
+ targetSim =
1630
+ sims.find(
1631
+ (sim) =>
1632
+ sim.id === savedSimId &&
1633
+ sim.readyState === 'open' &&
1634
+ simUsesDriver(sim, driverId),
1635
+ ) ?? null
1636
+ if (!targetSim) {
1637
+ clearCurrentSimId()
1638
+ }
1639
+ } else if (parsed.simIdSource === 'flag') {
1640
+ targetSim = resolveTargetSim(sims, parsed.simId)
1641
+ if (!simUsesDriver(targetSim, driverId)) {
1642
+ throw new Error(
1643
+ `sim ${targetSim.id} is not managed by the ${driverId} driver`,
1644
+ )
1645
+ }
1646
+ }
1647
+ if (!targetSim && parsed.simIdSource !== 'flag') {
1648
+ targetShellUrl = await buildShellUrl(
1649
+ target,
1650
+ applySootSimConfigToUrl(baseUrl, runtimeConfig),
1651
+ buildShellUrlOpts,
1652
+ )
1653
+ const normalizedTarget = new URL(targetShellUrl)
1654
+ normalizedTarget.searchParams.delete('inspectOpen')
1655
+ targetSim =
1656
+ sims.find((sim) => {
1657
+ if (
1658
+ sim.readyState !== 'open' ||
1659
+ !sim.url ||
1660
+ !simUsesDriver(sim, driverId)
1661
+ ) {
1662
+ return false
1663
+ }
1664
+ try {
1665
+ const normalizedCurrent = new URL(sim.url)
1666
+ normalizedCurrent.searchParams.delete('inspectOpen')
1667
+ return normalizedCurrent.toString() === normalizedTarget.toString()
1668
+ } catch {
1669
+ return false
1670
+ }
1671
+ }) ?? null
1672
+ }
1673
+ if (!targetSim) {
1674
+ if (parsed.simIdSource !== 'flag') {
1675
+ fallBackToNewSim = true
1676
+ } else {
1677
+ throw new Error('no sim connected')
1678
+ }
1679
+ }
1680
+ if (!fallBackToNewSim && targetSim) {
1681
+ const resolvedBaseUrl =
1682
+ hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1683
+ ? baseUrl
1684
+ : deriveCurrentSimBaseUrl(targetSim, baseUrl)
1685
+ const shellUrl =
1686
+ targetShellUrl ??
1687
+ (await buildShellUrl(
1688
+ target,
1689
+ applySootSimConfigToUrl(resolvedBaseUrl, runtimeConfig),
1690
+ buildShellUrlOpts,
1691
+ ))
1692
+ bridge
1693
+ .send({
1694
+ type: 'evaluate',
1695
+ simId: targetSim.id,
1696
+ code: `window.location.href = ${JSON.stringify(shellUrl)}`,
1697
+ })
1698
+ .catch(() => {})
1699
+ }
1700
+ } catch (err) {
1701
+ console.error(
1702
+ ` open failed: ${err instanceof Error ? err.message : String(err)}`,
1703
+ )
1704
+ await printBridgeFailureDiagnostics(bridge, {
1705
+ errorsCommand: `rnxsim get errors 5${simHint}`,
1706
+ warningsCommand: `rnxsim get warnings 5${simHint}`,
1707
+ requestsCommand: `rnxsim get requests 5${simHint}`,
1708
+ })
1709
+ process.exit(1)
1710
+ }
1711
+
1712
+ if (!fallBackToNewSim && targetSim) {
1713
+ await sleep(1500)
1714
+ const ready = await waitForSimReady(
1715
+ parsed.wsPort,
1716
+ parsed.commandTimeoutMs,
1717
+ targetSim.id,
1718
+ )
1719
+ if (!ready) {
1720
+ console.error(' timed out waiting for current sim to load target')
1721
+ process.exit(1)
1722
+ }
1723
+ ready.bridge.close()
1724
+ saveCurrentSimId(targetSim.id)
1725
+ const resolvedBaseUrl =
1726
+ hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1727
+ ? baseUrl
1728
+ : deriveCurrentSimBaseUrl(targetSim, baseUrl)
1729
+ const shellUrl =
1730
+ targetShellUrl ??
1731
+ (await buildShellUrl(
1732
+ target,
1733
+ applySootSimConfigToUrl(resolvedBaseUrl, runtimeConfig),
1734
+ buildShellUrlOpts,
1735
+ ))
1736
+ printOpenedSim(shellUrl, { ...targetSim, url: shellUrl }, 'current sim')
1737
+ await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
1738
+ return
1739
+ }
1740
+ } finally {
1741
+ bridge.close()
1742
+ }
1743
+ }
1744
+
1745
+ const configuredBaseUrl = applySootSimConfigToUrl(baseUrl, runtimeConfig)
1746
+ const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
1747
+ const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
1748
+ const ownerPid = getStableOwnerPid()
1749
+ if (!ownerPid) {
1750
+ console.error(
1751
+ ' could not resolve a stable owning process; refusing unsupervised sim host',
1752
+ )
1753
+ process.exit(1)
1754
+ }
1755
+ const simMatchesToken = (sim: BridgeSimInfo) =>
1756
+ sim.url?.includes(`inspectOpen=${token}`) ?? false
1757
+
1758
+ const driver = getDriver(driverId)
1759
+ if (!driver) {
1760
+ console.error(` unknown driver "${driverId}" — run \`rnxsim list --drivers\``)
1761
+ process.exit(1)
1762
+ }
1763
+ const connectWait = resolveDriverConnectWaitOptions()
1764
+ const result = await driver.launch({
1765
+ url: openUrl,
1766
+ // the shell serves the sim page but its WS bridge may have drifted off
1767
+ // the shell-port-implied default. inject the resolved bridge port so the
1768
+ // page does not reconnect to a stale world.
1769
+ bridgePort: bridgePortForShellBaseUrl(baseUrl) ?? parsed.wsPort,
1770
+ headless: driverHeadless,
1771
+ profileId,
1772
+ ephemeralProfile,
1773
+ cdpPort,
1774
+ device: deviceArg,
1775
+ ownerPid,
1776
+ viewport:
1777
+ explicitViewport ??
1778
+ (deviceSpec
1779
+ ? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 }
1780
+ : undefined),
1781
+ connectTimeoutMs: connectWait.hostTimeoutMs,
1782
+ })
1783
+ if (!result.launched) {
1784
+ console.error(` ${driver.name} driver: ${result.message}`)
1785
+ process.exit(1)
1786
+ }
1787
+ const outcome = await waitForSimMatchOrHostExit(
1788
+ parsed.wsPort,
1789
+ parsed.commandTimeoutMs,
1790
+ simMatchesToken,
1791
+ result.pid,
1792
+ { attempts: connectWait.attempts, intervalMs: connectWait.intervalMs },
1793
+ )
1794
+ if (outcome.kind === 'host-exit') {
1795
+ printDriverDiagnosticLogTail(result)
1796
+ console.error(` ${driver.name} host crashed — sim is no longer attached`)
1797
+ process.exit(1)
1798
+ }
1799
+ if (outcome.kind === 'timeout') {
1800
+ if (result.pid) {
1801
+ try {
1802
+ process.kill(result.pid, 'SIGTERM')
1803
+ console.error(` closed ${driver.name} host process ${result.pid}`)
1804
+ } catch {}
1805
+ }
1806
+ printDriverDiagnosticLogTail(result)
1807
+ console.error(
1808
+ ` timed out after ${connectWait.timeoutMs}ms waiting for opened sim to connect`,
1809
+ )
1810
+ process.exit(1)
1811
+ }
1812
+ const match = outcome.sim
1813
+ saveCurrentSimId(match.id)
1814
+ signalDriverLaunchConnected(result)
1815
+ printOpenedSim(openUrl, match, `${driver.name} driver`)
1816
+ await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
1817
+ }
1818
+
1819
+ async function runUseLikeCommand(args: string[], opts: ControlCommandOptions = {}) {
1820
+ const parsed = parseBridgeCliArgs(args, {
1821
+ port: opts.port,
1822
+ commandTimeoutMs: opts.timeoutMs,
1823
+ stripBooleanFlags: ['--force'],
1824
+ })
1825
+ const force = args.includes('--force')
1826
+ const bridge = createBridgeFromParsed(parsed)
1827
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1828
+
1829
+ try {
1830
+ try {
1831
+ const sims = await bridge.listSims()
1832
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
1833
+ // `use`/`focus` retarget the CLI at a sim — silently grabbing one a
1834
+ // human is actively driving is the same footgun `claim` guards against,
1835
+ // so honor the lock here too (this command historically did not, which
1836
+ // let `sootsim use <id>` steal the user's live tab).
1837
+ if (target.lockedBy && target.lockExpiresAt && target.lockExpiresAt > Date.now()) {
1838
+ const secs = Math.max(0, Math.round((target.lockExpiresAt - Date.now()) / 1000))
1839
+ if (target.lockedByKind === 'user-active') {
1840
+ // the active human is interacting with this tab. never steal it,
1841
+ // even with --force — mirrors `claim`, which refuses to even
1842
+ // report a user-active takeover. open a fresh sim instead.
1843
+ console.error(
1844
+ ` refused: ${target.id} is locked by the active user (${secs}s) — that's a live human tab`,
1845
+ )
1846
+ console.error(` run \`rnxsim open --new\` for a fresh investigation sim`)
1847
+ process.exit(1)
1848
+ }
1849
+ if (!force) {
1850
+ // another CLI agent leases it. don't hijack its target silently;
1851
+ // a cli lease is short (60s, refreshes per command) so this is
1852
+ // usually a real conflict, not a stale lock.
1853
+ console.error(
1854
+ ` refused: ${target.id} is leased by ${target.lockedBy} (${secs}s)`,
1855
+ )
1856
+ console.error(
1857
+ ` \`sootsim use ${target.id} --force\` to take it, or \`rnxsim open --new\` for a fresh sim`,
1858
+ )
1859
+ process.exit(1)
1860
+ }
1861
+ }
1862
+ await bridge.focusSim(target.id)
1863
+ saveCurrentSimId(target.id)
1864
+ console.log(` using: ${target.id}`)
1865
+ } catch (err) {
1866
+ console.error(` use failed: ${err instanceof Error ? err.message : String(err)}`)
1867
+ await printBridgeFailureDiagnostics(bridge, {
1868
+ errorsCommand: `rnxsim get errors 5${simHint}`,
1869
+ warningsCommand: `rnxsim get warnings 5${simHint}`,
1870
+ requestsCommand: `rnxsim get requests 5${simHint}`,
1871
+ })
1872
+ process.exit(1)
1873
+ }
1874
+ } finally {
1875
+ bridge.close()
1876
+ }
1877
+ }
1878
+
1879
+ export async function runUseCommand(args: string[], opts: ControlCommandOptions = {}) {
1880
+ await runUseLikeCommand(args, opts)
1881
+ }
1882
+
1883
+ export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) {
1884
+ await runUseLikeCommand(args, opts)
1885
+ }
1886
+
1887
+ export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) {
1888
+ const parsed = parseBridgeCliArgs(args, {
1889
+ port: opts.port,
1890
+ commandTimeoutMs: opts.timeoutMs,
1891
+ stripBooleanFlags: ['--force'],
1892
+ })
1893
+ const force = args.includes('--force')
1894
+ const bridge = createBridgeFromParsed(parsed)
1895
+
1896
+ try {
1897
+ try {
1898
+ const sims = await bridge.listSims()
1899
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
1900
+ const priorHolder =
1901
+ force && target.lockedBy && target.lockedByKind !== 'user-active'
1902
+ ? target.lockedBy
1903
+ : null
1904
+ const result = await bridge.claim(target.id, { force })
1905
+ saveCurrentSimId(target.id)
1906
+ const ttl = Math.max(0, Math.round((result.lockExpiresAt - Date.now()) / 1000))
1907
+ const booted = result.bootedCount > 0 ? ` (booted ${result.bootedCount})` : ''
1908
+ console.log(` claimed: ${result.simId} [${ttl}s]${booted}`)
1909
+ if (priorHolder) {
1910
+ console.log(` took over from: ${priorHolder}`)
1911
+ }
1912
+ } catch (err) {
1913
+ if (err instanceof BridgeSimLockedError) {
1914
+ const secs = Math.max(0, Math.round(err.lock.expiresInMs / 1000))
1915
+ console.error(` claim failed: locked by ${err.lock.by} for ${secs}s more`)
1916
+ console.error(
1917
+ ` use --force to take it, or \`rnxsim open --new\` for a fresh sim`,
1918
+ )
1919
+ process.exit(1)
1920
+ }
1921
+ console.error(` claim failed: ${err instanceof Error ? err.message : String(err)}`)
1922
+ process.exit(1)
1923
+ }
1924
+ } finally {
1925
+ bridge.close()
1926
+ }
1927
+ }
1928
+
1929
+ // close many sims at once. fires every close, then polls listSims until the
1930
+ // whole set is gone or the timeout elapses — far faster than awaiting
1931
+ // waitForSimGone serially when clearing dozens of leaked sims.
1932
+ export async function closeSimsBulk(
1933
+ bridge: ReturnType<typeof createBridgeFromParsed>,
1934
+ wsPort: number,
1935
+ commandTimeoutMs: number,
1936
+ ids: string[],
1937
+ ): Promise<{ closed: string[]; remaining: string[] }> {
1938
+ if (ids.length === 0) return { closed: [], remaining: [] }
1939
+ await Promise.all(ids.map((id) => bridge.closeSim(id).catch(() => {})))
1940
+ const wanted = new Set(ids)
1941
+ for (let i = 0; i < 40; i++) {
1942
+ let stillOpen: string[] = []
1943
+ try {
1944
+ const probe = createBridge(wsPort, { commandTimeoutMs })
1945
+ try {
1946
+ const sims = await probe.listSims()
1947
+ stillOpen = sims
1948
+ .filter((s) => wanted.has(s.id) && s.readyState === 'open')
1949
+ .map((s) => s.id)
1950
+ } finally {
1951
+ probe.close()
1952
+ }
1953
+ } catch {
1954
+ stillOpen = []
1955
+ }
1956
+ if (stillOpen.length === 0) {
1957
+ return { closed: [...wanted], remaining: [] }
1958
+ }
1959
+ if (i === 39) {
1960
+ return {
1961
+ closed: [...wanted].filter((id) => !stillOpen.includes(id)),
1962
+ remaining: stillOpen,
1963
+ }
1964
+ }
1965
+ await sleep(250)
1966
+ }
1967
+ return { closed: [...wanted], remaining: [] }
1968
+ }
1969
+
1970
+ export interface BulkClosePlan {
1971
+ openIds: string[]
1972
+ keepId: string | null
1973
+ targets: string[]
1974
+ staleSavedId: string | null
1975
+ missingExplicitKeepId: string | null
1976
+ fallbackKeepId: string | null
1977
+ }
1978
+
1979
+ export function planBulkCloseTargets(
1980
+ sims: BridgeSimInfo[],
1981
+ opts: {
1982
+ closeOthers: boolean
1983
+ explicitKeepId?: string
1984
+ savedKeepId?: string | null
1985
+ },
1986
+ ): BulkClosePlan {
1987
+ const open = sims.filter((sim) => sim.readyState === 'open')
1988
+ const openIds = open.map((sim) => sim.id)
1989
+ const explicitKeepId = opts.explicitKeepId?.trim() || ''
1990
+ const savedKeepId = opts.savedKeepId?.trim() || ''
1991
+ const savedIsOpen = savedKeepId ? open.some((sim) => sim.id === savedKeepId) : false
1992
+ const staleSavedId = savedKeepId && !savedIsOpen ? savedKeepId : null
1993
+ let keepId: string | null = null
1994
+ let missingExplicitKeepId: string | null = null
1995
+ let fallbackKeepId: string | null = null
1996
+
1997
+ if (opts.closeOthers) {
1998
+ if (explicitKeepId) {
1999
+ const match = open.find((sim) => sim.id === explicitKeepId)
2000
+ if (match) {
2001
+ keepId = match.id
2002
+ } else {
2003
+ missingExplicitKeepId = explicitKeepId
2004
+ }
2005
+ } else if (savedKeepId && savedIsOpen) {
2006
+ keepId = savedKeepId
2007
+ } else {
2008
+ const fallback = open.find((sim) => sim.isPrimary) ?? open[0] ?? null
2009
+ if (fallback) {
2010
+ keepId = fallback.id
2011
+ if (savedKeepId && !savedIsOpen) fallbackKeepId = fallback.id
2012
+ }
2013
+ }
2014
+ }
2015
+
2016
+ return {
2017
+ openIds,
2018
+ keepId,
2019
+ targets: missingExplicitKeepId ? [] : openIds.filter((id) => id !== keepId),
2020
+ staleSavedId,
2021
+ missingExplicitKeepId,
2022
+ fallbackKeepId,
2023
+ }
2024
+ }
2025
+
2026
+ export async function runCloseCommand(args: string[], opts: ControlCommandOptions = {}) {
2027
+ const parsed = parseBridgeCliArgs(args, {
2028
+ port: opts.port,
2029
+ commandTimeoutMs: opts.timeoutMs,
2030
+ stripBooleanFlags: ['--all', '--others'],
2031
+ })
2032
+ const bridge = createBridgeFromParsed(parsed)
2033
+ const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
2034
+ const closeAll = args.includes('--all')
2035
+ const closeOthers = args.includes('--others')
2036
+
2037
+ if (closeAll || closeOthers) {
2038
+ try {
2039
+ const sims = await bridge.listSims()
2040
+ const savedId = readCurrentSimId()
2041
+ const explicitKeepId =
2042
+ parsed.positional[0] || (parsed.simIdSource === 'flag' ? parsed.simId : undefined)
2043
+ // `--others` keeps the explicitly-targeted sim, else the saved/current
2044
+ // sim, else primary. if an explicit keep target is gone, abort instead
2045
+ // of treating "keep nothing" as permission to close every open sim.
2046
+ const plan = planBulkCloseTargets(sims, {
2047
+ closeOthers,
2048
+ explicitKeepId,
2049
+ savedKeepId: savedId,
2050
+ })
2051
+ if (plan.staleSavedId) {
2052
+ clearCurrentSimId()
2053
+ }
2054
+ if (plan.fallbackKeepId) {
2055
+ saveCurrentSimId(plan.fallbackKeepId)
2056
+ console.log(
2057
+ ` saved sim ${plan.staleSavedId} is gone — keeping primary ${plan.fallbackKeepId}`,
2058
+ )
2059
+ }
2060
+ if (plan.missingExplicitKeepId) {
2061
+ console.error(
2062
+ ` close failed: keep sim ${plan.missingExplicitKeepId} is not connected; not closing other sims`,
2063
+ )
2064
+ process.exit(1)
2065
+ }
2066
+ if (plan.targets.length === 0) {
2067
+ console.log(
2068
+ plan.keepId
2069
+ ? ` nothing to close — only the kept sim ${plan.keepId} is connected`
2070
+ : plan.staleSavedId
2071
+ ? ` nothing to close — no sims connected (cleared stale current sim ${plan.staleSavedId})`
2072
+ : ' nothing to close — no sims connected',
2073
+ )
2074
+ return
2075
+ }
2076
+ const result = await closeSimsBulk(
2077
+ bridge,
2078
+ parsed.wsPort,
2079
+ parsed.commandTimeoutMs,
2080
+ plan.targets,
2081
+ )
2082
+ await terminatePlaywrightHostsForSims(sims, plan.targets)
2083
+ // saved current sim may have just been closed — repoint or clear it.
2084
+ const nextSavedId = readCurrentSimId()
2085
+ if (nextSavedId && result.closed.includes(nextSavedId)) {
2086
+ if (plan.keepId) saveCurrentSimId(plan.keepId)
2087
+ else clearCurrentSimId()
2088
+ }
2089
+ const summary = ` closed ${result.closed.length} sim(s)${
2090
+ plan.keepId ? ` (kept ${plan.keepId})` : ''
2091
+ }`
2092
+ console.log(summary)
2093
+ if (result.remaining.length > 0) {
2094
+ console.error(` close failed: still connected: ${result.remaining.join(', ')}`)
2095
+ process.exit(1)
2096
+ }
2097
+ } catch (err) {
2098
+ console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
2099
+ process.exit(1)
2100
+ } finally {
2101
+ bridge.close()
2102
+ }
2103
+ return
2104
+ }
2105
+
2106
+ try {
2107
+ try {
2108
+ const sims = await bridge.listSims()
2109
+ const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
2110
+ const fallbackTarget = sims.find(
2111
+ (sim) => sim.id !== target.id && sim.readyState === 'open',
2112
+ )
2113
+ await bridge.closeSim(target.id)
2114
+ const closed = await waitForSimGone(
2115
+ parsed.wsPort,
2116
+ parsed.commandTimeoutMs,
2117
+ target.id,
2118
+ )
2119
+ if (!closed) {
2120
+ await terminatePlaywrightHostsForSims(sims, [target.id])
2121
+ console.error(` close failed: ${target.id} is still connected`)
2122
+ process.exit(1)
2123
+ }
2124
+ await terminatePlaywrightHostsForSims(sims, [target.id])
2125
+ if (readCurrentSimId() === target.id) {
2126
+ if (fallbackTarget) saveCurrentSimId(fallbackTarget.id)
2127
+ else clearCurrentSimId()
2128
+ }
2129
+ console.log(` closed: ${target.id}`)
2130
+ } catch (err) {
2131
+ console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
2132
+ await printBridgeFailureDiagnostics(bridge, {
2133
+ errorsCommand: `rnxsim get errors 5${simHint}`,
2134
+ warningsCommand: `rnxsim get warnings 5${simHint}`,
2135
+ requestsCommand: `rnxsim get requests 5${simHint}`,
2136
+ })
2137
+ process.exit(1)
2138
+ }
2139
+ } finally {
2140
+ bridge.close()
2141
+ }
2142
+ }