rnxsim 0.1.343 → 0.1.345

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 (209) hide show
  1. package/README.md +59 -31
  2. package/cli/app-fonts.ts +8 -28
  3. package/cli/app-project.ts +12 -184
  4. package/cli/app-splash.ts +3 -17
  5. package/cli/bin.ts +15 -28
  6. package/cli/commands/control.ts +20 -30
  7. package/cli/commands/daemon.ts +19 -44
  8. package/cli/commands/flow.ts +0 -27
  9. package/cli/commands/install-desktop.ts +2 -16
  10. package/cli/commands/login.ts +3 -43
  11. package/cli/commands/logout.ts +0 -7
  12. package/cli/commands/serve.ts +2 -32
  13. package/cli/commands/setup.ts +20 -194
  14. package/cli/commands/telemetry.ts +7 -5
  15. package/cli/commands/upload.ts +1 -48
  16. package/cli/commands/welcome.ts +64 -0
  17. package/cli/outbound-endpoints.ts +586 -0
  18. package/cli/privacy.ts +53 -0
  19. package/cli/runtime-notes.ts +2 -2
  20. package/cli/telemetry.ts +46 -149
  21. package/dist-cli/bin.js +6 -6
  22. package/dist-cli/chunks/{agent-RFD32ACU.js → agent-52G55XTO.js} +2 -2
  23. package/dist-cli/chunks/{agent-wrapper-2YLW2HQK.js → agent-wrapper-BOZM43ON.js} +2 -2
  24. package/dist-cli/chunks/{app-fonts-6OATDF3Y.js → app-fonts-A2L6V5EZ.js} +2 -2
  25. package/dist-cli/chunks/{assert-PSQICFOY.js → assert-CJKIP4LN.js} +2 -2
  26. package/dist-cli/chunks/{auth-XWLFKZT7.js → auth-VHHN3TOB.js} +2 -2
  27. package/dist-cli/chunks/{beta-I6WTVM6C.js → beta-EBDIGV3E.js} +2 -2
  28. package/dist-cli/chunks/{camera-45HKTLWG.js → camera-5F4KKCQ3.js} +2 -2
  29. package/dist-cli/chunks/chunk-3DBTS4MC.js +15 -0
  30. package/dist-cli/chunks/chunk-3N7M5HMJ.js +6 -0
  31. package/dist-cli/chunks/{chunk-RWYA2USN.js → chunk-4BIPA7MD.js} +1 -1
  32. package/dist-cli/chunks/chunk-4OKV7T5D.js +4 -0
  33. package/dist-cli/chunks/{chunk-T4T6DDKM.js → chunk-4ZILDTZX.js} +2 -2
  34. package/dist-cli/chunks/{chunk-R7X74I3I.js → chunk-5GXRRUOA.js} +26 -26
  35. package/dist-cli/chunks/chunk-63ASUHOV.js +4 -0
  36. package/dist-cli/chunks/{chunk-DASP2EKF.js → chunk-7GO2DSYQ.js} +1 -1
  37. package/dist-cli/chunks/{chunk-HJXIBZ35.js → chunk-7LGINKM6.js} +2 -2
  38. package/dist-cli/chunks/{chunk-2IPMH24A.js → chunk-7UIIAHVU.js} +2 -2
  39. package/dist-cli/chunks/{chunk-ES5BHPTS.js → chunk-7V4KPZ7O.js} +2 -2
  40. package/dist-cli/chunks/{chunk-FCHRGEHA.js → chunk-7YMQRA6L.js} +2 -2
  41. package/dist-cli/chunks/{chunk-DC7TS573.js → chunk-A2Q76LJV.js} +2 -2
  42. package/dist-cli/chunks/{chunk-SAUCA7SN.js → chunk-BCQV63BW.js} +45 -45
  43. package/dist-cli/chunks/{chunk-3FYJ7U3C.js → chunk-BFTDL2GX.js} +17 -17
  44. package/dist-cli/chunks/chunk-BHKSCNDC.js +4 -0
  45. package/dist-cli/chunks/{chunk-QPTJNLPT.js → chunk-DGTV3KOW.js} +1 -1
  46. package/dist-cli/chunks/chunk-DHM3JDCK.js +4 -0
  47. package/dist-cli/chunks/{chunk-TQRVLK64.js → chunk-DPP2M4AJ.js} +1 -1
  48. package/dist-cli/chunks/chunk-E2RGSCHT.js +4 -0
  49. package/dist-cli/chunks/{chunk-TQ5OPG33.js → chunk-EPLVIDPC.js} +1 -1
  50. package/dist-cli/chunks/{chunk-P3IDK7FR.js → chunk-GKGXULJK.js} +2 -2
  51. package/dist-cli/chunks/{chunk-34KDMQ6U.js → chunk-HK7YKH6N.js} +2 -2
  52. package/dist-cli/chunks/{chunk-BJUNXCPO.js → chunk-HXE344V3.js} +1 -1
  53. package/dist-cli/chunks/{chunk-HJHFC5ZZ.js → chunk-JC33TOAF.js} +1 -1
  54. package/dist-cli/chunks/{chunk-G3B2RZ47.js → chunk-KA4PJNTE.js} +2 -2
  55. package/dist-cli/chunks/{chunk-VC4TDPJQ.js → chunk-KH777UAO.js} +3 -3
  56. package/dist-cli/chunks/{chunk-4PFMDBLB.js → chunk-KRZPFXVN.js} +1 -1
  57. package/dist-cli/chunks/{chunk-6SLM6URN.js → chunk-KZFJNDH3.js} +1 -1
  58. package/dist-cli/chunks/{chunk-KLMWLQAO.js → chunk-LAQGSSTT.js} +1 -1
  59. package/dist-cli/chunks/chunk-LRM2XCEQ.js +11 -0
  60. package/dist-cli/chunks/{chunk-YKKVDKLE.js → chunk-NUPRHZQW.js} +2 -2
  61. package/dist-cli/chunks/{chunk-AKV2DGKM.js → chunk-OPHT4DUO.js} +1 -1
  62. package/dist-cli/chunks/{chunk-KON6XG4Y.js → chunk-P2CGGMU2.js} +2 -2
  63. package/dist-cli/chunks/{chunk-5FB4EA33.js → chunk-PLB6UVVX.js} +2 -2
  64. package/dist-cli/chunks/{chunk-FRRHCLG2.js → chunk-PLUWN2V5.js} +1 -1
  65. package/dist-cli/chunks/{chunk-WDA4QR6U.js → chunk-QNBBKRCA.js} +2 -2
  66. package/dist-cli/chunks/{chunk-G7S6T5OX.js → chunk-QNRWPAOA.js} +2 -2
  67. package/dist-cli/chunks/{chunk-2EVBZ37G.js → chunk-R7ZNRGQN.js} +1 -1
  68. package/dist-cli/chunks/{chunk-TMJGPUWJ.js → chunk-RB4VFUNM.js} +3 -3
  69. package/dist-cli/chunks/chunk-RT3DXTDP.js +25 -0
  70. package/dist-cli/chunks/{chunk-OYLIWHDB.js → chunk-RTDHX6CW.js} +2 -2
  71. package/dist-cli/chunks/{chunk-YLFDA4WW.js → chunk-T7SBFG5I.js} +2 -2
  72. package/dist-cli/chunks/{chunk-W7EXE22C.js → chunk-TEU5NGUA.js} +2 -2
  73. package/dist-cli/chunks/chunk-TMRD5MHL.js +99 -0
  74. package/dist-cli/chunks/{chunk-SU7RE3PV.js → chunk-TOF5TA5X.js} +11 -11
  75. package/dist-cli/chunks/{chunk-6IVEGYWV.js → chunk-U5YHXQHH.js} +2 -2
  76. package/dist-cli/chunks/{chunk-BA3HRFJ5.js → chunk-UVOEHIGP.js} +1 -1
  77. package/dist-cli/chunks/{chunk-SW4DV2TY.js → chunk-VLBN3KRC.js} +1 -1
  78. package/dist-cli/chunks/{chunk-7WKV6EKX.js → chunk-WPPGU5IK.js} +2 -2
  79. package/dist-cli/chunks/{chunk-JOE3J46E.js → chunk-WU72KL6Z.js} +2 -2
  80. package/dist-cli/chunks/chunk-XJBFJGNT.js +4 -0
  81. package/dist-cli/chunks/{chunk-3CMKNWRL.js → chunk-XXZLK4BY.js} +1 -1
  82. package/dist-cli/chunks/{chunk-BKMPV6N4.js → chunk-YVSUFVVB.js} +1 -1
  83. package/dist-cli/chunks/{chunk-RO3WVQPB.js → chunk-ZMTZ5G5H.js} +2 -2
  84. package/dist-cli/chunks/{cleanup-OU4VKFC3.js → cleanup-MAVZJXVW.js} +2 -2
  85. package/dist-cli/chunks/cli-version-2B4KRNML.js +4 -0
  86. package/dist-cli/chunks/{compat-GAEKP67T.js → compat-7AURLGCS.js} +2 -2
  87. package/dist-cli/chunks/config-TN3Y4J5N.js +13 -0
  88. package/dist-cli/chunks/{control-VQJURMFD.js → control-EV2ELU5R.js} +2 -2
  89. package/dist-cli/chunks/daemon-KKDG5MYR.js +4 -0
  90. package/dist-cli/chunks/{debug-F5N2LO4T.js → debug-JICITUB6.js} +3 -3
  91. package/dist-cli/chunks/{desktop-I4ILT4BS.js → desktop-4F5HGLLX.js} +3 -3
  92. package/dist-cli/chunks/{detox-2HMPYSP4.js → detox-ZC7NHLJI.js} +2 -2
  93. package/dist-cli/chunks/{device-5LWLOB5R.js → device-V57GHRXX.js} +2 -2
  94. package/dist-cli/chunks/{diagnose-5S4IQPAP.js → diagnose-FF4IWYXC.js} +2 -2
  95. package/dist-cli/chunks/{disk-cleanup-CJSZVJQX.js → disk-cleanup-T277L6ZO.js} +2 -2
  96. package/dist-cli/chunks/drivers-LCX7S76X.js +4 -0
  97. package/dist-cli/chunks/{film-HCPU2TDB.js → film-G7ZAPWB7.js} +3 -3
  98. package/dist-cli/chunks/flow-ONZGGFGD.js +4 -0
  99. package/dist-cli/chunks/help-DROVDXJM.js +4 -0
  100. package/dist-cli/chunks/{hidden-runtime-alias-YYUEZQPR.js → hidden-runtime-alias-VSJNNSNI.js} +2 -2
  101. package/dist-cli/chunks/{home-paths-3FXISM5I.js → home-paths-75HP3HKA.js} +2 -2
  102. package/dist-cli/chunks/inspect-MDA7ESGQ.js +4 -0
  103. package/dist-cli/chunks/install-desktop-EIVZHIFA.js +4 -0
  104. package/dist-cli/chunks/login-ZSIHGHUK.js +26 -0
  105. package/dist-cli/chunks/logout-Q4DXMI2C.js +4 -0
  106. package/dist-cli/chunks/{maestro-F5KK4RC4.js → maestro-LE5LCRBL.js} +3 -3
  107. package/dist-cli/chunks/{maestro-generate-6SUU3Y5I.js → maestro-generate-D3ZIJCRL.js} +3 -3
  108. package/dist-cli/chunks/{mode-CSWGOJLI.js → mode-BWX6MDPJ.js} +2 -2
  109. package/dist-cli/chunks/{optional-demo-registry-Y6VJ4SVV.js → optional-demo-registry-6CVASL3S.js} +2 -2
  110. package/dist-cli/chunks/{perf-NRWWAJL4.js → perf-NXQZISN6.js} +2 -2
  111. package/dist-cli/chunks/{permissions-OD62A3DJ.js → permissions-5PD67RRO.js} +2 -2
  112. package/dist-cli/chunks/privacy-H5KNRNIZ.js +4 -0
  113. package/dist-cli/chunks/{record-JZT2GM3G.js → record-YJF4KXMR.js} +3 -3
  114. package/dist-cli/chunks/{report-issue-ZWSNHOPB.js → report-issue-XOL5FQBG.js} +2 -2
  115. package/dist-cli/chunks/reset-5LZN5P5B.js +4 -0
  116. package/dist-cli/chunks/runtime-U36VWBH3.js +4 -0
  117. package/dist-cli/chunks/{screenshot-command-ZGJR7ICY.js → screenshot-command-VRPJXWFS.js} +3 -3
  118. package/dist-cli/chunks/{screenshot-layers-5AQKOJZK.js → screenshot-layers-QP4WA2F5.js} +2 -2
  119. package/dist-cli/chunks/{screenshots-capture-CPTWK5Y3.js → screenshots-capture-O2IBAVSP.js} +2 -2
  120. package/dist-cli/chunks/serve-WC6RZNJL.js +118 -0
  121. package/dist-cli/chunks/setup-A6BIG3KL.js +30 -0
  122. package/dist-cli/chunks/{skills-NVWRBMEO.js → skills-A7YC6RL5.js} +2 -2
  123. package/dist-cli/chunks/{state-QGLTKN6K.js → state-IJI5YROJ.js} +2 -2
  124. package/dist-cli/chunks/{storage-OZMEZ4AV.js → storage-Z2DBO2PQ.js} +4 -4
  125. package/dist-cli/chunks/store-BYTCKNIC.js +4 -0
  126. package/dist-cli/chunks/telemetry-JLHFMD7N.js +4 -0
  127. package/dist-cli/chunks/{timeline-EXLDREY3.js → timeline-N7V2M6QA.js} +2 -2
  128. package/dist-cli/chunks/{upgrade-4VWXQO6I.js → upgrade-P2NH3BOR.js} +2 -2
  129. package/dist-cli/chunks/upload-Z2WX5DCD.js +4 -0
  130. package/dist-cli/chunks/{version-WZM6XCD2.js → version-OUHCSCUS.js} +2 -2
  131. package/dist-cli/chunks/{web-ZYPEUVWL.js → web-6NRTF3UV.js} +2 -2
  132. package/dist-cli/chunks/welcome-6RS7PEJW.js +5 -0
  133. package/dist-cli/chunks/{what-happened-KF2HLWKW.js → what-happened-FUEL3BEF.js} +2 -2
  134. package/dist-lib/agent-daemon-client.cjs +1 -1
  135. package/dist-lib/agent-events.cjs +1 -1
  136. package/dist-lib/agent-identity.cjs +1 -1
  137. package/dist-lib/agent-sessions.cjs +1 -1
  138. package/dist-lib/attached-projects.cjs +1 -1
  139. package/dist-lib/auth/shared-session.cjs +1 -1
  140. package/dist-lib/backend-origin.cjs +1 -1
  141. package/dist-lib/beta.cjs +1 -1
  142. package/dist-lib/beta.mjs +1 -1
  143. package/dist-lib/bridge-constants.cjs +1 -1
  144. package/dist-lib/bridge-contract.cjs +1 -1
  145. package/dist-lib/cli-constants.cjs +1 -1
  146. package/dist-lib/config.cjs +1 -1
  147. package/dist-lib/detox/index.cjs +1 -1
  148. package/dist-lib/dev-bundle-resolution.cjs +18 -4
  149. package/dist-lib/home-paths.cjs +145 -11
  150. package/dist-lib/host/bridge-host.cjs +236 -31
  151. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  152. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  153. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  154. package/dist-lib/host/replacement-module-handler.cjs +71 -4
  155. package/dist-lib/host/websocket-proxy.cjs +1 -1
  156. package/dist-lib/index.cjs +337 -37
  157. package/dist-lib/jump-to-source-babel.cjs +1 -1
  158. package/dist-lib/menu.cjs +1 -1
  159. package/dist-lib/menu.mjs +1 -1
  160. package/dist-lib/metro-production-bundle.cjs +1 -1
  161. package/dist-lib/metro-production-bundle.mjs +1 -1
  162. package/dist-lib/metro.cjs +484 -14
  163. package/dist-lib/profiles.cjs +1 -1
  164. package/dist-lib/public-brand.cjs +1 -1
  165. package/dist-lib/react-native-host-modules.cjs +1 -1
  166. package/dist-lib/react-native-host-modules.mjs +1 -1
  167. package/dist-lib/render-mode.cjs +1 -1
  168. package/dist-lib/scripts/dev-server-scanner.cjs +25 -2
  169. package/dist-lib/sdk.cjs +1 -1
  170. package/dist-lib/sdk.mjs +1 -1
  171. package/dist-lib/skills.cjs +384 -385
  172. package/dist-lib/vite.cjs +193 -37
  173. package/package.json +2 -1
  174. package/scripts/dev-server-scanner.ts +12 -5
  175. package/skills/rnx-test/SKILL.md +4 -0
  176. package/src/dev-bundle-resolution.ts +13 -6
  177. package/src/dev-server-open.ts +197 -0
  178. package/src/home-paths.ts +168 -15
  179. package/src/host/bridge-host.ts +2 -2
  180. package/src/host/replacement-module-handler.ts +68 -2
  181. package/src/metro-plugin.ts +83 -5
  182. package/src/native-dev-bundle-url.ts +22 -0
  183. package/src/vite-plugin-one.ts +42 -28
  184. package/dist-cli/chunks/chunk-44LRQEU3.js +0 -4
  185. package/dist-cli/chunks/chunk-4RKTGL63.js +0 -9
  186. package/dist-cli/chunks/chunk-5SCIDXJD.js +0 -25
  187. package/dist-cli/chunks/chunk-CUVY4A2A.js +0 -4
  188. package/dist-cli/chunks/chunk-DPJGB7MP.js +0 -101
  189. package/dist-cli/chunks/chunk-HD6P3W5R.js +0 -4
  190. package/dist-cli/chunks/chunk-HFNESSHG.js +0 -15
  191. package/dist-cli/chunks/chunk-I7HHBFSD.js +0 -4
  192. package/dist-cli/chunks/chunk-LV2G34IX.js +0 -4
  193. package/dist-cli/chunks/cli-version-REQOM22G.js +0 -4
  194. package/dist-cli/chunks/config-QTFXJKD6.js +0 -13
  195. package/dist-cli/chunks/daemon-ETA375WE.js +0 -4
  196. package/dist-cli/chunks/drivers-R4YB2XAF.js +0 -4
  197. package/dist-cli/chunks/flow-3VQL6XZT.js +0 -4
  198. package/dist-cli/chunks/help-2RYCKZ2G.js +0 -4
  199. package/dist-cli/chunks/inspect-XDSX5D6D.js +0 -4
  200. package/dist-cli/chunks/install-desktop-OKVRLL35.js +0 -4
  201. package/dist-cli/chunks/login-4VCBPAY2.js +0 -28
  202. package/dist-cli/chunks/logout-WSL5JS5R.js +0 -4
  203. package/dist-cli/chunks/reset-3C52YQCV.js +0 -4
  204. package/dist-cli/chunks/runtime-4V2RBUOR.js +0 -4
  205. package/dist-cli/chunks/serve-ERAZQGCJ.js +0 -118
  206. package/dist-cli/chunks/setup-UG6PLYIL.js +0 -32
  207. package/dist-cli/chunks/store-VPBQKKWA.js +0 -4
  208. package/dist-cli/chunks/telemetry-FRW7OCYZ.js +0 -4
  209. package/dist-cli/chunks/upload-C6676NST.js +0 -4
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.343 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.345 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -37,43 +37,119 @@ __export(vite_plugin_one_exports, {
37
37
  sootsimPlugin: () => sootsimPlugin
38
38
  });
