rnxsim 0.1.519 → 0.1.521

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 (71) hide show
  1. package/cli/cloud-client.ts +419 -287
  2. package/cli/cloud-dispatch.ts +2 -21
  3. package/cli/cloud-session.ts +31 -85
  4. package/cli/commands/box.ts +15 -11
  5. package/cli/commands/inspect.ts +34 -1
  6. package/cli/commands/platform.ts +184 -232
  7. package/cli/main.ts +10 -2
  8. package/cli/outbound-endpoints.ts +21 -6
  9. package/dist-lib/agent-daemon-client.cjs +1 -1
  10. package/dist-lib/agent-events.cjs +1 -1
  11. package/dist-lib/agent-identity.cjs +1 -1
  12. package/dist-lib/agent-sessions.cjs +1 -1
  13. package/dist-lib/attached-projects.cjs +1 -1
  14. package/dist-lib/auth/shared-session.cjs +1 -1
  15. package/dist-lib/backend-origin.cjs +1 -1
  16. package/dist-lib/beta.cjs +1 -1
  17. package/dist-lib/beta.mjs +1 -1
  18. package/dist-lib/bridge-constants.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.cjs +7 -2
  20. package/dist-lib/bridge-contract-input.mjs +7 -2
  21. package/dist-lib/bridge-contract.cjs +1 -1
  22. package/dist-lib/bridge-contract.mjs +1 -1
  23. package/dist-lib/capture-contract.cjs +1 -1
  24. package/dist-lib/capture-contract.mjs +1 -1
  25. package/dist-lib/cli-constants.cjs +1 -1
  26. package/dist-lib/cloud-contract.cjs +1 -1
  27. package/dist-lib/cloud-contract.mjs +1 -1
  28. package/dist-lib/cloud.cjs +18 -2
  29. package/dist-lib/cloud.mjs +18 -2
  30. package/dist-lib/config.cjs +1 -1
  31. package/dist-lib/detox/index.cjs +1 -1
  32. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  33. package/dist-lib/home-paths.cjs +1 -1
  34. package/dist-lib/host/bridge-host.cjs +112 -39
  35. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  36. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  38. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  39. package/dist-lib/host/websocket-proxy.cjs +1 -1
  40. package/dist-lib/index.cjs +44 -117
  41. package/dist-lib/jump-to-source-babel.cjs +1 -1
  42. package/dist-lib/jump-to-source-native.cjs +1 -1
  43. package/dist-lib/menu.cjs +30 -1
  44. package/dist-lib/menu.mjs +27 -1
  45. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  46. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  47. package/dist-lib/metro-production-bundle.cjs +1 -1
  48. package/dist-lib/metro-production-bundle.mjs +1 -1
  49. package/dist-lib/metro.cjs +1 -1
  50. package/dist-lib/profiles.cjs +1 -1
  51. package/dist-lib/public-brand.cjs +1 -1
  52. package/dist-lib/react-native-host-modules.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.mjs +1 -1
  54. package/dist-lib/render-mode.cjs +1 -1
  55. package/dist-lib/scripts/dev-server-scanner.cjs +41 -4
  56. package/dist-lib/sdk.cjs +1 -1
  57. package/dist-lib/sdk.mjs +1 -1
  58. package/dist-lib/skills.cjs +138 -164
  59. package/dist-lib/swift.cjs +766 -24
  60. package/dist-lib/vite.cjs +103 -30
  61. package/package.json +1 -1
  62. package/scripts/dev-server-scanner.ts +72 -5
  63. package/src/bridge-contract-input.ts +10 -0
  64. package/src/bridge-contract.ts +5 -0
  65. package/src/cloud-contract.ts +7 -1
  66. package/src/cloud.ts +45 -6
  67. package/src/connect.ts +1 -1
  68. package/src/menu.ts +44 -0
  69. package/src/swift.ts +2 -0
  70. package/src/vite-plugin-one.ts +12 -0
  71. package/src/vite-plugin-swift.ts +141 -39
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.519 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.521 | (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;
@@ -762,12 +762,20 @@ var import_node_fs3 = __toESM(require("node:fs"), 1);
762
762
  var import_node_os2 = __toESM(require("node:os"), 1);
763
763
  var import_node_path4 = __toESM(require("node:path"), 1);
764
764
  var import_node_util = require("node:util");
765
+
766
+ // ../sootsim-swift/toolchain.json
767
+ var toolchain_default = {
768
+ release: "swift-6.3-RELEASE",
769
+ wasmSdkChecksum: "9fa4016ee632c7e9e906608ec3b55cf13dfc4dff44e47574c5af58064dc33fd9"
770
+ };
771
+
772
+ // src/vite-plugin-swift.ts
765
773
  var run = (0, import_node_util.promisify)(import_node_child_process2.execFile);
766
774
  var TOOLCHAIN_BIN = import_node_path4.default.join(
767
775
  import_node_os2.default.homedir(),
768
- "Library/Developer/Toolchains/swift-6.2-RELEASE.xctoolchain/usr/bin"
776
+ `Library/Developer/Toolchains/${toolchain_default.release}.xctoolchain/usr/bin`
769
777
  );
770
- var WASM_SDK = "swift-6.2-RELEASE_wasm";
778
+ var WASM_SDK = `${toolchain_default.release}_wasm`;
771
779
  var WASM_TRIPLE = "wasm32-unknown-wasip1";
772
780
  var ARTIFACT_PATH = "/__rnx/swift/";
773
781
  var VIRTUAL_PREFIX = "\0rnx-swift:";
@@ -780,30 +788,50 @@ function projectFor(packagePath) {
780
788
  projects.set(packagePath, created);
781
789
  return created;
782
790
  }
783
- function rootFor(file) {
784
- return devServer?.config.root ?? import_node_path4.default.dirname(file);
785
- }
786
- function sourceFiles(packagePath) {
791
+ function sourceFiles(root) {
787
792
  const found = [];
788
793
  const walk = (dir) => {
789
794
  for (const entry of import_node_fs3.default.readdirSync(dir, { withFileTypes: true })) {
790
795
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
791
796
  const full = import_node_path4.default.join(dir, entry.name);
792
- if (entry.isDirectory()) walk(full);
793
- else if (entry.name.endsWith(".swift")) found.push(full);
797
+ if (entry.isDirectory()) {
798
+ if (full !== root && import_node_fs3.default.existsSync(import_node_path4.default.join(full, "Package.swift"))) continue;
799
+ walk(full);
800
+ } else if (entry.name.endsWith(".swift")) found.push(full);
794
801
  }
795
802
  };
796
- walk(packagePath);
803
+ walk(root);
797
804
  return found;
798
805
  }
806
+ function linkedModuleRoots(packagePath) {
807
+ let manifest;
808
+ try {
809
+ manifest = import_node_fs3.default.readFileSync(import_node_path4.default.join(packagePath, "Package.swift"), "utf8");
810
+ } catch {
811
+ return [];
812
+ }
813
+ const roots = /* @__PURE__ */ new Set();
814
+ const dependency = /\.package\(\s*path:\s*"([^"]+)"\s*\)/g;
815
+ for (const match of manifest.replace(/^\s*\/\/.*$/gm, "").matchAll(dependency)) {
816
+ const root = import_node_path4.default.resolve(packagePath, match[1]);
817
+ if (import_node_fs3.default.existsSync(import_node_path4.default.join(root, "Package.swift"))) roots.add(root);
818
+ }
819
+ return [...roots].sort();
820
+ }
799
821
  function sourceStamp(packagePath) {
800
822
  const hash = (0, import_node_crypto2.createHash)("sha256");
801
- for (const file of sourceFiles(packagePath).sort()) {
802
- hash.update(import_node_path4.default.relative(packagePath, file));
803
- hash.update(String(import_node_fs3.default.statSync(file).mtimeMs));
823
+ for (const root of [packagePath, ...linkedModuleRoots(packagePath)]) {
824
+ hash.update(root);
825
+ for (const file of sourceFiles(root).sort()) {
826
+ hash.update(import_node_path4.default.relative(root, file));
827
+ hash.update(String(import_node_fs3.default.statSync(file).mtimeMs));
828
+ }
804
829
  }
805
830
  return hash.digest("hex").slice(0, 16);
806
831
  }
832
+ function viewRootId(packagePath, file) {
833
+ return import_node_path4.default.join(packagePath, import_node_path4.default.relative(packagePath, file));
834
+ }
807
835
  function artifactUrl(project, artifact) {
808
836
  const address = devServer?.httpServer?.address();
809
837
  const port = address && typeof address === "object" ? address.port : devServer?.config.server.port;
@@ -872,12 +900,36 @@ function artifactFor(project) {
872
900
  );
873
901
  return project.building;
874
902
  }
903
+ function packageFor(file) {
904
+ let dir = import_node_path4.default.dirname(file);
905
+ const stop = devServer?.config.root;
906
+ for (; ; ) {
907
+ if (import_node_fs3.default.existsSync(import_node_path4.default.join(dir, "Package.swift"))) return dir;
908
+ if (stop !== void 0 && dir === stop || import_node_path4.default.dirname(dir) === dir) break;
909
+ dir = import_node_path4.default.dirname(dir);
910
+ }
911
+ throw new Error(
912
+ `no Package.swift above ${file}${stop === void 0 ? "" : ` (searched up to ${stop})`}; a .swift import needs a swiftpm package like the one in packages/sootsim-swift/example`
913
+ );
914
+ }
915
+ function isViewPackage(packagePath) {
916
+ const main = /@main\s+(?:\w+\s+)*(?:struct|class|enum|actor)\s+\w+[^{]*:\s*[^{]*\bRNXPackage\b/;
917
+ for (const file of sourceFiles(packagePath)) {
918
+ const text = import_node_fs3.default.readFileSync(file, "utf8").replace(/^\s*\/\/.*$/gm, "");
919
+ if (main.test(text)) return true;
920
+ }
921
+ return false;
922
+ }
875
923
  function swiftPlugin(options = {}) {
876
- const packagePathFor = (file) => options.packagePath ?? rootFor(file);
877
924
  return {
878
925
  name: "rnx-swift",
879
926
  enforce: "pre",
880
927
  async resolveId(source, importer) {
928
+ if (source.startsWith(VIRTUAL_PREFIX)) return source;
929
+ if (source.startsWith("/@id/")) {
930
+ const decoded = source.slice("/@id/".length).replace(/^__x00__/, "\0");
931
+ return decoded.startsWith(VIRTUAL_PREFIX) ? decoded : null;
932
+ }
881
933
  if (!source.endsWith(".swift")) return null;
882
934
  const resolved = await this.resolve(source, importer, { skipSelf: true });
883
935
  if (!resolved) throw new Error(`cannot resolve swift source ${source}`);
@@ -885,9 +937,13 @@ function swiftPlugin(options = {}) {
885
937
  },
886
938
  async load(id) {
887
939
  if (!id.startsWith(VIRTUAL_PREFIX)) return null;
888
- const packagePath = packagePathFor(id.slice(VIRTUAL_PREFIX.length));
940
+ const file = id.slice(VIRTUAL_PREFIX.length);
941
+ const packagePath = options.packagePath ?? packageFor(file);
889
942
  const project = projectFor(packagePath);
890
943
  for (const source of sourceFiles(packagePath)) this.addWatchFile(source);
944
+ for (const root of linkedModuleRoots(packagePath)) {
945
+ for (const source of sourceFiles(root)) this.addWatchFile(source);
946
+ }
891
947
  let artifact;
892
948
  try {
893
949
  artifact = await artifactFor(project);
@@ -896,6 +952,14 @@ function swiftPlugin(options = {}) {
896
952
  this.warn(error instanceof Error ? error.message : String(error));
897
953
  artifact = project.artifact;
898
954
  }
955
+ if (isViewPackage(packagePath)) {
956
+ const rootId = viewRootId(packagePath, file);
957
+ return [
958
+ `import { createSwiftView } from 'rnxsim/swift'`,
959
+ `export default createSwiftView({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(rootId)} })`,
960
+ ""
961
+ ].join("\n");
962
+ }
899
963
  return [
900
964
  `import { createSwiftModule } from 'rnxsim/swift'`,
901
965
  `export default createSwiftModule({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)} })`,
@@ -904,30 +968,28 @@ function swiftPlugin(options = {}) {
904
968
  },
905
969
  configureServer(server) {
906
970
  devServer = server;
907
- const packagePath = packagePathFor("");
908
971
  server.middlewares.use((req, res, next) => {
909
972
  const url = (req.url ?? "").split("?")[0];
910
973
  if (!url.startsWith(ARTIFACT_PATH)) {
911
974
  next();
912
975
  return;
913
976
  }
914
- const project = projectFor(packagePath);
915
- void artifactFor(project).then(
916
- (artifact) => {
917
- if (url !== `${ARTIFACT_PATH}${artifact.hash}.wasm`) {
918
- res.statusCode = 404;
919
- res.end("swift artifact is stale");
977
+ void (async () => {
978
+ for (const project of projects.values()) {
979
+ const artifact = await artifactFor(project);
980
+ if (url === `${ARTIFACT_PATH}${artifact.hash}.wasm`) {
981
+ res.setHeader("access-control-allow-origin", "*");
982
+ res.setHeader("content-type", "application/wasm");
983
+ res.end(artifact.bytes);
920
984
  return;
921
985
  }
922
- res.setHeader("access-control-allow-origin", "*");
923
- res.setHeader("content-type", "application/wasm");
924
- res.end(artifact.bytes);
925
- },
926
- (error) => {
927
- res.statusCode = 500;
928
- res.end(error instanceof Error ? error.message : String(error));
929
986
  }
930
- );
987
+ res.statusCode = 404;
988
+ res.end("swift artifact is stale");
989
+ })().catch((error) => {
990
+ res.statusCode = 500;
991
+ res.end(error instanceof Error ? error.message : String(error));
992
+ });
931
993
  });
932
994
  }
933
995
  };
@@ -938,6 +1000,17 @@ function rnxPlugin(options = {}) {
938
1000
  const prefix = options.prefix || "/__soot";
939
1001
  return {
940
1002
  name: "sootsim-one",
1003
+ // use-latest-callback ships a CJS main (module.exports, no default) with
1004
+ // an esm.mjs shim that re-imports it. react-native-bottom-tabs
1005
+ // default-imports it from source served live, so vite hands the browser
1006
+ // the raw CJS file and the import fails with 'does not provide an export
1007
+ // named default' before anything mounts. forcing it through dep
1008
+ // optimization lets esbuild's CJS interop answer the default import, and
1009
+ // every bare import of it (including the nested copy's) resolves to the
1010
+ // one pre-bundled instance.
1011
+ config() {
1012
+ return { optimizeDeps: { include: ["use-latest-callback"] } };
1013
+ },
941
1014
  configureServer(server) {
942
1015
  if (options.enabled === false) return;
943
1016
  const bundleUrl = options.bundleUrl || "/node_modules/one/metro-entry.bundle?platform=ios&dev=true&minify=false";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.519",
3
+ "version": "0.1.521",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for rnx.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -94,6 +94,10 @@ export interface DiscoveredServer {
94
94
  lastSeen: number
95
95
  iconUrl?: string
96
96
  iconPath?: string
97
+ /** the app's own brand colour from its manifest, used as the home tile's
98
+ * background so the tile reads as that app before its icon decodes and
99
+ * when the icon is missing entirely. */
100
+ primaryColor?: string
97
101
  bundleId?: string
98
102
  settingsBundle?: readonly AppSettingsSpecifier[]
99
103
  settingsStorageScopeUrl?: string
@@ -594,6 +598,43 @@ function safeParseManifest(body: string): Record<string, unknown> | null {
594
598
  }
595
599
  }
596
600
 
601
+ // a manifest icon path ("./assets/app-icons/x.png") is relative to the project
602
+ // root. metro's asset route prefixes the whole relative path with /assets/; a
603
+ // statically-served project answers at the literal path. the manifest says
604
+ // nothing about which, so the two candidates are probed once per port and the
605
+ // winner cached. keyed by port and cleared with the rest of that port's state
606
+ // when the process disappears.
607
+ const localIconPathCache = new Map<number, string>()
608
+ const localIconProbeInFlight = new Set<number>()
609
+
610
+ function metroAssetPath(rawIcon: string): string {
611
+ return `/assets/${rawIcon.replace(/^\.?\//, '')}`
612
+ }
613
+
614
+ function staticAssetPath(rawIcon: string): string {
615
+ return `/${rawIcon.replace(/^\.?\//, '')}`
616
+ }
617
+
618
+ // probe both shapes and cache whichever the server actually serves. resolves
619
+ // nothing when neither answers — the tile falls back to the app's brand colour.
620
+ async function resolveLocalIconPath(
621
+ hostname: string,
622
+ port: number,
623
+ rawIcon: string,
624
+ ): Promise<void> {
625
+ const candidates = [metroAssetPath(rawIcon), staticAssetPath(rawIcon)]
626
+ for (const candidate of candidates) {
627
+ const res = await httpGet(hostname, port, candidate, 'HEAD', ENRICH_TIMEOUT_MS)
628
+ if (res?.statusCode === 200) {
629
+ localIconPathCache.set(port, candidate)
630
+ // the identified result caches as a strong entry and won't re-probe on
631
+ // its own; drop it so the next scan republishes with the working url.
632
+ portCache.delete(port)
633
+ return
634
+ }
635
+ }
636
+ }
637
+
597
638
  // fold expo manifest fields (name, icon, bundleId, launchAsset) into a result.
598
639
  // the manifest body is always the GET / response we already have from probePort,
599
640
  // so this never issues another http request.
@@ -607,6 +648,7 @@ function applyManifest(
607
648
  const manifest = JSON.parse(manifestRes.body)
608
649
  const client = manifest?.extra?.expoClient || manifest?.extra || {}
609
650
  if (client.name) result.projectName = client.name
651
+ if (typeof client.primaryColor === 'string') result.primaryColor = client.primaryColor
610
652
  if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier
611
653
  if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo'
612
654
  // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests
@@ -645,11 +687,16 @@ function applyManifest(
645
687
  resolvedIconUrl = rawIcon
646
688
  }
647
689
  } else {
648
- const cleanPath = rawIcon.replace(/^\.?\//, '')
649
- const assetPath = cleanPath.startsWith('assets/')
650
- ? `/${cleanPath}`
651
- : `/assets/${cleanPath}`
652
- const localUrl = `${buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port)}${assetPath}`
690
+ // a manifest icon path is relative to the project root, and the two
691
+ // server shapes we meet serve it at different URLs: metro's asset route
692
+ // is `/assets/` + that whole relative path (so `assets/x.png` lands at
693
+ // `/assets/assets/x.png`), while a statically-served project answers at
694
+ // the literal path. neither shape can be derived from the manifest, so
695
+ // resolveLocalIconPath probes the server once and caches the answer;
696
+ // until it lands we publish the metro form, which is the common case.
697
+ const base = buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port)
698
+ const assetPath = localIconPathCache.get(result.port) ?? metroAssetPath(rawIcon)
699
+ const localUrl = `${base}${assetPath}`
653
700
  resolvedIconUrl = buildIconProxyUrl ? buildIconProxyUrl(localUrl) : localUrl
654
701
  }
655
702
  if (resolvedIconUrl) {
@@ -700,6 +747,21 @@ interface ManifestEnrichment {
700
747
  const enrichmentCache = new Map<number, ManifestEnrichment>()
701
748
  const enrichmentInFlight = new Set<number>()
702
749
 
750
+ // a result that carries a relative icon path has not had its serving shape
751
+ // confirmed yet. probe once per port; applyManifest reads the cached answer.
752
+ function ensureLocalIconPathResolved(result: DiscoveredServer): void {
753
+ const rawIcon = result.iconPath
754
+ if (!rawIcon) return
755
+ if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) return
756
+ if (localIconPathCache.has(result.port)) return
757
+ if (localIconProbeInFlight.has(result.port)) return
758
+ localIconProbeInFlight.add(result.port)
759
+ const hostname = normalizeHostname(new URL(result.bundleUrl).hostname)
760
+ void resolveLocalIconPath(hostname, result.port, rawIcon).finally(() =>
761
+ localIconProbeInFlight.delete(result.port),
762
+ )
763
+ }
764
+
703
765
  // apply already-captured name/icon, or kick off a single background manifest
704
766
  // fetch to capture them. a no-op once the in-race manifest already named the
705
767
  // server, so it's safe to wrap every identified return.
@@ -707,6 +769,7 @@ function enrichWithManifest(
707
769
  result: DiscoveredServer,
708
770
  buildIconProxyUrl?: (externalUrl: string) => string,
709
771
  ): DiscoveredServer {
772
+ ensureLocalIconPathResolved(result)
710
773
  if (result.projectName) return result
711
774
 
712
775
  const cached = enrichmentCache.get(result.port)
@@ -1157,6 +1220,9 @@ export async function scanDevServers(
1157
1220
  for (const p of [...enrichmentCache.keys()]) {
1158
1221
  if (!currentPorts.has(p)) enrichmentCache.delete(p)
1159
1222
  }
1223
+ for (const p of [...localIconPathCache.keys()]) {
1224
+ if (!currentPorts.has(p)) localIconPathCache.delete(p)
1225
+ }
1160
1226
 
1161
1227
  const results: DiscoveredServer[] = []
1162
1228
  const toProbe: ListeningProcess[] = []
@@ -1179,6 +1245,7 @@ export async function scanDevServers(
1179
1245
  knownOne.delete(port)
1180
1246
  // a restarted process may be a different app — drop its captured name/icon.
1181
1247
  enrichmentCache.delete(port)
1248
+ localIconPathCache.delete(port)
1182
1249
  }
1183
1250
  toProbe.push({ port, pid })
1184
1251
  }
@@ -435,9 +435,19 @@ export function parseResetOptions(value: unknown): ResetOptions {
435
435
  if (strategy !== 'data' && strategy !== 'full') {
436
436
  return validationFailure('reset.strategy', 'must be data or full')
437
437
  }
438
+ // the shape check only; the simulator service re-validates the id against
439
+ // its own strict pattern and the caller's account before touching storage.
440
+ const from = input.from
441
+ if (
442
+ from !== undefined &&
443
+ (typeof from !== 'string' || from.length === 0 || from.length > 64)
444
+ ) {
445
+ return validationFailure('reset.from', 'must name a simulator')
446
+ }
438
447
  return {
439
448
  strategy,
440
449
  relaunch: optionalBoolean(input, 'relaunch', 'reset') ?? true,
450
+ ...(from === undefined ? {} : { from }),
441
451
  }
442
452
  }
443
453
 
@@ -502,6 +502,11 @@ export interface ResetOptions extends Pick<
502
502
  strategy?: ResetStrategy
503
503
  // relaunch the app after wiping. default true.
504
504
  relaunch?: boolean
505
+ // boot the wiped simulator on a live copy of another simulator's storage
506
+ // instead of empty stores: the cheap clean-but-not-cold seam. the id names
507
+ // a simulator on the same account; runtimes that cannot reach another
508
+ // simulator's storage refuse it by name rather than wiping without forking.
509
+ from?: string
505
510
  }
506
511
 
507
512
  export interface ResetResult {
@@ -6,7 +6,13 @@ export const RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES = 1024 * 1024
6
6
  export const RNX_CLOUD_TEST_FILES_MAX_COUNT = 256
7
7
  export const RNX_CLOUD_TEST_FILES_MAX_BYTES = 500_000
8
8
 
9
- export type RnxCloudCommandScope = 'read' | 'drive' | 'reset' | 'media' | 'debug' | 'watch'
9
+ export type RnxCloudCommandScope =
10
+ | 'read'
11
+ | 'drive'
12
+ | 'reset'
13
+ | 'media'
14
+ | 'debug'
15
+ | 'watch'
10
16
 
11
17
  const RNX_CLOUD_COMMAND_SCOPE_SET = new Set<string>([
12
18
  'read',
package/src/cloud.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  // public surface.
10
10
 
11
11
  import { isLoopbackHost } from './backend-origin'
12
- import type { SimStateOptions } from './bridge-contract'
13
12
  import { isRnxScreenCapture, type RnxScreenCapture } from './capture-contract'
14
13
  import {
15
14
  isRnxCloudCommandScope,
@@ -27,6 +26,7 @@ import {
27
26
  type RnxCloudScopedToken,
28
27
  } from './cloud-contract'
29
28
  import { rnxPublicBrand } from './public-brand'
29
+ import type { SimStateOptions } from './bridge-contract'
30
30
 
31
31
  export {
32
32
  isRnxCloudCommandScope,
@@ -93,7 +93,12 @@ export interface RnxCommands {
93
93
  // a `data:image/png;base64,` url, assembled by the service from the frame
94
94
  screenshot(options?: { layers?: 'full' | 'tenant' | 'shell' }): Promise<string>
95
95
  capture(): Promise<RnxScreenCapture>
96
- reset(): Promise<unknown>
96
+ // data wipes the app's stores and cold-remounts, keeping install-scoped
97
+ // credentials; full clears those too. from boots the wiped simulator on a
98
+ // wholesale live copy of another simulator's storage instead of empty
99
+ // stores. the bundle itself is never reinstalled: artifacts are immutable
100
+ // and sha-addressed, so there is no install to redo.
101
+ reset(options?: { strategy?: 'data' | 'full'; from?: string }): Promise<unknown>
97
102
  // the one-call read: screenshot, tree, route, and recent errors together.
98
103
  state(options?: SimStateOptions): Promise<unknown>
99
104
  }
@@ -433,10 +438,39 @@ export class RemoteSimulator {
433
438
  }
434
439
  return result
435
440
  },
436
- reset: () => this.sendCommand({ type: 'reset' }),
441
+ reset: (options) => {
442
+ if (
443
+ options?.strategy !== undefined &&
444
+ options.strategy !== 'data' &&
445
+ options.strategy !== 'full'
446
+ ) {
447
+ throw new Error('RNX Cloud reset strategy must be data or full')
448
+ }
449
+ if (
450
+ options?.from !== undefined &&
451
+ (typeof options.from !== 'string' || options.from.length === 0)
452
+ ) {
453
+ throw new Error('RNX Cloud reset from must name a simulator')
454
+ }
455
+ return this.sendCommand(
456
+ options === undefined
457
+ ? { type: 'reset' }
458
+ : {
459
+ type: 'reset',
460
+ resetOptions: {
461
+ ...(options.strategy === undefined
462
+ ? {}
463
+ : { strategy: options.strategy }),
464
+ ...(options.from === undefined ? {} : { from: options.from }),
465
+ },
466
+ },
467
+ )
468
+ },
437
469
  state: (options) =>
438
470
  this.sendCommand(
439
- options === undefined ? { type: 'state' } : { type: 'state', stateOptions: options },
471
+ options === undefined
472
+ ? { type: 'state' }
473
+ : { type: 'state', stateOptions: options },
440
474
  ),
441
475
  }
442
476
  }
@@ -653,7 +687,10 @@ function readConnectClaim(value: unknown): RnxCloudClaim | null {
653
687
  return readClaim(value)
654
688
  }
655
689
 
656
- function readConnectScopes(value: unknown, token: string): readonly RnxCloudCommandScope[] {
690
+ function readConnectScopes(
691
+ value: unknown,
692
+ token: string,
693
+ ): readonly RnxCloudCommandScope[] {
657
694
  // the full per-simulator token carries every scope except debug, which the
658
695
  // service already guarantees. a scoped token learns its verbs from the get
659
696
  // it just made, so a verb outside them is refused before any request.
@@ -678,7 +715,9 @@ function readConnectScopes(value: unknown, token: string): readonly RnxCloudComm
678
715
  // attach to a simulator another process created. one get proves the token,
679
716
  // and the simulator it returns drives with that token's scopes, takes no
680
717
  // claim, and never steals one.
681
- export async function connect(input: ConnectRemoteSimulatorInput): Promise<RemoteSimulator> {
718
+ export async function connect(
719
+ input: ConnectRemoteSimulatorInput,
720
+ ): Promise<RemoteSimulator> {
682
721
  if (!input.id) throw new Error('RNX Cloud connect needs a simulator id')
683
722
  if (!input.token) throw new Error('RNX Cloud connect needs a token')
684
723
  const origin = resolveOrigin(input.origin)
package/src/connect.ts CHANGED
@@ -67,7 +67,7 @@ export async function connect(options: ConnectOptions = {}): Promise<SimClient>
67
67
  const bridge = createCloudBridgeForParsed(target)
68
68
  if (!bridge)
69
69
  throw new SimConnectError('remote simulator session disappeared while connecting')
70
- return new SimClient(bridge, cloud.service === 'box' ? cloud.boxId : cloud.simId, {
70
+ return new SimClient(bridge, cloud.simId, {
71
71
  commandTimeoutMs: options.timeoutMs ?? 15_000,
72
72
  })
73
73
  }
package/src/menu.ts CHANGED
@@ -29,6 +29,28 @@ export const SOOTSIM_AMBIENT_GLOW_LABELS: Record<SootSimAmbientGlowLevel, string
29
29
  full: 'Full',
30
30
  }
31
31
 
32
+ // which ios home screen the simulator portrays. the home screen is authored
33
+ // twice (the flat ios 26 home and the ios 27 home measured off apple's art),
34
+ // so the era is one value every host carries: the menus, the persisted
35
+ // setting, the ?homeScreen= boot seed, the live set-home-screen push and
36
+ // electron's shared config.
37
+ export type SootSimHomeScreenVersion = 'ios26' | 'ios27'
38
+ export const SOOTSIM_HOME_SCREEN_VERSIONS = ['ios26', 'ios27'] as const
39
+
40
+ export function isSootSimHomeScreenVersion(
41
+ value: unknown,
42
+ ): value is SootSimHomeScreenVersion {
43
+ return value === 'ios26' || value === 'ios27'
44
+ }
45
+
46
+ export const SOOTSIM_HOME_SCREEN_VERSION_LABELS: Record<
47
+ SootSimHomeScreenVersion,
48
+ string
49
+ > = {
50
+ ios26: 'iOS 26',
51
+ ios27: 'iOS 27',
52
+ }
53
+
32
54
  export type SootSimMenuItemDef =
33
55
  | { type: 'separator' }
34
56
  | {
@@ -56,6 +78,11 @@ export type SootSimMenuDef = {
56
78
 
57
79
  export interface SootSimMenuState {
58
80
  currentDevice: string
81
+ homeScreenVersion: SootSimHomeScreenVersion
82
+ // whether this surface is a contributor development surface. unreleased
83
+ // simulator surfaces (the iPhone Duo and its home-screen era switch) are
84
+ // offered here and nowhere a user can reach.
85
+ developmentMode: boolean
59
86
  showBezels: boolean
60
87
  showMenuBar: boolean
61
88
  showRail: boolean
@@ -455,6 +482,23 @@ function windowMenu(state: SootSimMenuState): SootSimMenuDef {
455
482
  actionPayload: level,
456
483
  })),
457
484
  },
485
+ // both eras stay selectable while the ios 27 home surfaces land one at a
486
+ // time, and the device's own os picks the starting value. the switch is
487
+ // the iPhone Duo's, so it is offered exactly where the Duo is.
488
+ ...(state.developmentMode
489
+ ? [
490
+ {
491
+ label: 'Home Screen',
492
+ items: SOOTSIM_HOME_SCREEN_VERSIONS.map((version) => ({
493
+ label: SOOTSIM_HOME_SCREEN_VERSION_LABELS[version],
494
+ type: 'radio' as const,
495
+ checked: state.homeScreenVersion === version,
496
+ action: 'set-home-screen',
497
+ actionPayload: version,
498
+ })),
499
+ },
500
+ ]
501
+ : []),
458
502
  {
459
503
  label: 'Wireframe',
460
504
  type: 'checkbox',
package/src/swift.ts CHANGED
@@ -2,5 +2,7 @@
2
2
  // implementation lives beside the toolchain it drives, in sootsim-swift.
3
3
  export {
4
4
  createSwiftModule,
5
+ createSwiftView,
5
6
  type SwiftModuleOptions,
7
+ type SwiftViewOptions,
6
8
  } from '../../sootsim-swift/src/mount.tsx'
@@ -46,6 +46,18 @@ export function rnxPlugin(options: RnxPluginOptions = {}): Plugin {
46
46
  return {
47
47
  name: 'sootsim-one',
48
48
 
49
+ // use-latest-callback ships a CJS main (module.exports, no default) with
50
+ // an esm.mjs shim that re-imports it. react-native-bottom-tabs
51
+ // default-imports it from source served live, so vite hands the browser
52
+ // the raw CJS file and the import fails with 'does not provide an export
53
+ // named default' before anything mounts. forcing it through dep
54
+ // optimization lets esbuild's CJS interop answer the default import, and
55
+ // every bare import of it (including the nested copy's) resolves to the
56
+ // one pre-bundled instance.
57
+ config() {
58
+ return { optimizeDeps: { include: ['use-latest-callback'] } }
59
+ },
60
+
49
61
  configureServer(server) {
50
62
  if (options.enabled === false) return
51
63
  const bundleUrl =