peachsim 0.1.1625 → 0.1.1635

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 (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -9,6 +9,7 @@ import path from 'node:path'
9
9
  import { promisify } from 'node:util'
10
10
  import { RNX_FORKED_SCRIPT_ENV } from '../cli/internal-child'
11
11
  import { isLoopbackHost } from './backend-origin'
12
+ import { collectBridgeModuleSources } from './react-native-bridge-modules'
12
13
  import { swiftBinaryModules } from './swift-binary-modules'
13
14
  import {
14
15
  swiftAvailableModules,
@@ -24,7 +25,7 @@ import {
24
25
  icuUrl,
25
26
  projectFor,
26
27
  serveSwiftPackageAsset,
27
- sourceStamp,
28
+ sourceContentStamp,
28
29
  standinLibraries,
29
30
  SwiftMissingModulesError,
30
31
  } from './swift-host'
@@ -48,6 +49,37 @@ const run = promisify(execFile)
48
49
  const scriptEnv = { ...process.env, [RNX_FORKED_SCRIPT_ENV]: '1' }
49
50
  export const EXPO_SWIFT_ROUTE = '/__peach/expo-swift'
50
51
  export const EXPO_SWIFT_ENTRY = 'Sources/PeachExpoApp/PeachExpoEntry.swift'
52
+ // a connected caller (the conformance waiter) waits this long. the old 10
53
+ // minute cap aborted a compile that caller was still holding.
54
+ const EXPO_SWIFT_REQUEST_TIMEOUT_MS = 30 * 60_000
55
+ // bun drops an unanswered fetch at six minutes and the waiter retries. aborting
56
+ // in that gap restarts the compile. a reconnect inside this window rejoins it.
57
+ const EXPO_SWIFT_CLIENT_GAP_MS = 5_000
58
+
59
+ // the autolinker's command line changed across Expo releases: before 1.8.0 it
60
+ // takes the project from its working directory, before 1.10.3 the Apple
61
+ // platform is named `ios`.
62
+ function autolinkerAppleFlags(autolinkerManifest: string, appRoot: string) {
63
+ const manifest: unknown = JSON.parse(fs.readFileSync(autolinkerManifest, 'utf8'))
64
+ const version =
65
+ manifest && typeof manifest === 'object' && Reflect.get(manifest, 'version')
66
+ const parts =
67
+ typeof version === 'string'
68
+ ? version.split('.').map((part) => Number.parseInt(part, 10))
69
+ : []
70
+ const [major, minor, patch] = parts
71
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
72
+ throw new Error(
73
+ `unreadable expo-modules-autolinking version in ${autolinkerManifest}`,
74
+ )
75
+ const atLeast = (m: number, n: number, p: number) =>
76
+ major !== m ? major > m : minor !== n ? minor > n : patch >= p
77
+ if (atLeast(1, 10, 3))
78
+ return { args: ['--platform', 'apple', '--project-root', appRoot], cwd: undefined }
79
+ if (atLeast(1, 8, 0))
80
+ return { args: ['--platform', 'ios', '--project-root', appRoot], cwd: undefined }
81
+ return { args: ['--platform', 'ios'], cwd: appRoot }
82
+ }
51
83
 
52
84
  export async function collectExpoSwiftSources(
53
85
  appRoot: string,
@@ -68,23 +100,25 @@ export async function collectExpoSwiftSources(
68
100
  return null
69
101
  throw error
70
102
  }
103
+ // autolinkers before 1.0 predate app-owned Swift modules and `--project-root`.
104
+ const autolinkerVersion: unknown = JSON.parse(
105
+ fs.readFileSync(autolinker, 'utf8'),
106
+ ).version
107
+ if (typeof autolinkerVersion === 'string' && Number.parseInt(autolinkerVersion, 10) < 1)
108
+ return null
109
+ const cli = path.join(path.dirname(autolinker), 'bin/expo-modules-autolinking.js')
110
+ const flags = autolinkerAppleFlags(autolinker, appRoot)
71
111
  // both upstream commands discover their inputs independently. settle both
72
112
  // before propagating errors so a failed request leaves no child running.
73
113
  const [searched, resolvedModules] = await Promise.allSettled(
74
114
  ['search', 'resolve'].map((command) =>
75
- run(
76
- process.execPath,
77
- [
78
- path.join(path.dirname(autolinker), 'bin/expo-modules-autolinking.js'),
79
- command,
80
- '--platform',
81
- 'apple',
82
- '--project-root',
83
- appRoot,
84
- '--json',
85
- ],
86
- { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: scriptEnv, signal },
87
- ),
115
+ run(process.execPath, [cli, command, ...flags.args, '--json'], {
116
+ timeout: 120_000,
117
+ maxBuffer: 8 * 1024 * 1024,
118
+ env: scriptEnv,
119
+ cwd: flags.cwd,
120
+ signal,
121
+ }),
88
122
  ),
89
123
  )
90
124
  if (searched.status === 'rejected') throw searched.reason
@@ -226,10 +260,11 @@ export async function collectExpoSwiftSources(
226
260
  }
227
261
  walk(ios)
228
262
  for (const module of classes) {
229
- const unit = {
263
+ const unit: ExpoSwiftSourceUnit = {
230
264
  namespace,
231
265
  moduleId: `${namespace}.${module.name}`,
232
266
  classes: [module.registration],
267
+ bridgeModules: [],
233
268
  files: swiftSourceClosure(files, [module.name]),
234
269
  frameworks: [...frameworks],
235
270
  }
@@ -282,7 +317,12 @@ export async function collectExpoSwiftSources(
282
317
  return units
283
318
  }
284
319
 
285
- function expoManifest(files: Record<string, string>, generation = ''): string {
320
+ // an app target's Swift compiles in its own language mode; Expo packages in the default
321
+ function expoManifest(
322
+ files: Record<string, string>,
323
+ generation = '',
324
+ modes: Map<string, number> = new Map(),
325
+ ): string {
286
326
  const targets = new Map<string, string>()
287
327
  for (const file of Object.keys(files)) {
288
328
  const match = /^(Sources|__standins__)\/([^/]+)\//.exec(file)
@@ -292,11 +332,29 @@ function expoManifest(files: Record<string, string>, generation = ''): string {
292
332
  throw new Error(`duplicate Expo Swift compile target ${match[2]}`)
293
333
  targets.set(match[2]!, dir)
294
334
  }
295
- return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
335
+ return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : modes.has(name) ? `, swiftSettings: [.swiftLanguageMode(.v${modes.get(name)})]` : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
336
+ }
337
+
338
+ function languageModes(units: ExpoSwiftSourceUnit[]): Map<string, number> {
339
+ return new Map(
340
+ units.flatMap((unit) =>
341
+ unit.swiftLanguageMode === undefined
342
+ ? []
343
+ : [[unit.namespace, unit.swiftLanguageMode]],
344
+ ),
345
+ )
296
346
  }
297
347
 
298
348
  function expoEntry(units: ExpoSwiftSourceUnit[]): string {
299
- return `import SwiftUI\nimport ExpoModulesCore\n${[...new Set(units.map((unit) => unit.namespace))].map((namespace) => `import ${namespace}`).join('\n')}\n\n@main struct PeachExpoApp: PeachPackage {\n init() { MainActor.assumeIsolated { PeachExpoRuntime.register([${units.flatMap((unit) => unit.classes).join(', ')}]) } }\n func view(props: JSON) -> PeachExpoNativeView { PeachExpoNativeView(props: props) }\n}\n\n@_expose(wasm, "peach_expo_call")\n@_cdecl("peach_expo_call")\n@MainActor public func peachExpoCall(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {\n PeachExpoRuntime.dispatch(payload, length)\n}\n`
349
+ // an Objective-C bridge module registers with no Swift of its own
350
+ const namespaces = [...new Set(units.map((unit) => unit.namespace))].filter(
351
+ (namespace) =>
352
+ units.some((unit) =>
353
+ Object.keys(unit.files).some((file) => file.startsWith(`Sources/${namespace}/`)),
354
+ ),
355
+ )
356
+ const bridgeModules = units.flatMap((unit) => unit.bridgeModules)
357
+ return `import SwiftUI\nimport ExpoModulesCore\n${bridgeModules.length ? 'import React\n' : ''}${namespaces.map((namespace) => `import ${namespace}`).join('\n')}\n\n@main struct PeachExpoApp: PeachPackage {\n init() { MainActor.assumeIsolated { PeachExpoRuntime.register([${units.flatMap((unit) => unit.classes).join(', ')}])${bridgeModules.length ? `; RCTModuleHost.register([${bridgeModules.join(', ')}])` : ''} } }\n func view(props: JSON) -> PeachExpoNativeView { PeachExpoNativeView(props: props) }\n}\n\n@_expose(wasm, "peach_expo_call")\n@_cdecl("peach_expo_call")\n@MainActor public func peachExpoCall(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {\n PeachExpoRuntime.dispatch(payload, length)\n}\n`
300
358
  }
301
359
 
302
360
  const compiling = new Map<
@@ -307,12 +365,41 @@ const compiling = new Map<
307
365
  clients: number
308
366
  }
309
367
  >()
368
+ // identical sources already produced a wasm that is still registered on its
369
+ // project. a second caller otherwise relinks namespaces whose failure records
370
+ // did not stick, and that link blows the flow's ready bound.
371
+ const preparedExpoSwift = new Map<string, ExpoSwiftCompilationResult>()
372
+
373
+ function expoSwiftPreparedKey(base: string, units: ExpoSwiftSourceUnit[]): string {
374
+ const hash = createHash('sha256')
375
+ hash.update(base)
376
+ for (const unit of units) {
377
+ hash.update('\0')
378
+ hash.update(unit.moduleId)
379
+ for (const name of Object.keys(unit.files).sort()) {
380
+ hash.update('\0')
381
+ hash.update(name)
382
+ hash.update('\0')
383
+ hash.update(unit.files[name] ?? '')
384
+ }
385
+ }
386
+ return hash.digest('hex')
387
+ }
388
+
389
+ // this process's copy of the app's expo sources. the build key covers the
390
+ // same directory, so another server compiling the app keeps its own tree.
391
+ function expoSwiftPackage(appKey: string, name: string): string {
392
+ return path.join(os.tmpdir(), 'peach-expo-swift', appKey, `p${process.pid}`, name)
393
+ }
394
+
310
395
  class ExpoSwiftLinkRefusal extends Error {}
311
396
  // a failed compiler result is valid only for the exact app and framework inputs.
312
- // it is kept beside the namespace's build tree, so a restarted dev server skips
313
- // the namespace instead of relinking every namespace and isolating each again.
397
+ // it is kept at the app level, above each process's build copy, so a restarted
398
+ // dev server skips the namespace instead of relinking every namespace and
399
+ // isolating each again.
314
400
  function failureRecordPath(packagePath: string) {
315
- return `${packagePath}.failed.json`
401
+ const app = path.dirname(path.dirname(packagePath))
402
+ return path.join(app, `${path.basename(packagePath)}.failed.json`)
316
403
  }
317
404
 
318
405
  function readFailedCompilation(packagePath: string) {
@@ -350,7 +437,9 @@ export async function compileExpoSwiftModules(
350
437
  ) => Promise<void>,
351
438
  options: ExpoSwiftPreparationOptions = {},
352
439
  ): Promise<ExpoSwiftCompilationResult | null> {
353
- const timeoutMs = options.timeoutMs ?? 600_000
440
+ // a cold isolation compiles the sdk once per module. one module ran past
441
+ // six minutes, and the readiness wait allows thirty, so the compile does too.
442
+ const timeoutMs = options.timeoutMs ?? 30 * 60_000
354
443
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
355
444
  throw new Error('Expo Swift preparation timeout must be between 1 and 2147483647ms')
356
445
  const controller = new AbortController()
@@ -367,9 +456,20 @@ export async function compileExpoSwiftModules(
367
456
  try {
368
457
  signal.throwIfAborted()
369
458
  report(`discovering app-owned modules (budget ${timeoutMs}ms): ${root}`)
370
- const units = await collectExpoSwiftSources(root, signal)
459
+ const expoUnits = await collectExpoSwiftSources(root, signal)
371
460
  signal.throwIfAborted()
372
- if (!units) return null
461
+ const bridge = collectBridgeModuleSources(fs.realpathSync(root), signal)
462
+ for (const unit of bridge?.units ?? []) {
463
+ if (expoUnits?.some((expo) => expo.namespace === unit.namespace))
464
+ throw new Error(
465
+ `app target ${unit.namespace} shares its Swift module name with a local Expo module`,
466
+ )
467
+ unit.files[EXPO_SWIFT_ENTRY] = expoEntry([unit])
468
+ unit.files['Package.swift'] = expoManifest(unit.files, '', languageModes([unit]))
469
+ }
470
+ const units = [...(expoUnits ?? []), ...(bridge?.units ?? [])]
471
+ const bridgeFailures = bridge?.failures ?? []
472
+ if (units.length === 0 && bridgeFailures.length === 0) return null
373
473
  report(
374
474
  `preparing ${units.length} modules: ${units.map((unit) => unit.moduleId).join(', ')}`,
375
475
  )
@@ -380,9 +480,16 @@ export async function compileExpoSwiftModules(
380
480
  if (!module && publishArtifact)
381
481
  throw new Error('No local Swift toolchain or distribution is available')
382
482
  const result: ExpoSwiftCompilationResult = {
383
- namespaces: units.map((unit) => unit.moduleId),
483
+ namespaces: [
484
+ ...units.map((unit) => unit.moduleId),
485
+ ...bridgeFailures.map((failure) => failure.namespace),
486
+ ],
487
+ bridgeModules: [
488
+ ...(bridge?.units ?? []).map((unit) => unit.moduleId),
489
+ ...bridgeFailures.map((failure) => failure.namespace),
490
+ ],
384
491
  modules: [],
385
- failures: [],
492
+ failures: [...bridgeFailures],
386
493
  }
387
494
  if (!module) {
388
495
  for (const unit of units)
@@ -475,10 +582,14 @@ export async function compileExpoSwiftModules(
475
582
  unit,
476
583
  [...built.gaps].map(([name, reason]) => `${name}: no stand-in: ${reason}`),
477
584
  )
478
- unit.files['Package.swift'] = expoManifest(unit.files, generation)
585
+ unit.files['Package.swift'] = expoManifest(
586
+ unit.files,
587
+ generation,
588
+ languageModes([unit]),
589
+ )
479
590
  }
480
591
  }
481
- const packagePath = path.join(os.tmpdir(), 'peach-expo-swift', key, unit.moduleId)
592
+ const packagePath = expoSwiftPackage(key, unit.moduleId)
482
593
  // preserve the compiler's input stamp across unchanged reloads.
483
594
  // only this adapter's build copy is synchronized; app sources are read only.
484
595
  syncSwiftTree(
@@ -491,7 +602,7 @@ export async function compileExpoSwiftModules(
491
602
  ),
492
603
  )
493
604
  projectFor(packagePath).libraries = libraries.get(unit)
494
- const stamp = sourceStamp(packagePath, module.root)
605
+ const stamp = sourceContentStamp(packagePath, module.root)
495
606
  const failed = readFailedCompilation(packagePath)
496
607
  if (failed?.stamp === stamp) {
497
608
  failures.set(unit.moduleId, failed.diagnostic)
@@ -501,6 +612,14 @@ export async function compileExpoSwiftModules(
501
612
  stamps.set(unit, stamp)
502
613
  candidates.push(unit)
503
614
  }
615
+ const preparedKey = expoSwiftPreparedKey(base, units)
616
+ if (!publishArtifact) {
617
+ const prepared = preparedExpoSwift.get(preparedKey)
618
+ if (prepared) {
619
+ report('reusing prepared expo swift artifact')
620
+ return prepared
621
+ }
622
+ }
504
623
  const link = async (linked: ExpoSwiftSourceUnit[]) => {
505
624
  const files: Record<string, string> = {}
506
625
  for (const unit of linked)
@@ -517,8 +636,9 @@ export async function compileExpoSwiftModules(
517
636
  files['Package.swift'] = expoManifest(
518
637
  files,
519
638
  linked.map((unit) => generations.get(unit) ?? '').join(','),
639
+ languageModes(linked),
520
640
  )
521
- const packagePath = path.join(os.tmpdir(), 'peach-expo-swift', key, 'linked-app')
641
+ const packagePath = expoSwiftPackage(key, 'linked-app')
522
642
  syncSwiftTree(
523
643
  packagePath,
524
644
  new Map(
@@ -538,7 +658,7 @@ export async function compileExpoSwiftModules(
538
658
  projectFor(packagePath).libraries =
539
659
  linkedLibraries.size > 0 ? Object.fromEntries(linkedLibraries) : undefined
540
660
  signal.throwIfAborted()
541
- const stamp = sourceStamp(packagePath, module.root)
661
+ const stamp = sourceContentStamp(packagePath, module.root)
542
662
  const refused = readFailedCompilation(packagePath)
543
663
  if (refused?.stamp === stamp) {
544
664
  report('reusing recent link refusal')
@@ -563,7 +683,7 @@ export async function compileExpoSwiftModules(
563
683
  throw error
564
684
  // admission and interrupted builds say nothing about individual modules.
565
685
  // share their result briefly, including across dev server restarts.
566
- if (sourceStamp(packagePath, module.root) === stamp)
686
+ if (sourceContentStamp(packagePath, module.root) === stamp)
567
687
  failureRecords.set(packagePath, {
568
688
  stamp,
569
689
  diagnostic,
@@ -587,12 +707,7 @@ export async function compileExpoSwiftModules(
587
707
  report('linked build failed; isolating module diagnostics')
588
708
  linked = []
589
709
  for (const unit of candidates) {
590
- const packagePath = path.join(
591
- os.tmpdir(),
592
- 'peach-expo-swift',
593
- key,
594
- unit.moduleId,
595
- )
710
+ const packagePath = expoSwiftPackage(key, unit.moduleId)
596
711
  try {
597
712
  signal.throwIfAborted()
598
713
  report(`checking ${unit.moduleId}`)
@@ -630,13 +745,14 @@ export async function compileExpoSwiftModules(
630
745
  )
631
746
  throw error
632
747
  // a source error or a module peach does not provide holds until the
633
- // stamp changes; retrying either relinks every namespace and rewrites
634
- // the linked package, so no reload would ever reuse its artifact.
748
+ // stamp changes. the compiler also reports the same error at the
749
+ // build copy, so the app lines are enough to hold it. retrying
750
+ // either relinks every namespace and rewrites the linked package,
751
+ // so no reload would ever reuse its artifact.
635
752
  const missing = error instanceof SwiftMissingModulesError
636
753
  if (
637
- ((sourceErrors.length && sourceErrors.length === errors.length) ||
638
- missing) &&
639
- sourceStamp(packagePath, module.root) === stamps.get(unit)
754
+ (missing || sourceErrors.length > 0) &&
755
+ sourceContentStamp(packagePath, module.root) === stamps.get(unit)
640
756
  ) {
641
757
  failureRecords.set(packagePath, {
642
758
  stamp: stamps.get(unit)!,
@@ -651,7 +767,29 @@ export async function compileExpoSwiftModules(
651
767
  }
652
768
  // every namespace compiles alone, so the link itself is what failed.
653
769
  if (linked.length === candidates.length) throw linkError
654
- if (linked.length) artifact = await link(linked)
770
+ if (linked.length) {
771
+ try {
772
+ artifact = await link(linked)
773
+ } catch (error) {
774
+ signal.throwIfAborted()
775
+ if (!(error instanceof SwiftMissingModulesError)) throw error
776
+ // the merged sources import a module none of the pieces needed
777
+ // alone. drop the pieces that import it and link the rest; a
778
+ // miss that none of them import is still a failed link.
779
+ const missing = new Set(
780
+ [...error.message.matchAll(/^ (\S+)/gm)].map((match) => match[1]),
781
+ )
782
+ const dropped = linked.filter((unit) =>
783
+ Object.values(unit.files).some((source) =>
784
+ swiftImportNames(source).some((name) => missing.has(name)),
785
+ ),
786
+ )
787
+ if (dropped.length === 0) throw error
788
+ for (const unit of dropped) failures.set(unit.moduleId, error.message)
789
+ linked = linked.filter((unit) => !dropped.includes(unit))
790
+ if (linked.length) artifact = await link(linked)
791
+ }
792
+ }
655
793
  }
656
794
  }
657
795
  }
@@ -677,6 +815,7 @@ export async function compileExpoSwiftModules(
677
815
  report(
678
816
  `ready: ${result.modules.length} artifact, ${result.failures.length} unavailable modules`,
679
817
  )
818
+ if (!publishArtifact) preparedExpoSwift.set(preparedKey, result)
680
819
  return result
681
820
  } catch (error) {
682
821
  const failure: unknown = signal.aborted ? signal.reason : error
@@ -746,6 +885,7 @@ export async function handleExpoSwiftRequest(
746
885
  const controller = new AbortController()
747
886
  const promise = compileExpoSwiftModules(key, `http://${host}`, undefined, {
748
887
  signal: controller.signal,
888
+ timeoutMs: EXPO_SWIFT_REQUEST_TIMEOUT_MS,
749
889
  }).finally(() => {
750
890
  if (compiling.get(flightKey)?.controller === controller) compiling.delete(flightKey)
751
891
  })
@@ -758,9 +898,13 @@ export async function handleExpoSwiftRequest(
758
898
  const release = () => {
759
899
  if (released) return
760
900
  released = true
761
- if (--current.clients === 0) {
762
- current.controller.abort(new Error('Expo Swift preparation client disconnected'))
763
- }
901
+ if (--current.clients > 0) return
902
+ const flight = current
903
+ setTimeout(() => {
904
+ if (flight.clients > 0 || flight.controller.signal.aborted) return
905
+ if (compiling.get(flightKey)?.controller !== flight.controller) return
906
+ flight.controller.abort(new Error('Expo Swift preparation client disconnected'))
907
+ }, EXPO_SWIFT_CLIENT_GAP_MS)
764
908
  }
765
909
  res.once('close', release)
766
910
  try {
@@ -0,0 +1,78 @@
1
+ // an app with local Expo Swift modules only renders once the bridge compiles
2
+ // them, and a cold /__peach/expo-swift artifact compiles for minutes (a linked
3
+ // build plus per-namespace isolation builds in sequence) while a flow waits
4
+ // 120s for its first tree. so `peach open` GETs the same endpoint the engine
5
+ // will fetch and returns only once it answers; the bridge shares one compile
6
+ // per root, so the engine's own fetch then finds it done. a warm artifact
7
+ // answers in ~1s and an app without local modules answers null fast.
8
+ export const EXPO_SWIFT_READY_TIMEOUT_MS = 30 * 60_000
9
+ const EXPO_SWIFT_READY_PROGRESS_MS = 30_000
10
+
11
+ export type ExpoSwiftReadyOptions = {
12
+ timeoutMs?: number
13
+ progressMs?: number
14
+ signal?: AbortSignal
15
+ onProgress?: (elapsedMs: number) => void
16
+ }
17
+
18
+ export async function awaitExpoSwiftReady(
19
+ endpointUrl: string,
20
+ options: ExpoSwiftReadyOptions = {},
21
+ ): Promise<void> {
22
+ const timeoutMs = options.timeoutMs ?? EXPO_SWIFT_READY_TIMEOUT_MS
23
+ const progressMs = options.progressMs ?? EXPO_SWIFT_READY_PROGRESS_MS
24
+ const start = Date.now()
25
+ const report =
26
+ options.onProgress ??
27
+ ((elapsedMs: number) => {
28
+ console.log(
29
+ ` still waiting on ${endpointUrl} after ${Math.round(elapsedMs / 1000)}s — expo swift compile in progress`,
30
+ )
31
+ })
32
+ const progress = setInterval(() => report(Date.now() - start), progressMs)
33
+ try {
34
+ options.signal?.throwIfAborted()
35
+ const timeout = AbortSignal.timeout(timeoutMs)
36
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
37
+ let res: Response | undefined
38
+ while (!res) {
39
+ try {
40
+ // bun ends an unanswered fetch at six minutes. that close is the last
41
+ // client, so the bridge aborts the shared compile and the retry starts
42
+ // over. hold the socket for the caller's bound instead.
43
+ const init: RequestInit = { signal }
44
+ Object.assign(init, { timeout: false })
45
+ res = await fetch(endpointUrl, init)
46
+ } catch (error) {
47
+ if (options.signal?.aborted) throw error
48
+ if (timeout.aborted) {
49
+ throw new Error(
50
+ `timed out after ${timeoutMs}ms waiting for ${endpointUrl} — the expo swift compile did not answer within the bound`,
51
+ )
52
+ }
53
+ // a dropped socket still rejoins the same compile when the bridge has
54
+ // another client. retry only that case.
55
+ if (error instanceof Error && error.name === 'TimeoutError') continue
56
+ throw new Error(`could not reach ${endpointUrl}`, { cause: error })
57
+ }
58
+ }
59
+ if (!res.ok) {
60
+ const body = (await res.text()).trim().slice(0, 500)
61
+ throw new Error(`${endpointUrl} answered ${res.status}${body ? `: ${body}` : ''}`)
62
+ }
63
+ await res.text()
64
+ console.log(
65
+ ` expo swift modules ready in ${((Date.now() - start) / 1000).toFixed(1)}s`,
66
+ )
67
+ } finally {
68
+ clearInterval(progress)
69
+ }
70
+ }
71
+
72
+ // the expoSwift endpoint a built shell url carries, or null for an app with no
73
+ // project root
74
+ export async function awaitStampedExpoSwiftReady(shellUrl: string): Promise<void> {
75
+ const endpoint = new URL(shellUrl).searchParams.get('expoSwift')
76
+ if (!endpoint) return
77
+ await awaitExpoSwiftReady(endpoint)
78
+ }
@@ -9,8 +9,12 @@ export interface ExpoSwiftSourceUnit {
9
9
  namespace: string
10
10
  moduleId: string
11
11
  classes: string[]
12
+ // legacy bridge module registrations (react-native-bridge-modules.ts)
13
+ bridgeModules: string[]
12
14
  files: Record<string, string>
13
15
  frameworks: string[]
16
+ // the app target's Swift language mode; Expo packages compile in the default
17
+ swiftLanguageMode?: number
14
18
  }
15
19
 
16
20
  export interface ExpoSwiftCompiledModule {
@@ -23,6 +27,8 @@ export interface ExpoSwiftCompiledModule {
23
27
 
24
28
  export interface ExpoSwiftCompilationResult {
25
29
  namespaces: string[]
30
+ // the namespaces that are legacy bridge modules rather than Expo modules
31
+ bridgeModules?: string[]
26
32
  modules: ExpoSwiftCompiledModule[]
27
33
  failures: { namespace: string; diagnostic: string }[]
28
34
  }
@@ -47,6 +53,13 @@ export function isExpoSwiftCompilationResult(
47
53
  return false
48
54
  const remaining = new Set(value.namespaces)
49
55
  if (remaining.size !== value.namespaces.length) return false
56
+ // bundles published before legacy bridge modules carry no list
57
+ if (
58
+ value.bridgeModules !== undefined &&
59
+ (!strings(value.bridgeModules) ||
60
+ !value.bridgeModules.every((name) => remaining.has(name)))
61
+ )
62
+ return false
50
63
  for (const artifact of value.modules) {
51
64
  if (
52
65
  !record(artifact) ||
package/src/home-paths.ts CHANGED
@@ -185,6 +185,15 @@ export function cacheDir(): string {
185
185
  return path.join(rnxHomeDir(), 'cache')
186
186
  }
187
187
 
188
+ // swiftpm otherwise uses shared caches and configuration outside PEACH_HOME.
189
+ export function swiftPackageCacheOptions(): string[] {
190
+ const root = path.join(cacheDir(), 'swiftpm')
191
+ return ['cache', 'config', 'security'].flatMap((name) => [
192
+ `--${name}-path`,
193
+ path.join(root, name),
194
+ ])
195
+ }
196
+
188
197
  export function daemonLockfilePath(): string {
189
198
  return path.join(rnxHomeDir(), DAEMON_LOCKFILE)
190
199
  }
@@ -261,7 +270,10 @@ export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
261
270
  return next
262
271
  }
263
272
 
264
- const CURRENT_ONBOARDING_VERSION = 1
273
+ // version 2 made the native app the default simulator window. version 1 setup
274
+ // saved the browser without asking on a Mac lacking the app, so its saved
275
+ // driver is not read back and those machines run setup again.
276
+ const CURRENT_ONBOARDING_VERSION = 2
265
277
 
266
278
  export type SimulatorDriverPreference = DriverId
267
279
 
@@ -270,7 +282,9 @@ export function isOnboardingComplete(): boolean {
270
282
  }
271
283
 
272
284
  export function readSimulatorDriverPreference(): SimulatorDriverPreference | null {
273
- const driver = readSharedConfig().simulatorDriver
285
+ const config = readSharedConfig()
286
+ if (config.onboardingVersion !== CURRENT_ONBOARDING_VERSION) return null
287
+ const driver = config.simulatorDriver
274
288
  return driver === 'desktop' || driver === 'electron' || driver === 'playwright'
275
289
  ? driver
276
290
  : null
@@ -634,6 +648,38 @@ export function isDevBridgeLockfileFresh(
634
648
  }
635
649
  }
636
650
 
651
+ function hostBridgePortNumber(value: number | null | undefined): number | null {
652
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)
653
+ return null
654
+ return value
655
+ }
656
+
657
+ /** port a desktop host's page should register on.
658
+ *
659
+ * the launch url's wsPort pin wins. an ambient PEACH_BRIDGE_PORT wins only
660
+ * when this home has no fresh daemon, the daemon already bound that port, or
661
+ * a fresh dev-bridge lockfile in this home claims it. otherwise the fresh
662
+ * daemon lock wins, so a second PEACH_HOME is not pulled onto another world's
663
+ * bridge. the mac user script and the native seed use the same rule. */
664
+ export function desktopHostBridgePort(input: {
665
+ pinnedPort?: number | null
666
+ daemonPort?: number | null
667
+ envPort?: number | null
668
+ devBridgePorts?: readonly number[]
669
+ }): number | null {
670
+ const pinned = hostBridgePortNumber(input.pinnedPort)
671
+ if (pinned != null) return pinned
672
+ const daemon = hostBridgePortNumber(input.daemonPort)
673
+ const env = hostBridgePortNumber(input.envPort)
674
+ const claimed = new Set(
675
+ (input.devBridgePorts ?? [])
676
+ .map((port) => hostBridgePortNumber(port))
677
+ .filter((port): port is number => port != null),
678
+ )
679
+ if (env != null && (daemon == null || env === daemon || claimed.has(env))) return env
680
+ return daemon
681
+ }
682
+
637
683
  export function readLiveRuntimeVersions(): string[] {
638
684
  const versions = new Set<string>()
639
685
  const daemon = readDaemonLockfile()
@@ -543,10 +543,13 @@ export class SootSimBridgeHost {
543
543
  if (this.httpServer || this.wss) return this.effectivePort
544
544
 
545
545
  // an installed runtime can serve while the startup update runs. only a
546
- // machine without usable assets needs to wait for that download.
546
+ // machine without usable assets needs to wait for that download. only the
547
+ // daemon's runtime updater ever clears the flag, so a dev bridge (which
548
+ // serves the checkout build and runs no updater) never sets it.
547
549
  const bootRuntimeDir = this.getActiveRuntime().runtimeDir
548
550
  this.bootstrapping =
549
- !bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html'))
551
+ this.shouldWriteLockfile &&
552
+ (!bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html')))
550
553
 
551
554
  for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
552
555
  const candidate = this.preferredPort === 0 ? 0 : this.preferredPort + attempt