39
39
  module.exports = __toCommonJS(vite_plugin_one_exports);
40
- var import_child_process = require("child_process");
41
40
  var import_fs2 = __toESM(require("fs"), 1);
41
+ var import_node_crypto2 = require("node:crypto");
42
42
  var import_path2 = __toESM(require("path"), 1);
43
- var import_url2 = require("url");
44
43
 
45
44
  // src/bridge-constants.ts
46
45
  var DEFAULT_SOOTSIM_BRIDGE_PORT = 7668;
47
46
  var DEFAULT_SOOTSIM_BRIDGE_URL = `http://localhost:${DEFAULT_SOOTSIM_BRIDGE_PORT}/`;
48
47
 
49
- // src/home-paths.ts
48
+ // src/dev-server-open.ts
49
+ var import_node_child_process = require("node:child_process");
50
+ var import_node_crypto = require("node:crypto");
50
51
  var import_node_fs = __toESM(require("node:fs"), 1);
51
- var import_node_os = require("node:os");
52
52
  var import_node_path = __toESM(require("node:path"), 1);
53
+ var import_node_url = require("node:url");
54
+ function resolveRnxDesktopAppName(open, projectRoot) {
55
+ if (!open) return null;
56
+ if (typeof open === "object") {
57
+ const appName = open.appName.trim();
58
+ if (!appName) throw new Error("rnx: open.appName must not be empty");
59
+ return appName;
60
+ }
61
+ const packageJsonPath = import_node_path.default.join(projectRoot, "package.json");
62
+ const packageJson = JSON.parse(import_node_fs.default.readFileSync(packageJsonPath, "utf8"));
63
+ const packageName = packageJson && typeof packageJson === "object" ? Reflect.get(packageJson, "name") : void 0;
64
+ if (typeof packageName !== "string" || !packageName.trim()) {
65
+ throw new Error(`rnx: ${packageJsonPath} must declare a package name`);
66
+ }
67
+ return packageName.trim();
68
+ }
69
+ function tagRnxBundleForPlugin(bundleUrl, replacementOrigin, replacementToken, projectRoot, appName) {
70
+ const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(bundleUrl);
71
+ const url = new URL(bundleUrl, absolute ? void 0 : "http://rnx.local");
72
+ if (appName) {
73
+ url.searchParams.set("rnxAppName", appName);
74
+ url.searchParams.set(
75
+ "rnxProjectId",
76
+ (0, import_node_crypto.createHash)("sha256").update(import_node_fs.default.realpathSync(projectRoot)).digest("hex").slice(0, 32)
77
+ );
78
+ }
79
+ url.searchParams.set("rnxReplacementOrigin", replacementOrigin);
80
+ url.searchParams.set("rnxReplacementToken", replacementToken);
81
+ return absolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
82
+ }
83
+ function resolveRnxDesktopLaunchTarget(port, bundleUrl) {
84
+ return new URL(bundleUrl, `http://localhost:${port}`).toString();
85
+ }
86
+ function buildRnxDesktopOpenArgs(bundleUrl) {
87
+ return ["open", bundleUrl, "--new", "--driver", "electron", "--no-describe"];
88
+ }
89
+ function observeRnxDesktopLaunch(child, appName) {
90
+ let stderr = "";
91
+ let spawnFailed = false;
92
+ child.stderr?.setEncoding("utf8");
93
+ child.stderr?.on("data", (chunk) => {
94
+ stderr = `${stderr}${chunk}`.slice(-16384);
95
+ });
96
+ child.once("error", (error) => {
97
+ spawnFailed = true;
98
+ console.error(`[rnx] could not open ${appName}: ${error.message}`);
99
+ });
100
+ child.once("close", (code, signal) => {
101
+ if (spawnFailed) return;
102
+ if (code === 0) {
103
+ console.log(`[rnx] opened ${appName} in the desktop simulator`);
104
+ return;
105
+ }
106
+ const detail = stderr.trim() || (signal ? `signal ${signal}` : `exit ${code}`);
107
+ console.error(`[rnx] could not open ${appName}: ${detail}`);
108
+ });
109
+ }
110
+ function launchRnxDesktop(bundleUrl, appName) {
111
+ const packageRoot = import_node_path.default.resolve(import_node_path.default.dirname((0, import_node_url.fileURLToPath)(__sootsim_import_meta_url)), "..");
112
+ const binPath = import_node_path.default.join(packageRoot, "dist-cli", "bin.js");
113
+ console.log(`[rnx] opening ${appName} in the desktop simulator`);
114
+ const child = (0, import_node_child_process.spawn)(
115
+ process.execPath,
116
+ [binPath, ...buildRnxDesktopOpenArgs(bundleUrl)],
117
+ {
118
+ stdio: ["ignore", "ignore", "pipe"],
119
+ windowsHide: true
120
+ }
121
+ );
122
+ observeRnxDesktopLaunch(child, appName);
123
+ }
124
+
125
+ // src/home-paths.ts
126
+ var import_node_fs2 = __toESM(require("node:fs"), 1);
127
+ var import_node_os = require("node:os");
128
+ var import_node_path2 = __toESM(require("node:path"), 1);
53
129
  var SOOTSIM_HOME_ENV = "SOOTSIM_HOME";
