peachsim 0.1.1620 → 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 (185) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -6
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +39 -18
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +248 -156
  7. package/cli/cloud-client.ts +68 -57
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/box/search-command.ts +3 -2
  10. package/cli/commands/box.ts +10 -29
  11. package/cli/commands/control.ts +391 -92
  12. package/cli/commands/doctor.ts +17 -184
  13. package/cli/commands/flow.ts +79 -11
  14. package/cli/commands/inspect/core.ts +10 -5
  15. package/cli/commands/inspect/settling.ts +71 -22
  16. package/cli/commands/inspect/wait-ready.ts +12 -0
  17. package/cli/commands/inspect.ts +26 -3
  18. package/cli/commands/install-desktop.ts +1 -0
  19. package/cli/commands/maestro-compare.ts +105 -67
  20. package/cli/commands/maestro.ts +9 -1
  21. package/cli/commands/platform.ts +71 -36
  22. package/cli/commands/record.ts +21 -1
  23. package/cli/commands/screenshot.ts +23 -1
  24. package/cli/commands/serve.ts +7 -2
  25. package/cli/commands/setup.ts +19 -42
  26. package/cli/commands/swift.ts +5 -0
  27. package/cli/commands/upload.ts +51 -8
  28. package/cli/desktop-companion.ts +102 -17
  29. package/cli/dev-build-freshness.ts +37 -2
  30. package/cli/drivers/desktop.ts +14 -1
  31. package/cli/drivers/playwright-sim-host.ts +44 -8
  32. package/cli/drivers/playwright.ts +9 -8
  33. package/cli/drivers/types.ts +5 -0
  34. package/cli/maestro-accessibility.ts +53 -0
  35. package/cli/maestro-compare.ts +108 -12
  36. package/cli/main.ts +2 -1
  37. package/cli/outbound-endpoints.ts +35 -6
  38. package/cli/parent-pid.ts +0 -9
  39. package/cli/pause-aware-timeout.ts +102 -0
  40. package/cli/remote-simulators.ts +112 -0
  41. package/cli/remoteSimulatorContract.ts +21 -0
  42. package/cli/scene-video.ts +89 -0
  43. package/cli/ws-bridge.ts +23 -14
  44. package/detox/attempt-events.cjs +4 -0
  45. package/detox/expectations.ts +5 -1
  46. package/detox/index.ts +241 -181
  47. package/detox/motion-change.cjs +73 -0
  48. package/detox/motion-change.d.cts +21 -0
  49. package/detox/navigation-lifecycle.ts +22 -4
  50. package/dist-lib/agent-daemon-client.cjs +3 -2
  51. package/dist-lib/agent-events.cjs +1 -1
  52. package/dist-lib/attached-projects.cjs +1 -1
  53. package/dist-lib/auth/shared-session.cjs +3 -2
  54. package/dist-lib/backend-origin.cjs +1 -1
  55. package/dist-lib/beta.cjs +1 -1
  56. package/dist-lib/beta.mjs +1 -1
  57. package/dist-lib/bridge-constants.cjs +13 -1
  58. package/dist-lib/bridge-contract-input.cjs +1 -1
  59. package/dist-lib/bridge-contract-input.mjs +1 -1
  60. package/dist-lib/bridge-contract.cjs +14 -8
  61. package/dist-lib/bridge-contract.mjs +13 -7
  62. package/dist-lib/bundle-channel.cjs +10480 -7962
  63. package/dist-lib/capture-contract.cjs +1 -1
  64. package/dist-lib/capture-contract.mjs +1 -1
  65. package/dist-lib/cloud-contract.cjs +1 -1
  66. package/dist-lib/cloud-contract.mjs +1 -1
  67. package/dist-lib/cloud-test-args.cjs +1 -1
  68. package/dist-lib/cloud-test-args.mjs +1 -1
  69. package/dist-lib/cloud.cjs +3 -2
  70. package/dist-lib/cloud.mjs +3 -2
  71. package/dist-lib/config.cjs +10 -6
  72. package/dist-lib/config.mjs +10 -6
  73. package/dist-lib/detox/index.cjs +1854 -966
  74. package/dist-lib/dev-bundle-resolution.cjs +64 -24
  75. package/dist-lib/expo-swift-bundle.cjs +8715 -6271
  76. package/dist-lib/expo-swift-metadata.cjs +7 -4
  77. package/dist-lib/expo-swift-metadata.mjs +7 -4
  78. package/dist-lib/home-paths.cjs +32 -3
  79. package/dist-lib/host/bridge-host.cjs +10135 -7060
  80. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  81. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  82. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  83. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  84. package/dist-lib/host/websocket-proxy.cjs +1 -1
  85. package/dist-lib/index.cjs +1843 -408
  86. package/dist-lib/jump-to-source-babel.cjs +1 -1
  87. package/dist-lib/jump-to-source-native.cjs +1 -1
  88. package/dist-lib/kotlin-host.cjs +1 -1
  89. package/dist-lib/menu.cjs +3 -2
  90. package/dist-lib/menu.mjs +3 -2
  91. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  92. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  93. package/dist-lib/metro-production-bundle.cjs +5 -4
  94. package/dist-lib/metro-production-bundle.mjs +5 -4
  95. package/dist-lib/metro.cjs +1182 -418
  96. package/dist-lib/native-app-identity-project.cjs +782 -23
  97. package/dist-lib/native-app-identity-project.mjs +781 -23
  98. package/dist-lib/native-app-identity.cjs +276 -9
  99. package/dist-lib/native-app-identity.mjs +273 -8
  100. package/dist-lib/native-firebase-metadata.cjs +5 -4
  101. package/dist-lib/native-firebase-metadata.mjs +5 -4
  102. package/dist-lib/plist.cjs +1 -1
  103. package/dist-lib/plist.mjs +1 -1
  104. package/dist-lib/profiles.cjs +1 -1
  105. package/dist-lib/public-brand.cjs +3 -2
  106. package/dist-lib/react-native-host-modules.cjs +8 -1
  107. package/dist-lib/react-native-host-modules.mjs +8 -1
  108. package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
  109. package/dist-lib/sdk.cjs +115 -34
  110. package/dist-lib/sdk.mjs +115 -33
  111. package/dist-lib/skills.cjs +15274 -9904
  112. package/dist-lib/swift-compile.cjs +1165 -173
  113. package/dist-lib/swift-compile.mjs +1212 -203
  114. package/dist-lib/swift-package.cjs +12 -3
  115. package/dist-lib/swift-package.mjs +12 -3
  116. package/dist-lib/swift-submit.cjs +7 -1
  117. package/dist-lib/swift-submit.mjs +5 -1
  118. package/dist-lib/swift-toolchain.cjs +133 -18
  119. package/dist-lib/swift.cjs +1 -1
  120. package/dist-lib/vite-swift.cjs +2648 -862
  121. package/dist-lib/vite.cjs +70 -14
  122. package/dist-lib/worklet-executable.cjs +25 -2
  123. package/dist-lib/worklet-executable.mjs +25 -2
  124. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  125. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  126. package/package.json +2 -2
  127. package/scripts/dev-server-scanner.ts +35 -14
  128. package/skills/peach-perf/SKILL.md +11 -5
  129. package/skills/peach-setup/SKILL.md +16 -19
  130. package/src/admitted-mac-build.ts +34 -8
  131. package/src/android-app-identity.ts +146 -0
  132. package/src/android-autolinking.ts +75 -0
  133. package/src/android-string-value.ts +36 -0
  134. package/src/android-strings.ts +5 -36
  135. package/src/androidAutolinkingContract.ts +40 -0
  136. package/src/bridge-constants.ts +11 -0
  137. package/src/bridge-contract.ts +22 -34
  138. package/src/bundleGlobalFooter.ts +26 -7
  139. package/src/codex-client.ts +3 -2
  140. package/src/config.ts +15 -0
  141. package/src/desktop-host-delivery.ts +5 -1
  142. package/src/dev-bundle-resolution.ts +60 -26
  143. package/src/expo-kotlin-host.ts +6 -36
  144. package/src/expo-manifest-body.ts +13 -0
  145. package/src/expo-swift-bundle.ts +17 -12
  146. package/src/expo-swift-host.ts +640 -192
  147. package/src/expo-swift-ready.ts +78 -0
  148. package/src/expoSwiftContract.ts +19 -0
  149. package/src/home-paths.ts +48 -2
  150. package/src/host/bridge-host.ts +12 -57
  151. package/src/host/staged-host-files.ts +29 -2
  152. package/src/installed-package-versions.ts +54 -14
  153. package/src/launchMetadataContract.ts +24 -0
  154. package/src/metro-plugin.ts +37 -42
  155. package/src/native-app-identity-project.ts +142 -5
  156. package/src/native-app-identity.ts +176 -11
  157. package/src/native-firebase-config.ts +209 -13
  158. package/src/nitro-compile.ts +2 -5
  159. package/src/nitro-source.ts +147 -63
  160. package/src/process-group.ts +114 -61
  161. package/src/process-output.ts +0 -9
  162. package/src/public-brand.ts +1 -0
  163. package/src/react-native-bridge-modules.ts +416 -0
  164. package/src/react-native-host-modules.ts +7 -0
  165. package/src/sim-client.ts +6 -4
  166. package/src/swift-binary-modules.ts +2 -2
  167. package/src/swift-compile.ts +1007 -108
  168. package/src/swift-compiler-digest.ts +14 -0
  169. package/src/swift-distribution.lock.json +40 -12
  170. package/src/swift-distribution.ts +61 -9
  171. package/src/swift-host.ts +149 -56
  172. package/src/swift-icu.ts +14 -6
  173. package/src/swift-objc-sdk.ts +133 -10
  174. package/src/swift-objc-standin.ts +38 -3
  175. package/src/swift-package-manifest.ts +21 -6
  176. package/src/swift-package.ts +15 -1
  177. package/src/swift-packages.ts +119 -60
  178. package/src/swift-standin-emit.ts +57 -9
  179. package/src/swift-standins.ts +185 -25
  180. package/src/swift-submit.ts +10 -0
  181. package/src/swift-toolchain.ts +149 -23
  182. package/src/vite-plugin.ts +20 -20
  183. package/src/worklet-executable.ts +30 -3
  184. package/src/worklets-babel.ts +35 -14
  185. package/src/xcode-project.ts +10 -0
