rnxsim 0.1.519 → 0.1.520

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 (68) 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 +1 -1
  20. package/dist-lib/bridge-contract-input.mjs +1 -1
  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 +1 -1
  29. package/dist-lib/cloud.mjs +1 -1
  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 +43 -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 +674 -12
  60. package/dist-lib/vite.cjs +26 -3
  61. package/package.json +1 -1
  62. package/scripts/dev-server-scanner.ts +72 -5
  63. package/src/cloud-contract.ts +7 -1
  64. package/src/cloud.ts +11 -4
  65. package/src/connect.ts +1 -1
  66. package/src/menu.ts +44 -0
  67. package/src/vite-plugin-one.ts +12 -0
  68. package/src/vite-plugin-swift.ts +17 -5
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.520 | (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,19 @@ 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
+ };
770
+
771
+ // src/vite-plugin-swift.ts
765
772
  var run = (0, import_node_util.promisify)(import_node_child_process2.execFile);
766
773
  var TOOLCHAIN_BIN = import_node_path4.default.join(
767
774
  import_node_os2.default.homedir(),
768
- "Library/Developer/Toolchains/swift-6.2-RELEASE.xctoolchain/usr/bin"
775
+ `Library/Developer/Toolchains/${toolchain_default.release}.xctoolchain/usr/bin`
769
776
  );
770
- var WASM_SDK = "swift-6.2-RELEASE_wasm";
777
+ var WASM_SDK = `${toolchain_default.release}_wasm`;
771
778
  var WASM_TRIPLE = "wasm32-unknown-wasip1";
772
779
  var ARTIFACT_PATH = "/__rnx/swift/";
773
780
  var VIRTUAL_PREFIX = "\0rnx-swift:";