54
130
  var ACTIVE_RUNTIME_FILE = "active";
55
131
  var DAEMON_LOCKFILE = "daemon.json";
56
132
  var DAEMON_HEARTBEAT_STALE_MS = 3e4;
57
133
  function sootsimHomeDir() {
58
134
  const override = process.env[SOOTSIM_HOME_ENV];
59
- if (override && override.length > 0) return import_node_path.default.resolve(override);
60
- return import_node_path.default.join((0, import_node_os.homedir)(), ".sootsim");
135
+ if (override && override.length > 0) return import_node_path2.default.resolve(override);
136
+ return import_node_path2.default.join((0, import_node_os.homedir)(), ".sootsim");
61
137
  }
62
138
  function runtimesDir() {
63
- return import_node_path.default.join(sootsimHomeDir(), "runtimes");
139
+ return import_node_path2.default.join(sootsimHomeDir(), "runtimes");
64
140
  }
65
141
  function runtimeDir(version) {
66
- return import_node_path.default.join(runtimesDir(), version);
142
+ return import_node_path2.default.join(runtimesDir(), version);
67
143
  }
68
144
  function activeRuntimeFile() {
69
- return import_node_path.default.join(runtimesDir(), ACTIVE_RUNTIME_FILE);
145
+ return import_node_path2.default.join(runtimesDir(), ACTIVE_RUNTIME_FILE);
70
146
  }