@@ -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
+ }
@@ -1,10 +1,20 @@
1
1
  import { insertBundleGlobalFooter, readBundleGlobalFooter } from './bundleGlobalFooter'
2
2
 
3
+ export interface ExpoSwiftPreparationOptions {
4
+ signal?: AbortSignal
5
+ timeoutMs?: number
6
+ }
7
+
3
8
  export interface ExpoSwiftSourceUnit {
4
9
  namespace: string
5
10
  moduleId: string
6
11
  classes: string[]
12
+ // legacy bridge module registrations (react-native-bridge-modules.ts)
13
+ bridgeModules: string[]
7
14
  files: Record<string, string>
15
+ frameworks: string[]
16
+ // the app target's Swift language mode; Expo packages compile in the default
17
+ swiftLanguageMode?: number
8
18
  }
9
19
 
10
20
  export interface ExpoSwiftCompiledModule {
@@ -17,6 +27,8 @@ export interface ExpoSwiftCompiledModule {
17
27
 
18
28
  export interface ExpoSwiftCompilationResult {
19
29
  namespaces: string[]
30
+ // the namespaces that are legacy bridge modules rather than Expo modules
31
+ bridgeModules?: string[]
20
32
  modules: ExpoSwiftCompiledModule[]
21
33
  failures: { namespace: string; diagnostic: string }[]
22
34
  }
@@ -41,6 +53,13 @@ export function isExpoSwiftCompilationResult(
41
53
  return false
42
54
  const remaining = new Set(value.namespaces)
43
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
44
63
  for (const artifact of value.modules) {
45
64
  if (
46
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()
@@ -474,9 +474,9 @@ export class SootSimBridgeHost {
474
474
  // can't immediately reclaim while the user gets oriented.
475
475
  private static USER_BOOT_LEASE_TTL_MS = 60_000
476
476
  private static SIM_RECONNECT_TTL_MS = 30_000
477
- // abandoned-tab GC. the ws heartbeat only reaps sims whose socket went
478
- // dead — but a background tab from a prior `peach open` / QA / test run
479
- // keeps its socket alive (the page still pongs) forever, so `peach list`
477
+ // abandoned-tab GC. transport keepalive only reaps broken connections,
478
+ // but a background tab from a prior `peach open` / QA / test run
479
+ // keeps its socket alive forever, so `peach list`
480
480
  // accretes dozens of zombie sims that all time out on every command (QA
481
481
  // F21-3, carried F19-2). reap a sim only when it is provably nobody's:
482
482
  // not primary, not user-focused, no CLI attached, no active lease, and no
@@ -496,15 +496,6 @@ export class SootSimBridgeHost {
496
496
  private startedAt = 0
497
497
  private heartbeatTimer: NodeJS.Timeout | null = null
498
498
  private devHeartbeatTimer: NodeJS.Timeout | null = null
499
- // ws-level heartbeat: sims that hang up uncleanly (page navigated, network
500
- // dropped, sim crashed) leave their server-side WebSocket sitting "open"
501
- // forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's
502
- // ping was never answered, terminate(). that fires 'close' which runs the
503
- // sim-cleanup path and stops `peach list` from showing 8 zombie
504
- // sims that all time out on every command.
505
- private wsHeartbeatTimer: NodeJS.Timeout | null = null
506
- private wsIsAlive = new WeakMap<WebSocket, boolean>()
507
- private static WS_HEARTBEAT_INTERVAL_MS = 30_000
508
499
  private runtimeUpdateTimer: NodeJS.Timeout | null = null
509
500
  private runtimeUpdateInFlight: Promise<void> | null = null
510
501
  private readonly handleServerScan = createServerScanHandler({
@@ -552,10 +543,13 @@ export class SootSimBridgeHost {
552
543
  if (this.httpServer || this.wss) return this.effectivePort
553
544
 
554
545
  // an installed runtime can serve while the startup update runs. only a
555
- // 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.
556
549
  const bootRuntimeDir = this.getActiveRuntime().runtimeDir
557
550
  this.bootstrapping =
558
- !bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html'))
551
+ this.shouldWriteLockfile &&
552
+ (!bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html')))
559
553
 
560
554
  for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
561
555
  const candidate = this.preferredPort === 0 ? 0 : this.preferredPort + attempt
@@ -670,14 +664,10 @@ export class SootSimBridgeHost {
670
664
  // 'close' fires right after and runs the cleanup below.
671
665
  ws.on('error', () => {})
672
666
 
673
- // ws-level heartbeat. pong responses come for free from the ws
674
- // protocol — we just need to track them so the heartbeat sweep
675
- // (started in afterBind) can terminate connections that stop
676
- // answering. fresh connections start alive.
677
- this.wsIsAlive.set(ws, true)
678
- ws.on('pong', () => {
679
- this.wsIsAlive.set(ws, true)
680
- })
667
+ // the kernel answers keepalive during SIGSTOP. application pings would
668
+ // mistake a governor-paused render for a dead connection and discard it.
669
+ // command deadlines still bound an unresponsive application.
670
+ req.socket.setKeepAlive(true, 30_000)
681
671
 
682
672
  // register the socket with the agent host so session-status pushes
683
673
  // reach it even before it subscribes to any specific session, and so
@@ -1211,12 +1201,6 @@ export class SootSimBridgeHost {
1211
1201
  ) as unknown as NodeJS.Timeout
1212
1202
  this.cliIdleTimer.unref()
1213
1203
 
1214
- this.wsHeartbeatTimer = setInterval(
1215
- () => this.sweepDeadWebSockets(),
1216
- SootSimBridgeHost.WS_HEARTBEAT_INTERVAL_MS,
1217
- ) as unknown as NodeJS.Timeout
1218
- this.wsHeartbeatTimer.unref()
1219
-
1220
1204
  if (this.shouldWriteLockfile) {
1221
1205
  try {
1222
1206
  ensureRnxHome()
@@ -2131,31 +2115,6 @@ export class SootSimBridgeHost {
2131
2115
  return queue.tickets.length > queue.limit
2132
2116
  }
2133
2117
 
2134
- // ping every connected ws; if the previous round's ping went unanswered,
2135
- // terminate the socket so 'close' fires and the sim cleanup path
2136
- // runs. matches the recommended ws-library heartbeat pattern.
2137
- private sweepDeadWebSockets() {
2138
- if (!this.wss) return
2139
- for (const ws of this.wss.clients) {
2140
- if (ws.readyState !== WebSocket.OPEN) continue
2141
- const alive = this.wsIsAlive.get(ws)
2142
- if (alive === false) {
2143
- try {
2144
- ws.terminate()
2145
- } catch {}
2146
- continue
2147
- }
2148
- this.wsIsAlive.set(ws, false)
2149
- try {
2150
- ws.ping()
2151
- } catch {
2152
- try {
2153
- ws.terminate()
2154
- } catch {}
2155
- }
2156
- }
2157
- }
2158
-
2159
2118
  private closeSimSocketFromHost(ws: WebSocket, initiatedBy?: string) {
2160
2119
  if (ws.readyState !== WebSocket.OPEN) return
2161
2120
  if (initiatedBy) this.hostCloseCauseBySocket.set(ws, initiatedBy)
@@ -2302,10 +2261,6 @@ export class SootSimBridgeHost {
2302
2261
  clearInterval(this.devHeartbeatTimer)
2303
2262
  this.devHeartbeatTimer = null
2304
2263
  }
2305
- if (this.wsHeartbeatTimer) {
2306
- clearInterval(this.wsHeartbeatTimer)
2307
- this.wsHeartbeatTimer = null
2308
- }
2309
2264
  if (this.runtimeUpdateTimer) {
2310
2265
  clearInterval(this.runtimeUpdateTimer)
2311
2266
  this.runtimeUpdateTimer = null
@@ -2,6 +2,10 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { cacheDir } from '../home-paths'
5
+ import {
6
+ LAUNCH_METADATA_KEYS,
7
+ LAUNCH_METADATA_QUERY_PARAM,
8
+ } from '../launchMetadataContract'
5
9
  import type { IncomingMessage, ServerResponse } from 'node:http'
6
10
 
7
11
  // local files a sim loads from the bridge: native-linked app fonts and
@@ -22,10 +26,33 @@ function contentType(extension: string | undefined): string {
22
26
  : 'application/octet-stream'
23
27
  }
24
28
 
29
+ export function stageHostLaunchMetadata(url: URL, origin: string): string {
30
+ const metadata = new URLSearchParams()
31
+ for (const key of LAUNCH_METADATA_KEYS) {
32
+ for (const value of url.searchParams.getAll(key)) metadata.append(key, value)
33
+ url.searchParams.delete(key)
34
+ }
35
+ if (metadata.size > 0) {
36
+ const reference = stageHostBytes(
37
+ new TextEncoder().encode(metadata.toString()),
38
+ origin,
39
+ )
40
+ url.searchParams.set(LAUNCH_METADATA_QUERY_PARAM, reference)
41
+ }
42
+ return url.toString()
43
+ }
44
+
25
45
  export function stageHostFile(filePath: string, origin: string): string {
26
- const bytes = fs.readFileSync(filePath)
46
+ return stageHostBytes(fs.readFileSync(filePath), origin, path.extname(filePath))
47
+ }
48
+
49
+ export function stageHostBytes(
50
+ bytes: Uint8Array,
51
+ origin: string,
52
+ extension = '',
53
+ ): string {
27
54
  const digest = createHash('sha256').update(bytes).digest('hex')
28
- const extension = path.extname(filePath).toLowerCase()
55
+ extension = extension.toLowerCase()
29
56
  const name = STAGED_NAME.test(digest + extension) ? digest + extension : digest
30
57
  const directory = stagedDir()
31
58
  const destination = path.join(directory, name)
@@ -6,11 +6,12 @@
6
6
  import fs from 'fs'
7
7
  import path from 'path'
8
8
  import { LAZY_STUB_PATTERNS } from '@peach/compat/lazy-stub-patterns'
9
+ import { NATIVE_RUNTIME_PACKAGE_NAMES } from '@peach/compat/native-package-names'
9
10
  import { COMPAT_STUBS } from '@peach/compat/stub-manifest'
10
11
  import { extractPackageFromPath } from '../../peach-engine/src/stub-pattern-matcher'
11
12
  import type { PackageVersions } from './packageVersionsContract'
12
13
 
13
- function readInstalledVersion(projectRoot: string, name: string): string | null {
14
+ function readInstalledPackage(projectRoot: string, name: string) {
14
15
  let dir = projectRoot
15
16
  for (;;) {
16
17
  let manifest: unknown
@@ -27,11 +28,9 @@ function readInstalledVersion(projectRoot: string, name: string): string | null
27
28
  dir = parent
28
29
  continue
29
30
  }
30
- const version =
31
- typeof manifest === 'object' && manifest !== null
32
- ? Reflect.get(manifest, 'version')
33
- : undefined
34
- return typeof version === 'string' && version.length > 0 ? version : null
31
+ return typeof manifest === 'object' && manifest !== null
32
+ ? { root: path.join(dir, 'node_modules', name), manifest }
33
+ : null
35
34
  }
36
35
  }
37
36
 
@@ -46,21 +45,62 @@ export function readInstalledPackageVersions(
46
45
  }
47
46
  const versions: PackageVersions = {}
48
47
  for (const name of [...names].sort()) {
49
- const version = readInstalledVersion(projectRoot, name)
50
- if (version) versions[name] = version
48
+ const installed = readInstalledPackage(projectRoot, name)
49
+ const version = installed && Reflect.get(installed.manifest, 'version')
50
+ if (typeof version === 'string' && version.length > 0) versions[name] = version
51
51
  }
52
52
  return versions
53
53
  }
54
54
 
55
55
  // plain expo and one servers need no peach plugin. the launcher reads their
56
- // installed native packages from the same root that autolinking uses.
56
+ // installed native packages from the app's dependency graph. an unrelated
57
+ // package hoisted beside a workspace app is resolvable but is not linked.
57
58
  export function readInstalledNativePackageVersions(projectRoot: string): PackageVersions {
59
+ const appRoot = fs.realpathSync(projectRoot)
60
+ const nativeNames = new Set<string>()
61
+ for (const specifier of [
62
+ 'react-native',
63
+ ...NATIVE_RUNTIME_PACKAGE_NAMES,
64
+ ...LAZY_STUB_PATTERNS,
65
+ ...Object.values(COMPAT_STUBS).map((entry) => entry.specifier),
66
+ ]) {
67
+ const name = extractPackageFromPath(`node_modules/${specifier}`)
68
+ if (name) nativeNames.add(name)
69
+ }
70
+ const manifest: unknown = JSON.parse(
71
+ fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
72
+ )
73
+ if (typeof manifest !== 'object' || manifest === null) {
74
+ throw new Error('app package.json must contain an object')
75
+ }
76
+ const pending = [{ root: appRoot, manifest }]
77
+ const visited = new Set<string>()
78
+ const needed = new Set<string>()
79
+ for (let next = pending.pop(); next; next = pending.pop()) {
80
+ const root = fs.realpathSync(next.root)
81
+ if (visited.has(root)) continue
82
+ visited.add(root)
83
+ const fields = ['dependencies', 'optionalDependencies', 'peerDependencies']
84
+ if (root === appRoot) fields.push('devDependencies')
85
+ const peers = Reflect.get(next.manifest, 'peerDependenciesMeta')
86
+ for (const field of fields) {
87
+ const dependencies = Reflect.get(next.manifest, field)
88
+ if (typeof dependencies !== 'object' || dependencies === null) continue
89
+ for (const name of Object.keys(dependencies)) {
90
+ if (field === 'peerDependencies' && typeof peers === 'object' && peers !== null) {
91
+ const peer = Reflect.get(peers, name)
92
+ if (typeof peer === 'object' && peer !== null && Reflect.get(peer, 'optional'))
93
+ continue
94
+ }
95
+ const installed = readInstalledPackage(root, name)
96
+ if (!installed) continue
97
+ if (nativeNames.has(name)) needed.add(name)
98
+ pending.push(installed)
99
+ }
100
+ }
101
+ }
58
102
  return readInstalledPackageVersions(
59
103
  projectRoot,
60
- [
61
- 'react-native',
62
- ...LAZY_STUB_PATTERNS,
63
- ...Object.values(COMPAT_STUBS).map((entry) => entry.specifier),
64
- ].map((specifier) => `node_modules/${specifier}`),
104
+ [...needed].map((specifier) => `node_modules/${specifier}`),
65
105
  )
66
106
  }
@@ -0,0 +1,24 @@
1
+ // app resources and native configuration travel as a body, never in an http
2
+ // request target. routing and display options remain on the navigable url.
3
+ export const LAUNCH_METADATA_QUERY_PARAM = 'launchMetadata'
4
+
5
+ export const LAUNCH_METADATA_KEYS: readonly string[] = [
6
+ 'appFonts',
7
+ 'appSplash',
8
+ 'appCatalog',
9
+ 'nativeAppIdentity',
10
+ 'nativeFirebase',
11
+ 'appManifest',
12
+ 'packageVersions',
13
+ 'dotEnv',
14
+ 'env',
15
+ ]
16
+
17
+ export function readLaunchMetadata(wire: string): URLSearchParams {
18
+ const metadata = new URLSearchParams(wire)
19
+ const keys: ReadonlySet<string> = new Set(LAUNCH_METADATA_KEYS)
20
+ for (const key of metadata.keys()) {
21
+ if (!keys.has(key)) throw new Error(`invalid launch metadata field: ${key}`)
22
+ }
23
+ return metadata
24
+ }
@@ -12,7 +12,13 @@ import fs from 'fs'
12
12
  import { createRequire } from 'module'
13
13
  import { randomUUID } from 'node:crypto'
14
14
  import path from 'path'
15
+ import {
16
+ getRuntimePlatformAdapter,
17
+ isRuntimePlatform,
18
+ } from '../../peach-engine/src/platform/adapter'
15
19
  import { resolveAppProject } from '../cli/app-project'
20
+ import { collectAndroidAutolinkedPackages } from './android-autolinking'
21
+ import { insertAndroidAutolinkingFooter } from './androidAutolinkingContract'
16
22
  import { tagRnxBundleForPlugin } from './dev-server-open'
17
23
  import {
18
24
  handleReplacementModuleRequest,
@@ -26,12 +32,11 @@ import {
26
32
  toPeachProductionBundleUrl,
27
33
  type RNXMetroModuleIdentityMetadata,
28
34
  } from './metro-production-bundle'
35
+ import { insertNativeAppIdentityFooter } from './native-app-identity'
29
36
  import {
30
- canExtractNativeAppIdentity,
31
- extractNativeAppIdentityFromFiles,
32
- insertNativeAppIdentityFooter,
33
- } from './native-app-identity'
34
- import { readNativeAppIdentityFromProject } from './native-app-identity-project'
37
+ readNativeAppIdentityFromProject,
38
+ readNativeAppIdentityFromSources,
39
+ } from './native-app-identity-project'
35
40
  import { readNativeFirebaseConfig } from './native-firebase-config'
36
41
  import {
37
42
  insertNativeFirebaseFooter,
@@ -279,7 +284,7 @@ async function appendRNXModulePaths(
279
284
 
280
285
  export function withPeach<T extends Record<string, unknown>>(
281
286
  config: T,
282
- options: { iosTarget?: string } = {},
287
+ options: { iosTarget?: string; androidMergedManifest?: string } = {},
283
288
  ): T {
284
289
  const cfgProjectRoot = (config as MetroConfigWithServer).projectRoot || process.cwd()
285
290
  const cfgWatchFolders = (config as MetroConfigWithServer).watchFolders ?? []
@@ -318,7 +323,14 @@ export function withPeach<T extends Record<string, unknown>>(
318
323
  (defaultSerializer ||= createMetroDefaultSerializer(
319
324
  serializerOptions.asyncRequireModulePath,
320
325
  ))
321
- const result = await serializer(entryPoint, preModules, graph, serializerOptions)
326
+ const bundlePlatform = graph.transformOptions?.platform
327
+ const [result, androidAutolinkedPackages] = await Promise.all([
328
+ serializer(entryPoint, preModules, graph, serializerOptions),
329
+ isRuntimePlatform(bundlePlatform) &&
330
+ getRuntimePlatformAdapter(bundlePlatform).isAndroid
331
+ ? collectAndroidAutolinkedPackages(cfgProjectRoot)
332
+ : Promise.resolve(null),
333
+ ])
322
334
  const staticJson =
323
335
  typeof result === 'string' &&
324
336
  (serializerOptions.serializerOptions?.output === 'static' ||
@@ -347,46 +359,29 @@ export function withPeach<T extends Record<string, unknown>>(
347
359
  'empty-module.js',
348
360
  ),
349
361
  )
350
- const iosRoot = path.join(cfgProjectRoot, 'ios')
351
- const bundlePlatform = graph.transformOptions?.platform
352
- if (canExtractNativeAppIdentity(bundlePlatform) && fs.existsSync(iosRoot)) {
353
- let identity = readNativeAppIdentityFromProject(
362
+ if (androidAutolinkedPackages !== null) {
363
+ annotated = await mapMetroSerializerCode(annotated, (code) =>
364
+ insertAndroidAutolinkingFooter(code, androidAutolinkedPackages),
365
+ )
366
+ }
367
+ const identity =
368
+ readNativeAppIdentityFromProject(
369
+ cfgProjectRoot,
370
+ bundlePlatform,
371
+ parsePlistSource,
372
+ options.iosTarget,
373
+ options.androidMergedManifest,
374
+ ) ??
375
+ readNativeAppIdentityFromSources(
354
376
  cfgProjectRoot,
355
377
  bundlePlatform,
356
378
  parsePlistSource,
357
379
  options.iosTarget,
358
380
  )
359
- if (!identity) {
360
- const files: Record<string, string> = {}
361
- for (const entry of fs.readdirSync(iosRoot, { withFileTypes: true })) {
362
- if (entry.name === 'Pods') continue
363
- if (entry.isDirectory()) {
364
- const names = entry.name.endsWith('.xcodeproj')
365
- ? ['project.pbxproj']
366
- : ['Info.plist', 'AppDelegate.swift', 'AppDelegate.mm', 'AppDelegate.m']
367
- for (const name of names) {
368
- const relative = `ios/${entry.name}/${name}`
369
- const file = path.join(cfgProjectRoot, relative)
370
- if (fs.existsSync(file)) files[relative] = fs.readFileSync(file, 'utf8')
371
- }
372
- } else if (/AppDelegate\.(?:swift|mm|m)$/.test(entry.name)) {
373
- const relative = `ios/${entry.name}`
374
- const file = path.join(cfgProjectRoot, relative)
375
- if (fs.existsSync(file)) files[relative] = fs.readFileSync(file, 'utf8')
376
- }
377
- }
378
- identity = extractNativeAppIdentityFromFiles(
379
- files,
380
- bundlePlatform,
381
- parsePlistSource,
382
- options.iosTarget,
383
- )
384
- }
385
- if (identity) {
386
- annotated = await mapMetroSerializerCode(annotated, (code) =>
387
- insertNativeAppIdentityFooter(code, identity),
388
- )
389
- }
381
+ if (identity) {
382
+ annotated = await mapMetroSerializerCode(annotated, (code) =>
383
+ insertNativeAppIdentityFooter(code, identity),
384
+ )
390
385
  }
391
386
  if (
392
387
  [...(graph.dependencies?.values() ?? [])].some((module) =>