@@ -878,6 +885,11 @@ function swiftPlugin(options = {}) {
878
885
  name: "rnx-swift",
879
886
  enforce: "pre",
880
887
  async resolveId(source, importer) {
888
+ if (source.startsWith(VIRTUAL_PREFIX)) return source;
889
+ if (source.startsWith("/@id/")) {
890
+ const decoded = source.slice("/@id/".length).replace(/^__x00__/, "\0");
891
+ return decoded.startsWith(VIRTUAL_PREFIX) ? decoded : null;
892
+ }
881
893
  if (!source.endsWith(".swift")) return null;
882
894
  const resolved = await this.resolve(source, importer, { skipSelf: true });
883
895
  if (!resolved) throw new Error(`cannot resolve swift source ${source}`);
@@ -938,6 +950,17 @@ function rnxPlugin(options = {}) {
938
950
  const prefix = options.prefix || "/__soot";
939
951
  return {
940
952
  name: "sootsim-one",
953
+ // use-latest-callback ships a CJS main (module.exports, no default) with
954
+ // an esm.mjs shim that re-imports it. react-native-bottom-tabs
955
+ // default-imports it from source served live, so vite hands the browser
956
+ // the raw CJS file and the import fails with 'does not provide an export
957
+ // named default' before anything mounts. forcing it through dep
958
+ // optimization lets esbuild's CJS interop answer the default import, and
959
+ // every bare import of it (including the nested copy's) resolves to the
960
+ // one pre-bundled instance.
961
+ config() {
962
+ return { optimizeDeps: { include: ["use-latest-callback"] } };
963
+ },
941
964
  configureServer(server) {
942
965
  if (options.enabled === false) return;
943
966
  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.520",
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
  }
@@ -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,
@@ -436,7 +436,9 @@ export class RemoteSimulator {
436
436
  reset: () => this.sendCommand({ type: 'reset' }),
437
437
  state: (options) =>
438
438
  this.sendCommand(
439
- options === undefined ? { type: 'state' } : { type: 'state', stateOptions: options },
439
+ options === undefined
440
+ ? { type: 'state' }
441
+ : { type: 'state', stateOptions: options },
440
442
  ),
441
443
  }
442
444
  }
@@ -653,7 +655,10 @@ function readConnectClaim(value: unknown): RnxCloudClaim | null {
653
655
  return readClaim(value)
654
656
  }
655
657
 
656
- function readConnectScopes(value: unknown, token: string): readonly RnxCloudCommandScope[] {
658
+ function readConnectScopes(
659
+ value: unknown,
660
+ token: string,
661
+ ): readonly RnxCloudCommandScope[] {
657
662
  // the full per-simulator token carries every scope except debug, which the
658
663
  // service already guarantees. a scoped token learns its verbs from the get
659
664
  // it just made, so a verb outside them is refused before any request.
@@ -678,7 +683,9 @@ function readConnectScopes(value: unknown, token: string): readonly RnxCloudComm
678
683
  // attach to a simulator another process created. one get proves the token,
679
684
  // and the simulator it returns drives with that token's scopes, takes no
680
685
  // claim, and never steals one.
681
- export async function connect(input: ConnectRemoteSimulatorInput): Promise<RemoteSimulator> {
686
+ export async function connect(
687
+ input: ConnectRemoteSimulatorInput,
688
+ ): Promise<RemoteSimulator> {
682
689
  if (!input.id) throw new Error('RNX Cloud connect needs a simulator id')
683
690
  if (!input.token) throw new Error('RNX Cloud connect needs a token')
684
691
  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',
@@ -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 =
@@ -16,18 +16,20 @@ import fs from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { promisify } from 'node:util'
19
+ import toolchain from '../../sootsim-swift/toolchain.json'
19
20
  import type { Plugin, ViteDevServer } from 'vite'
20
21
 
21
22
  const run = promisify(execFile)
22
23
 
23
- // the toolchain layout scripts/build.sh pins. xcode's swift has no wasm
24
- // backend, so the swift.org toolchain and its matching wasm sdk are the only
25
- // ones that can build this target.
24
+ // the swift.org release scripts/build.sh builds with, from the one place it is
25
+ // named: the toolchain is <release>.xctoolchain and its matching wasm sdk is
26
+ // <release>_wasm. xcode's swift has no wasm backend, so this toolchain is the
27
+ // only one that can build the target.
26
28
  const TOOLCHAIN_BIN = path.join(
27
29
  os.homedir(),
28
- 'Library/Developer/Toolchains/swift-6.2-RELEASE.xctoolchain/usr/bin',
30
+ `Library/Developer/Toolchains/${toolchain.release}.xctoolchain/usr/bin`,
29
31
  )
30
- const WASM_SDK = 'swift-6.2-RELEASE_wasm'
32
+ const WASM_SDK = `${toolchain.release}_wasm`
31
33
  const WASM_TRIPLE = 'wasm32-unknown-wasip1'
32
34
 
33
35
  const ARTIFACT_PATH = '/__rnx/swift/'
@@ -185,6 +187,16 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
185
187
  enforce: 'pre',
186
188
 
187
189
  async resolveId(source, importer) {
190
+ if (source.startsWith(VIRTUAL_PREFIX)) return source
191
+ // import-analysis re-resolves the served URL form of the id
192
+ // (/@id/__x00__..., with the null byte encoded) while transforming the
193
+ // importer. it still names this module, so answer the decoded id; any
194
+ // other /@id/ url is already resolved and must not fall through to the
195
+ // filesystem resolve below, which would throw for it.
196
+ if (source.startsWith('/@id/')) {
197
+ const decoded = source.slice('/@id/'.length).replace(/^__x00__/, '\0')
198
+ return decoded.startsWith(VIRTUAL_PREFIX) ? decoded : null
199
+ }
188
200
  if (!source.endsWith('.swift')) return null
189
201
  const resolved = await this.resolve(source, importer, { skipSelf: true })
190
202
  if (!resolved) throw new Error(`cannot resolve swift source ${source}`)