71
147
  function daemonLockfilePath() {
72
- return import_node_path.default.join(sootsimHomeDir(), DAEMON_LOCKFILE);
148
+ return import_node_path2.default.join(sootsimHomeDir(), DAEMON_LOCKFILE);
73
149
  }
74
150
  function readActiveRuntime() {
75
151
  try {
76
- const value = import_node_fs.default.readFileSync(activeRuntimeFile(), "utf8").trim();
152
+ const value = import_node_fs2.default.readFileSync(activeRuntimeFile(), "utf8").trim();
77
153
  return value.length > 0 ? value : null;
78
154
  } catch {
79
155
  return null;
@@ -82,10 +158,10 @@ function readActiveRuntime() {
82
158
  var DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024;
83
159
  function readDaemonLockfile() {
84
160
  try {
85
- const fd = import_node_fs.default.openSync(daemonLockfilePath(), "r");
161
+ const fd = import_node_fs2.default.openSync(daemonLockfilePath(), "r");
86
162
  try {
87
163
  const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES);
88
- const bytesRead = import_node_fs.default.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0);
164
+ const bytesRead = import_node_fs2.default.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0);
89
165
  const raw = buf.subarray(0, bytesRead).toString("utf8");
90
166
  const parsed = JSON.parse(raw);
91
167
  if (parsed && parsed.schema === 1 && typeof parsed.pid === "number" && typeof parsed.bridgePort === "number" && typeof parsed.runtimePort === "number" && typeof parsed.startedAt === "number" && typeof parsed.heartbeatAt === "number" && (parsed.servedRuntimes === void 0 || Array.isArray(parsed.servedRuntimes) && parsed.servedRuntimes.every((version) => typeof version === "string"))) {
@@ -93,7 +169,7 @@ function readDaemonLockfile() {
93
169
  }
94
170
  return null;
95
171
  } finally {
96
- import_node_fs.default.closeSync(fd);
172
+ import_node_fs2.default.closeSync(fd);
97
173
  }
98
174
  } catch {
99
175
  return null;
@@ -112,12 +188,23 @@ function isDaemonLockfileFresh(lock, now = Date.now()) {
112
188
 
113
189
  // src/host/replacement-module-handler.ts
114
190
  var import_promises = __toESM(require("node:fs/promises"), 1);
115
- var import_node_path2 = __toESM(require("node:path"), 1);
191
+ var import_node_path3 = __toESM(require("node:path"), 1);
116
192
  var import_esbuild = require("esbuild");
193
+
194
+ // src/backend-origin.ts
195
+ function normalizeHostname(hostname) {
196
+ return hostname.replace(/^\[|\]$/g, "").toLowerCase();
197
+ }
198
+ function isLoopbackHost(hostname) {
199
+ const normalized = normalizeHostname(hostname);
200
+ return normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "0.0.0.0" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized);
201
+ }
202
+
203
+ // src/host/replacement-module-handler.ts
117
204
  var REPLACEMENT_MODULE_ROUTE = "/__sootsim-replacement-module";
118
205
  var REPLACEMENT_RUNTIME_PREFIX = "/__sootsim-replacement-runtime";
119
206
  function getReplacementLoader(filePath) {
120
- const ext = import_node_path2.default.extname(filePath).toLowerCase();
207
+ const ext = import_node_path3.default.extname(filePath).toLowerCase();
121
208
  switch (ext) {
122
209
  case ".js":
123
210
  return "js";
@@ -214,9 +301,18 @@ export const Appearance = rn.Appearance
214
301
  `;
215
302
  }
216
303
  function sendJavaScript(req, res, source) {
304
+ const origin = req.headers.origin;
305
+ let allowedOrigin;
306
+ if (origin) {
307
+ try {
308
+ if (isLoopbackHost(new URL(origin).hostname)) allowedOrigin = origin;
309
+ } catch {
310
+ }
311
+ }
217
312
  res.writeHead(200, {
218
313
  "Content-Type": "application/javascript; charset=utf-8",
219
- "Cache-Control": "no-cache"
314
+ "Cache-Control": "no-cache",
315
+ ...allowedOrigin ? { "Access-Control-Allow-Origin": allowedOrigin, Vary: "Origin" } : {}
220
316
  });
221
317
  res.end(req.method === "HEAD" ? void 0 : source);
222
318
  }
@@ -228,6 +324,23 @@ async function handleReplacementModuleRequest(req, res, options = {}) {
228
324
  } catch {
229
325
  return false;
230
326
  }
327
+ if (options.requireLoopbackOrigin) {
328
+ const origin = req.headers.origin;
329
+ const remoteAddress = req.socket.remoteAddress?.replace(/^::ffff:/, "");
330
+ let allowed = Boolean(remoteAddress && isLoopbackHost(remoteAddress));
331
+ if (allowed && origin) {
332
+ try {
333
+ allowed = isLoopbackHost(new URL(origin).hostname);
334
+ } catch {
335
+ allowed = false;
336
+ }
337
+ }
338
+ if (!allowed) {
339
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
340
+ res.end("replacement modules are available only to loopback origins");
341
+ return true;
342
+ }
343
+ }
231
344
  if (url.pathname === `${REPLACEMENT_RUNTIME_PREFIX}/react`) {
232
345
  sendJavaScript(req, res, reactRuntimeSource(options.reactBridgeUrl));
233
346
  return true;
@@ -237,6 +350,11 @@ async function handleReplacementModuleRequest(req, res, options = {}) {
237
350
  return true;
238
351
  }
239
352
  if (url.pathname !== REPLACEMENT_MODULE_ROUTE) return false;
353
+ if (options.requestToken && url.searchParams.get("token") !== options.requestToken) {
354
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
355
+ res.end("replacement module request token is invalid");
356
+ return true;
357
+ }
240
358
  const targetPath = url.searchParams.get("path");
241
359
  if (!targetPath) {
242
360
  res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
@@ -249,17 +367,42 @@ async function handleReplacementModuleRequest(req, res, options = {}) {
249
367
  res.end("replacement files must be .js, .jsx, .ts, or .tsx");
250
368
  return true;
251
369
  }
370
+ if (!import_node_path3.default.isAbsolute(targetPath)) {
371
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
372
+ res.end("replacement file path must be absolute");
373
+ return true;
374
+ }
375
+ let resolvedProjectRoot;
376
+ if (options.projectRoot) {
377
+ const lexicalProjectRoot = import_node_path3.default.resolve(options.projectRoot);
378
+ resolvedProjectRoot = await import_promises.default.realpath(options.projectRoot);
379
+ const lexicalTarget = import_node_path3.default.relative(lexicalProjectRoot, targetPath);
380
+ if (lexicalTarget.startsWith("..") || import_node_path3.default.isAbsolute(lexicalTarget)) {
381
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
382
+ res.end("replacement file must be inside the project root");
383
+ return true;
384
+ }
385
+ }
386
+ let resolvedTargetPath;
252
387
  try {
253
- await import_promises.default.access(targetPath);
388
+ resolvedTargetPath = await import_promises.default.realpath(targetPath);
254
389
  } catch (error) {
255
390
  const message = error instanceof Error ? error.message : String(error);
256
391
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
257
392
  res.end(`replacement file not found: ${message}`);
258
393
  return true;
259
394
  }
395
+ if (resolvedProjectRoot) {
396
+ const relativeTarget = import_node_path3.default.relative(resolvedProjectRoot, resolvedTargetPath);
397
+ if (relativeTarget.startsWith("..") || import_node_path3.default.isAbsolute(relativeTarget)) {
398
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
399
+ res.end("replacement file must be inside the project root");
400
+ return true;
401
+ }
402
+ }
260
403
  try {
261
404
  const result = await (0, import_esbuild.build)({
262
- entryPoints: [targetPath],
405
+ entryPoints: [resolvedTargetPath],
263
406
  bundle: true,
264
407
  write: false,
265
408
  format: "esm",
@@ -446,11 +589,25 @@ function rnxPlugin(options = {}) {
446
589
  configureServer(server) {
447
590
  if (options.enabled === false) return;
448
591
  const bundleUrl = options.bundleUrl || "/node_modules/one/metro-entry.bundle?platform=ios&dev=true&minify=false";
592
+ const appName = resolveRnxDesktopAppName(options.open, server.config.root);
593
+ const replacementToken = (0, import_node_crypto2.randomUUID)();
594
+ const configuredPort = server.config.server.port || 8081;
595
+ const listeningPort = () => {
596
+ const address = server.httpServer?.address();
597
+ return address && typeof address === "object" ? address.port : configuredPort;
598
+ };
599
+ const bundleForPort = (port2) => tagRnxBundleForPlugin(
600
+ bundleUrl,
601
+ `http://localhost:${port2}`,
602
+ replacementToken,
603
+ server.config.root,
604
+ appName
605
+ );
449
606
  const buildShellHtml = (runtimeRoot) => {
450
607
  const html = import_fs2.default.readFileSync(import_path2.default.join(runtimeRoot, "index.html"), "utf8");
451
608
  const lock = readDaemonLockfile();
452
609
  const bridgePort = lock && isDaemonLockfileFresh(lock) && lock.bridgePort > 0 ? lock.bridgePort : DEFAULT_SOOTSIM_BRIDGE_PORT;
453
- const inject = `<script>(function(){var u=new URL(location.href);if(!u.searchParams.has('bundle'))u.searchParams.set('bundle',${JSON.stringify(bundleUrl)});window.__sootsimBridgePort=${bridgePort};history.replaceState(null,'','${prefix}/'+u.search)})()</script>`;
610
+ const inject = `<script>(function(){var u=new URL(location.href);if(!u.searchParams.has('bundle'))u.searchParams.set('bundle',${JSON.stringify(bundleForPort(listeningPort()))});window.__sootsimBridgePort=${bridgePort};history.replaceState(null,'','${prefix}/'+u.search)})()</script>`;
454
611
  return html.replace("</head>", inject + "</head>");
455
612
  };
456
613
  server.middlewares.use((req, res, next) => {
@@ -471,8 +628,9 @@ function rnxPlugin(options = {}) {
471
628
  });
472
629
  server.middlewares.use((req, res, next) => {
473
630
  if ((req.url || "").split("?")[0] !== "/__server-scan") return next();
474
- const scanPort = server.config.server.port || 8081;
475
- const absoluteBundleUrl = bundleUrl.startsWith("http") ? bundleUrl : `http://localhost:${scanPort}${bundleUrl}`;
631
+ const scanPort = listeningPort();
632
+ const pluginBundleUrl = bundleForPort(scanPort);
633
+ const absoluteBundleUrl = pluginBundleUrl.startsWith("http") ? pluginBundleUrl : `http://localhost:${scanPort}${pluginBundleUrl}`;
476
634
  res.setHeader("content-type", "application/json");
477
635
  res.end(
478
636
  JSON.stringify([
@@ -480,13 +638,18 @@ function rnxPlugin(options = {}) {
480
638
  port: scanPort,
481
639
  framework: "one",
482
640
  bundleUrl: absoluteBundleUrl,
641
+ ...appName ? { projectName: appName } : {},
483
642
  lastSeen: Date.now()
484
643
  }
485
644
  ])
486
645
  );
487
646
  });
488
647
  server.middlewares.use((req, res, next) => {
489
- void handleReplacementModuleRequest(req, res).then((handled) => {
648
+ void handleReplacementModuleRequest(req, res, {
649
+ projectRoot: server.config.root,
650
+ requestToken: replacementToken,
651
+ requireLoopbackOrigin: true
652
+ }).then((handled) => {
490
653
  if (!handled) next();
491
654
  });
492
655
  });
@@ -533,23 +696,16 @@ function rnxPlugin(options = {}) {
533
696
  }
534
697
  next();
535
698
  });
536
- const port = server.config.server.port || 8081;
699
+ const port = configuredPort;
537
700
  const sootsimUrl = `http://localhost:${port}${prefix}/`;
538
701
  console.log(`[rnx] serving at ${sootsimUrl}`);
539
- if (options.open && !process.env.CI && !process.env.SOOTSIM_NO_OPEN) {
702
+ if (appName && !process.env.CI && !process.env.SOOTSIM_NO_OPEN) {
540
703
  server.httpServer?.once("listening", () => {
541
- const packageRoot = import_path2.default.resolve(
542
- import_path2.default.dirname((0, import_url2.fileURLToPath)(__sootsim_import_meta_url)),
543
- ".."
704
+ const openPort = listeningPort();
705
+ launchRnxDesktop(
706
+ resolveRnxDesktopLaunchTarget(openPort, bundleForPort(openPort)),
707
+ appName
544
708
  );
545
- const binPath = import_path2.default.join(packageRoot, "dist-cli", "bin.js");
546
- const openPort = server.config.server.port || port;
547
- console.log(`[rnx] opening simulator for :${openPort}`);
548
- (0, import_child_process.spawn)(process.execPath, [binPath, "open", String(openPort)], {
549
- detached: true,
550
- stdio: "ignore",
551
- windowsHide: true
552
- }).unref();
553
709
  });
554
710
  }
555
711
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.343",
3
+ "version": "0.1.345",
4
4
  "description": "rnx CLI, Vite and Metro plugins, and agent skills for driving React Native apps in the browser.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -200,6 +200,7 @@
200
200
  "@expo/plist": "^0.5.4",
201
201
  "esbuild": "^0.25.0",
202
202
  "jiti": "2.7.0",
203
+ "playwright-core": "1.61.0",
203
204
  "ws": "^8.18.0"
204
205
  },
205
206
  "peerDependencies": {
@@ -23,7 +23,10 @@ import type { AppSettingsSpecifier } from '@sootsim/globals'
23
23
 
24
24
  const execP = promisify(exec)
25
25
  import { applySootSimConfigToUrl } from '../src/config.ts'
26
- import { normalizeNativeDevBundleUrl } from '../src/native-dev-bundle-url.ts'
26
+ import {
27
+ alignLoopbackBundleUrlWithProbe,
28
+ normalizeNativeDevBundleUrl,
29
+ } from '../src/native-dev-bundle-url.ts'
27
30
  import {
28
31
  __resetAppSettingsBundleCacheForTests,
29
32
  readAppSettingsBundle,
@@ -399,10 +402,14 @@ function applyManifest(
399
402
  if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo'
400
403
  // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests
401
404
  // and one/vxrn manifests point at a launchAsset URL with `hot=false`
402
- // (hardcoded by @expo/cli — see metroOptions.ts). that's the same URL
403
- // real iOS sims load, so sootsim uses it verbatim to share metro's
404
- // bundle cache; HMR still works via sootsim's HmrClient.
405
- const launchUrl = manifest?.launchAsset?.url
405
+ // (hardcoded by @expo/cli — see metroOptions.ts). keep its path and query
406
+ // so metro shares the iOS bundle cache. when both hosts are loopback, keep
407
+ // the host that answered discovery so address-family drift stays reachable.
408
+ const rawLaunchUrl = manifest?.launchAsset?.url
409
+ const launchUrl =
410
+ typeof rawLaunchUrl === 'string'
411
+ ? alignLoopbackBundleUrlWithProbe(rawLaunchUrl, `http://localhost:${result.port}`)
412
+ : rawLaunchUrl
406
413
  if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) {
407
414
  result.bundleUrl = withRuntimeConfig(result.port, launchUrl, result.cwd)
408
415
  }
@@ -12,6 +12,10 @@ Detox runs existing imperative Jest suites through the rnx driver.
12
12
  > needs a connected, pinned sim. if `rnx describe` errors, load
13
13
  > `/rnx-setup` first.
14
14
 
15
+ Set `SOOTSIM_NO_OPEN=1` before starting an app dev server for a Detox,
16
+ Maestro, or iOS-only pass. Apps can opt into a named Electron window through
17
+ the official Vite or Metro plugin, and test-only work must not open that window.
18
+
15
19
  ## route in (decision tree)
16
20
 
17
21
  - have an existing `.maestro/` directory? → [Maestro](#branch--maestro)
@@ -1,5 +1,9 @@
1
1
  import { isLoopbackHost } from './backend-origin'
2
- import { METRO_BUNDLE_PATH, normalizeNativeDevBundleUrl } from './native-dev-bundle-url'
2
+ import {
3
+ alignLoopbackBundleUrlWithProbe,
4
+ METRO_BUNDLE_PATH,
5
+ normalizeNativeDevBundleUrl,
6
+ } from './native-dev-bundle-url'
3
7
 
4
8
  export const DEFAULT_TEST_DRIVE_PORT = 8081
5
9
 
@@ -65,8 +69,8 @@ function isBaseServerUrl(url: URL) {
65
69
 
66
70
  // resolve a loopback dev-server port through the SAME server-side scanner the
67
71
  // dev-server list uses (`/__server-scan`), so an explicit port (`/rn/<port>`, a
68
- // typed port) gets the EXACT manifest `launchAsset.url` the list resolves —
69
- // byte-identical, one resolver.
72
+ // typed port) gets the same manifest path and query the list resolves. loopback
73
+ // hosts stay aligned to the address that answered discovery.
70
74
  //
71
75
  // why this exists: in a browser/worker the direct manifest probe below is
72
76
  // cross-origin (shell on :5173, dev server on :<port>), and a dev server
@@ -109,8 +113,8 @@ async function resolvePortViaServerScan(port: number): Promise<ResolvedDevBundle
109
113
  if (!match) return null
110
114
  return {
111
115
  // normalize exactly like the manifest-probe branch below — the scan
112
- // returns metro's raw launchAsset url (lazy=true for code-split one/vxrn
113
- // apps), but sootsim loads ONE self-contained bundle into the tenant
116
+ // returns metro's manifest launchAsset url (lazy=true for code-split
117
+ // one/vxrn apps), but sootsim loads ONE self-contained bundle into the tenant
114
118
  // worker, so a deferred React.lazy chunk throws "Requiring unknown module
115
119
  // <id>". normalizeNativeDevBundleUrl forces lazy=false + strips bytecode.
116
120
  bundleUrl: normalizeNativeDevBundleUrl(match.bundleUrl),
@@ -145,10 +149,13 @@ async function probeBaseUrlBundle(
145
149
  if (manifestRes.ok) {
146
150
  const manifest = await manifestRes.json()
147
151
  const client = manifest?.extra?.expoClient || manifest?.extra || {}
148
- const launchUrl =
152
+ const rawLaunchUrl =
149
153
  typeof manifest?.launchAsset?.url === 'string'
150
154
  ? manifest.launchAsset.url
151
155
  : undefined
156
+ const launchUrl = rawLaunchUrl
157
+ ? alignLoopbackBundleUrlWithProbe(rawLaunchUrl, normalizedBaseUrl)
158
+ : undefined
152
159
  if (launchUrl || client.name) {
153
160
  return {
154
161
  bundleUrl: normalizeNativeDevBundleUrl(
@@ -0,0 +1,197 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import http from 'node:http'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ export type RnxDesktopOpenOption = boolean | { appName: string }
9
+
10
+ const scheduledMetroOpens = new Set<string>()
11
+
12
+ export function resolveRnxDesktopAppName(
13
+ open: RnxDesktopOpenOption | undefined,
14
+ projectRoot: string,
15
+ ): string | null {
16
+ if (!open) return null
17
+ if (typeof open === 'object') {
18
+ const appName = open.appName.trim()
19
+ if (!appName) throw new Error('rnx: open.appName must not be empty')
20
+ return appName
21
+ }
22
+
23
+ const packageJsonPath = path.join(projectRoot, 'package.json')
24
+ const packageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
25
+ const packageName =
26
+ packageJson && typeof packageJson === 'object'
27
+ ? Reflect.get(packageJson, 'name')
28
+ : undefined
29
+ if (typeof packageName !== 'string' || !packageName.trim()) {
30
+ throw new Error(`rnx: ${packageJsonPath} must declare a package name`)
31
+ }
32
+ return packageName.trim()
33
+ }
34
+
35
+ export function tagRnxBundleForPlugin(
36
+ bundleUrl: string,
37
+ replacementOrigin: string,
38
+ replacementToken: string,
39
+ projectRoot: string,
40
+ appName?: string | null,
41
+ ): string {
42
+ const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(bundleUrl)
43
+ const url = new URL(bundleUrl, absolute ? undefined : 'http://rnx.local')
44
+ if (appName) {
45
+ url.searchParams.set('rnxAppName', appName)
46
+ url.searchParams.set(
47
+ 'rnxProjectId',
48
+ createHash('sha256')
49
+ .update(fs.realpathSync(projectRoot))
50
+ .digest('hex')
51
+ .slice(0, 32),
52
+ )
53
+ }
54
+ url.searchParams.set('rnxReplacementOrigin', replacementOrigin)
55
+ url.searchParams.set('rnxReplacementToken', replacementToken)
56
+ return absolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`
57
+ }
58
+
59
+ export function resolveRnxDesktopLaunchTarget(port: number, bundleUrl: string): string {
60
+ return new URL(bundleUrl, `http://localhost:${port}`).toString()
61
+ }
62
+
63
+ export function buildRnxDesktopOpenArgs(bundleUrl: string): string[] {
64
+ return ['open', bundleUrl, '--new', '--driver', 'electron', '--no-describe']
65
+ }
66
+
67
+ export function observeRnxDesktopLaunch(child: ChildProcess, appName: string): void {
68
+ let stderr = ''
69
+ let spawnFailed = false
70
+ child.stderr?.setEncoding('utf8')
71
+ child.stderr?.on('data', (chunk: string) => {
72
+ stderr = `${stderr}${chunk}`.slice(-16_384)
73
+ })
74
+ child.once('error', (error) => {
75
+ spawnFailed = true
76
+ console.error(`[rnx] could not open ${appName}: ${error.message}`)
77
+ })
78
+ child.once('close', (code, signal) => {
79
+ if (spawnFailed) return
80
+ if (code === 0) {
81
+ console.log(`[rnx] opened ${appName} in the desktop simulator`)
82
+ return
83
+ }
84
+ const detail = stderr.trim() || (signal ? `signal ${signal}` : `exit ${code}`)
85
+ console.error(`[rnx] could not open ${appName}: ${detail}`)
86
+ })
87
+ }
88
+
89
+ export function launchRnxDesktop(bundleUrl: string, appName: string): void {
90
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
91
+ const binPath = path.join(packageRoot, 'dist-cli', 'bin.js')
92
+ console.log(`[rnx] opening ${appName} in the desktop simulator`)
93
+ const child = spawn(
94
+ process.execPath,
95
+ [binPath, ...buildRnxDesktopOpenArgs(bundleUrl)],
96
+ {
97
+ stdio: ['ignore', 'ignore', 'pipe'],
98
+ windowsHide: true,
99
+ },
100
+ )
101
+ observeRnxDesktopLaunch(child, appName)
102
+ }
103
+
104
+ interface MetroDesktopOpenOptions {
105
+ port: number
106
+ bundleUrl: string
107
+ appName: string
108
+ projectRoot: string
109
+ serverId: string
110
+ intervalMs?: number
111
+ timeoutMs?: number
112
+ launch?: (bundleUrl: string, appName: string) => void
113
+ }
114
+
115
+ export function openRnxDesktopWhenMetroIsReady({
116
+ port,
117
+ bundleUrl,
118
+ appName,
119
+ projectRoot,
120
+ serverId,
121
+ intervalMs = 100,
122
+ timeoutMs = 30_000,
123
+ launch = launchRnxDesktop,
124
+ }: MetroDesktopOpenOptions): Promise<boolean> {
125
+ const key = `${projectRoot}\0${port}`
126
+ if (scheduledMetroOpens.has(key)) return Promise.resolve(false)
127
+ scheduledMetroOpens.add(key)
128
+ const deadline = Date.now() + timeoutMs
129
+
130
+ return new Promise((resolve) => {
131
+ let finished = false
132
+ const finish = (opened: boolean) => {
133
+ if (finished) return
134
+ finished = true
135
+ scheduledMetroOpens.delete(key)
136
+ resolve(opened)
137
+ }
138
+ const retry = () => {
139
+ if (finished) return
140
+ if (Date.now() >= deadline) {
141
+ console.error(
142
+ `[rnx] could not open ${appName}: Metro did not become ready on port ${port}`,
143
+ )
144
+ finish(false)
145
+ return
146
+ }
147
+ const timer = setTimeout(check, intervalMs)
148
+ timer.unref()
149
+ }
150
+ const check = () => {
151
+ const request = http.get(
152
+ `http://127.0.0.1:${port}/__server-scan`,
153
+ { headers: { Accept: 'application/json' } },
154
+ (response) => {
155
+ let body = ''
156
+ response.setEncoding('utf8')
157
+ response.on('data', (chunk: string) => {
158
+ body = `${body}${chunk}`.slice(0, 65_536)
159
+ })
160
+ response.once('end', () => {
161
+ try {
162
+ const scan: unknown = JSON.parse(body)
163
+ const matches =
164
+ response.statusCode === 200 &&
165
+ Array.isArray(scan) &&
166
+ scan.some((entry: unknown) => {
167
+ if (!entry || typeof entry !== 'object') return false
168
+ const scannedPort = Reflect.get(entry, 'port')
169
+ const projectName = Reflect.get(entry, 'projectName')
170
+ const scannedServerId = Reflect.get(entry, 'rnxServerId')
171
+ const scannedBundle = Reflect.get(entry, 'bundleUrl')
172
+ if (
173
+ scannedPort !== port ||
174
+ projectName !== appName ||
175
+ scannedServerId !== serverId ||
176
+ typeof scannedBundle !== 'string'
177
+ ) {
178
+ return false
179
+ }
180
+ return new URL(scannedBundle).searchParams.get('rnxAppName') === appName
181
+ })
182
+ if (matches) {
183
+ launch(bundleUrl, appName)
184
+ finish(true)
185
+ return
186
+ }
187
+ } catch {}
188
+ retry()
189
+ })
190
+ },
191
+ )
192
+ request.setTimeout(Math.min(1_000, timeoutMs), () => request.destroy())
193
+ request.once('error', retry)
194
+ }
195
+ check()
196
+ })
197
+ }