peachsim 0.1.1619 → 0.1.1625

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 (140) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +222 -175
  4. package/cli/cloud-client.ts +81 -48
  5. package/cli/commands/box/search-command.ts +3 -2
  6. package/cli/commands/box.ts +10 -29
  7. package/cli/commands/control.ts +108 -43
  8. package/cli/commands/inspect/settling.ts +57 -20
  9. package/cli/commands/maestro-compare.ts +11 -32
  10. package/cli/commands/platform.ts +74 -17
  11. package/cli/commands/record.ts +20 -15
  12. package/cli/commands/upload.ts +5 -1
  13. package/cli/dev-build-freshness.ts +37 -2
  14. package/cli/drivers/playwright-sim-host.ts +18 -6
  15. package/cli/maestro-accessibility.ts +53 -0
  16. package/cli/maestro-compare.ts +24 -0
  17. package/cli/outbound-endpoints.ts +27 -8
  18. package/cli/parent-pid.ts +0 -9
  19. package/cli/remote-simulators.ts +112 -0
  20. package/cli/remoteSimulatorContract.ts +21 -0
  21. package/detox/expectations.ts +5 -1
  22. package/detox/index.ts +133 -98
  23. package/detox/motion-change.cjs +37 -0
  24. package/detox/motion-change.d.cts +16 -0
  25. package/detox/navigation-lifecycle.ts +22 -4
  26. package/dist-lib/agent-daemon-client.cjs +3 -2
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +3 -2
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +1528 -937
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +4 -3
  46. package/dist-lib/cloud.mjs +4 -3
  47. package/dist-lib/config.cjs +5 -4
  48. package/dist-lib/config.mjs +5 -4
  49. package/dist-lib/detox/index.cjs +1021 -364
  50. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  51. package/dist-lib/expo-swift-bundle.cjs +1351 -804
  52. package/dist-lib/expo-swift-metadata.cjs +5 -4
  53. package/dist-lib/expo-swift-metadata.mjs +5 -4
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +2475 -1343
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +1269 -300
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +3 -2
  66. package/dist-lib/menu.mjs +3 -2
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +5 -4
  70. package/dist-lib/metro-production-bundle.mjs +5 -4
  71. package/dist-lib/metro.cjs +806 -398
  72. package/dist-lib/native-app-identity-project.cjs +729 -21
  73. package/dist-lib/native-app-identity-project.mjs +728 -21
  74. package/dist-lib/native-app-identity.cjs +232 -9
  75. package/dist-lib/native-app-identity.mjs +230 -8
  76. package/dist-lib/native-firebase-metadata.cjs +5 -4
  77. package/dist-lib/native-firebase-metadata.mjs +5 -4
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +3 -2
  82. package/dist-lib/react-native-host-modules.cjs +4 -1
  83. package/dist-lib/react-native-host-modules.mjs +4 -1
  84. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  85. package/dist-lib/sdk.cjs +89 -24
  86. package/dist-lib/sdk.mjs +89 -24
  87. package/dist-lib/skills.cjs +4471 -2394
  88. package/dist-lib/swift-compile.cjs +336 -145
  89. package/dist-lib/swift-compile.mjs +350 -150
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +1 -1
  93. package/dist-lib/swift-submit.mjs +1 -1
  94. package/dist-lib/swift-toolchain.cjs +1 -1
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +891 -557
  97. package/dist-lib/vite.cjs +68 -14
  98. package/dist-lib/worklet-executable.cjs +25 -2
  99. package/dist-lib/worklet-executable.mjs +25 -2
  100. package/package.json +2 -2
  101. package/scripts/dev-server-scanner.ts +33 -13
  102. package/src/admitted-mac-build.ts +34 -8
  103. package/src/android-app-identity.ts +146 -0
  104. package/src/android-autolinking.ts +75 -0
  105. package/src/android-string-value.ts +36 -0
  106. package/src/android-strings.ts +5 -36
  107. package/src/androidAutolinkingContract.ts +40 -0
  108. package/src/bundleGlobalFooter.ts +26 -7
  109. package/src/cloud.ts +2 -2
  110. package/src/codex-client.ts +3 -2
  111. package/src/dev-bundle-resolution.ts +58 -25
  112. package/src/expo-kotlin-host.ts +6 -36
  113. package/src/expo-swift-bundle.ts +17 -12
  114. package/src/expo-swift-host.ts +477 -173
  115. package/src/expoSwiftContract.ts +6 -0
  116. package/src/host/bridge-host.ts +7 -55
  117. package/src/host/staged-host-files.ts +29 -2
  118. package/src/installed-package-versions.ts +54 -14
  119. package/src/launchMetadataContract.ts +24 -0
  120. package/src/metro-plugin.ts +37 -42
  121. package/src/native-app-identity-project.ts +118 -3
  122. package/src/native-app-identity.ts +103 -11
  123. package/src/nitro-compile.ts +2 -5
  124. package/src/nitro-source.ts +147 -63
  125. package/src/process-group.ts +90 -60
  126. package/src/process-output.ts +0 -9
  127. package/src/public-brand.ts +1 -0
  128. package/src/react-native-host-modules.ts +3 -0
  129. package/src/sim-client.ts +6 -4
  130. package/src/swift-compile.ts +185 -31
  131. package/src/swift-distribution.lock.json +37 -11
  132. package/src/swift-distribution.ts +61 -9
  133. package/src/swift-host.ts +58 -27
  134. package/src/swift-objc-standin.ts +13 -0
  135. package/src/swift-standin-emit.ts +1 -1
  136. package/src/swift-standins.ts +17 -5
  137. package/src/vite-plugin.ts +8 -19
  138. package/src/worklet-executable.ts +30 -3
  139. package/src/worklets-babel.ts +35 -14
  140. package/src/xcode-project.ts +10 -0
@@ -46,8 +46,8 @@ import {
46
46
  RNX_METRO_MODULE_IDENTITY_VERSION,
47
47
  type RNXMetroModuleIdentityMetadata,
48
48
  } from '../src/metro-production-bundle.ts'
49
+ import { readPackageVersionsFooter } from '../src/packageVersionsContract'
49
50
  import { rnxPublicBrand } from '../src/public-brand.ts'
50
- import { PEACH_SIMULATOR_PATH } from '../src/simulator-route'
51
51
  import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts'
52
52
  import {
53
53
  createCloudSession,
@@ -274,6 +274,26 @@ async function loadBundleSource(bundleInput: string, command: string): Promise<s
274
274
  return source
275
275
  }
276
276
 
277
+ // a metro bundle carries react native's Platform module for exactly the
278
+ // platform it was built for, and that module's OS constant survives
279
+ // minification. a bundle naming only the other platform would boot against
280
+ // native modules it was never built for, so it refuses before a host exists.
281
+ export function assertBundleBuiltFor(
282
+ bytes: Buffer,
283
+ platform: RemotePlatform,
284
+ command: string,
285
+ ): void {
286
+ const source = bytes.toString('latin1')
287
+ const ios = /\bOS:\s*["']ios["']/.test(source)
288
+ const android = /\bOS:\s*["']android["']/.test(source)
289
+ if (ios === android) return
290
+ const built = ios ? 'ios' : 'android'
291
+ if (built === platform) return
292
+ throw new Error(
293
+ `${command} needs a bundle built for ${platform}; this one was built for ${built}. Rebuild with \`react-native bundle --platform ${platform}\` or run \`peach ${built} --remote\`.`,
294
+ )
295
+ }
296
+
277
297
  // `react-native bundle --assets-dest` dest paths are not always the JS
278
298
  // descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
279
299
  // that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
@@ -746,6 +766,11 @@ export async function produceCloudBoxArtifact(
746
766
  assetsDirectory ? 'require-assets' : 'allow-placeholders',
747
767
  assetsDirectory,
748
768
  )
769
+ if (!readPackageVersionsFooter(prepared.source)?.['react-native']) {
770
+ throw new Error(
771
+ 'remote simulator bundle is missing __peachPackageVersions with the installed react-native version. Wrap your project Metro config with withPeach from peachsim/metro, then rebuild the bundle before running peach ios --remote or peach android --remote.',
772
+ )
773
+ }
749
774
  const warnings: string[] = []
750
775
  if (prepared.search.unconfirmed && prepared.dest) {
751
776
  warnings.push(
@@ -1603,9 +1628,11 @@ export function createCloudBridgeForParsed(
1603
1628
  : new CloudBridge(session)
1604
1629
  }
1605
1630
 
1606
- // stops the box, which ends the simulator it holds. an account api key stops
1607
- // with its own authority; a session login stops with the login, falling back
1608
- // to the box token the session already holds.
1631
+ // deletes the box, which ends the simulator it holds. a cloud session box is
1632
+ // the prebuilt bundle `peach ios --remote` created, so it holds nothing but that
1633
+ // bundle: stopping it would only keep its name taken and its storage held. an
1634
+ // account api key deletes with its own authority; a session login deletes with
1635
+ // the login, falling back to the box token the session already holds.
1609
1636
  export async function closeCloudBox(session: CloudSession): Promise<void> {
1610
1637
  if (session.service !== 'box') {
1611
1638
  if (!session.boxId || !session.boxToken) return
@@ -1632,7 +1659,7 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
1632
1659
  let response: Response
1633
1660
  try {
1634
1661
  response = await fetch(
1635
- `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`,
1662
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/delete`,
1636
1663
  {
1637
1664
  method: 'POST',
1638
1665
  headers: { authorization: authHeader },
@@ -1651,10 +1678,13 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
1651
1678
  }
1652
1679
  }
1653
1680
 
1654
- export interface BoxDisplayGrant {
1655
- url: string
1656
- authToken: string
1657
- expectedPeer: unknown
1681
+ // the box's page reported that the app threw while booting, so no simulator is
1682
+ // coming and the box should end rather than bill.
1683
+ export class BoxBootFailedError extends Error {
1684
+ constructor(boxId: string, detail: string) {
1685
+ super(`box ${boxId} could not boot the app: ${detail}`)
1686
+ this.name = 'BoxBootFailedError'
1687
+ }
1658
1688
  }
1659
1689
 
1660
1690
  // how long `peach remote` waits for a fresh box's page to attach its simulator
@@ -1663,27 +1693,48 @@ export interface BoxDisplayGrant {
1663
1693
  const BOX_DISPLAY_GRANT_WAIT_MS = 180_000
1664
1694
  const BOX_DISPLAY_GRANT_RETRY_MS = 2_000
1665
1695
 
1666
- function readBoxDisplayGrant(value: unknown): BoxDisplayGrant {
1667
- if (!isRecord(value) || !isRecord(value.grant)) {
1668
- throw new Error('box display-grant returned an invalid grant')
1669
- }
1670
- const grant = value.grant
1671
- if (
1672
- typeof grant.url !== 'string' ||
1673
- !grant.url ||
1674
- typeof grant.authToken !== 'string' ||
1675
- !grant.authToken ||
1676
- !isRecord(grant.expectedPeer)
1677
- ) {
1678
- throw new Error('box display-grant returned an invalid grant')
1696
+ // waits for a fresh box's page to load its app or report that the app threw
1697
+ // while booting, reading the same runtime receipts the page records. a boot
1698
+ // error throws; a slow boot that reports neither within the wait is not an
1699
+ // error, so the caller still gets its watch url.
1700
+ export async function awaitBoxBoot(options: {
1701
+ apiOrigin: string
1702
+ boxId: string
1703
+ boxToken: string
1704
+ }): Promise<void> {
1705
+ const started = Date.now()
1706
+ while (Date.now() - started < BOX_DISPLAY_GRANT_WAIT_MS) {
1707
+ const response = await fetch(
1708
+ `${options.apiOrigin}/v1/boxes/${encodeURIComponent(options.boxId)}/lifecycle`,
1709
+ { headers: { authorization: `Bearer ${options.boxToken}` } },
1710
+ )
1711
+ const text = await response.text()
1712
+ if (!response.ok) throw responseError(response.status, text)
1713
+ const status: unknown = JSON.parse(text)
1714
+ const generation =
1715
+ isRecord(status) && isRecord(status.browser)
1716
+ ? status.browser.browserGeneration
1717
+ : null
1718
+ const receipts =
1719
+ isRecord(status) && Array.isArray(status.runtimeReceipts)
1720
+ ? status.runtimeReceipts
1721
+ : []
1722
+ for (const receipt of receipts.toReversed()) {
1723
+ if (!isRecord(receipt) || receipt.browserGeneration !== generation) continue
1724
+ if (receipt.kind === 'boot-failed' && typeof receipt.error === 'string') {
1725
+ throw new BoxBootFailedError(options.boxId, receipt.error)
1726
+ }
1727
+ if (receipt.kind === 'loaded' || receipt.kind === 'painted') return
1728
+ }
1729
+ await new Promise((resolve) => setTimeout(resolve, BOX_DISPLAY_GRANT_RETRY_MS))
1679
1730
  }
1680
- return { url: grant.url, authToken: grant.authToken, expectedPeer: grant.expectedPeer }
1681
1731
  }
1682
1732
 
1683
- // mints a watch-only display grant from the box and builds the bare shell
1684
- // watch url for it: the same /simulator#remoteEngine fragment the nano service
1685
- // mints, pointed at the box's display socket instead. the box token stays in
1686
- // this process; only the grant travels in the fragment.
1733
+ // mints a watch-only display grant from the box and returns the watch url the
1734
+ // box service built for it. the service puts it on the deploy that serves the
1735
+ // box page, so the viewer runs the same engine build, and display wire, as the
1736
+ // page hosting the simulator. the box token stays in this process; only the
1737
+ // grant travels, in the url's fragment.
1687
1738
  export async function requestBoxWatchUrl(options: {
1688
1739
  apiOrigin: string
1689
1740
  boxId: string
@@ -1730,27 +1781,9 @@ export async function requestBoxWatchUrl(options: {
1730
1781
  } catch {
1731
1782
  throw new Error('box display-grant returned unreadable JSON')
1732
1783
  }
1733
- const grant = readBoxDisplayGrant(parsed)
1734
- const watch = new URL(PEACH_SIMULATOR_PATH, rnxPublicBrand.origin)
1735
- // the shell boots its stage at its own default device, while the box page
1736
- // hosts its simulator at the grant's device profile. without ?device= the
1737
- // tenant refuses the host hello on dimensions and paints nothing — the
1738
- // same param SimPage and BoxHostPage already set on their stage urls.
1739
- if (
1740
- isRecord(grant.expectedPeer) &&
1741
- typeof grant.expectedPeer.deviceProfile === 'string' &&
1742
- grant.expectedPeer.deviceProfile
1743
- ) {
1744
- watch.searchParams.set('device', grant.expectedPeer.deviceProfile)
1745
- }
1746
- watch.hash = `remoteEngine=${encodeURIComponent(
1747
- JSON.stringify({
1748
- url: grant.url,
1749
- authToken: grant.authToken,
1750
- expectedPeer: grant.expectedPeer,
1751
- }),
1752
- )}`
1753
- return watch.toString()
1784
+ if (!isRecord(parsed) || typeof parsed.watchUrl !== 'string' || !parsed.watchUrl)
1785
+ throw new Error('box display-grant returned no watch url')
1786
+ return parsed.watchUrl
1754
1787
  }
1755
1788
  }
1756
1789
 
@@ -34,6 +34,7 @@
34
34
  import { spawn } from 'node:child_process'
35
35
  import { defineCommand, type CustomCommand } from '@peach/box/shell'
36
36
  import { PROJECT_MOUNT } from '@peach/box/shell'
37
+ import { terminateProcessGroup } from '../../../src/process-group'
37
38
 
38
39
  // generous next to a 414ms p95: this bounds a wedged child, it does not cut off
39
40
  // a large search.
@@ -63,9 +64,9 @@ function runGitGrep(args: string[], cwd: string) {
63
64
  clearTimeout(timer)
64
65
  resolve({ stdout, stderr, exitCode })
65
66
  }
66
- const timer = setTimeout(() => {
67
+ const timer = setTimeout(async () => {
67
68
  try {
68
- child.kill('SIGKILL')
69
+ await terminateProcessGroup(child, 'SIGKILL')
69
70
  } catch {}
70
71
  stderr += `rg: search timed out after ${SEARCH_DEADLINE_MS}ms\n`
71
72
  finish(2)
@@ -52,9 +52,6 @@ interface BoxCommandOptions {
52
52
  verbose?: boolean
53
53
  }
54
54
 
55
- // no default. a cloud box service has to be deployed before one can exist, and
56
- // defaulting to an origin that answers nothing would advertise a capability
57
- // that always fails.
58
55
  const ENDPOINT_ENV = 'PEACH_BOX_ENDPOINT'
59
56
  const API_ORIGIN_ENV = 'PEACH_API_ORIGIN'
60
57
  const BOX_TEMPLATES = new BoxTemplateRegistry([
@@ -212,23 +209,16 @@ async function runCloudBoxUse(args: string[]): Promise<number> {
212
209
  }
213
210
  }
214
211
 
215
- function requireEndpoint(args: string[], label: string): string | null {
216
- return cloudBoxEndpoint(args, `box ${label}`)
217
- }
218
-
219
212
  /**
220
- * the cloud box service this command talks to, or null with the reason
221
- * printed. exported because `peach box send` creates a box too, and a second
222
- * spelling of where boxes live is a second place to configure.
213
+ * the cloud box service for every CLI command that selects a service. existing
214
+ * box references carry their own endpoint from the account service.
223
215
  */
224
- export function cloudBoxEndpoint(args: string[], command: string): string | null {
225
- const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim()
226
- if (endpoint) return endpoint
227
- console.error(
228
- ` ${rnxPublicBrand.commandName} ${command}: no cloud box service to talk to.\n` +
229
- ` set ${ENDPOINT_ENV} or pass --endpoint=<url>.`,
216
+ export function cloudBoxEndpoint(args: string[]): string {
217
+ return (
218
+ flag(args, 'endpoint')?.trim() ||
219
+ process.env[ENDPOINT_ENV]?.trim() ||
220
+ rnxPublicBrand.boxOrigin
230
221
  )
231
- return null
232
222
  }
233
223
 
234
224
  /** the api origin a cloud box bills against. */
@@ -278,8 +268,7 @@ async function runBoxSend(args: string[], opts: BoxCommandOptions): Promise<numb
278
268
  async function runCloudBoxStop(args: string[], purge: boolean): Promise<number> {
279
269
  const peach = rnxPublicBrand.commandName
280
270
  const label = purge ? 'delete' : 'stop'
281
- const endpoint = requireEndpoint(args, label)
282
- if (!endpoint) return 1
271
+ const endpoint = cloudBoxEndpoint(args)
283
272
  const name = boxTarget(args, label)
284
273
  if (!name) return 1
285
274
  const { auth, header } = authHeaderOrExit(`box ${label}`)
@@ -321,7 +310,7 @@ async function runCloudBoxList(args: string[]): Promise<number> {
321
310
  const accountId = cloudAccountIdOrExit(auth)
322
311
  const client = createBoxClient(
323
312
  createCloudBoxProvider({
324
- endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '',
313
+ endpoint: cloudBoxEndpoint(args),
325
314
  authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
326
315
  }),
327
316
  )
@@ -589,15 +578,7 @@ async function runCloudBoxCreate(
589
578
  ): Promise<number> {
590
579
  const peach = rnxPublicBrand.commandName
591
580
  const json = args.includes('--json')
592
- const endpoint = flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim()
593
- if (!endpoint) {
594
- console.error(
595
- ` ${peach} box create: no cloud box service to create a box in.\n` +
596
- ` set ${ENDPOINT_ENV} or pass --endpoint=<url>. \`${peach} box\` runs the\n` +
597
- ` same shell locally over this checkout.`,
598
- )
599
- return 1
600
- }
581
+ const endpoint = cloudBoxEndpoint(args)
601
582
 
602
583
  // --bundle IS the template choice: the box runs an app that is already
603
584
  // built, so naming a second template would ask for two different boxes.
@@ -45,8 +45,14 @@ import {
45
45
  runtimesDir,
46
46
  } from '../../src/home-paths'
47
47
  import { projectLoopbackUrl } from '../../src/host/loopback-ports'
48
- import { stageHostFile } from '../../src/host/staged-host-files'
48
+ import {
49
+ isStagedHostFileRequest,
50
+ stageHostBytes,
51
+ stageHostFile,
52
+ stageHostLaunchMetadata,
53
+ } from '../../src/host/staged-host-files'
49
54
  import { readInstalledNativePackageVersions } from '../../src/installed-package-versions'
55
+ import { LAUNCH_METADATA_QUERY_PARAM } from '../../src/launchMetadataContract'
50
56
  import { detectBundleModeFromText } from '../../src/metro-production-bundle'
51
57
  import {
52
58
  canExtractNativeAppIdentity,
@@ -275,12 +281,15 @@ async function connectBridgeForOpen(
275
281
  const lock = readDaemonLockfile()
276
282
  assertExplicitBridgeLock(preferredPort, explicitPort, lock)
277
283
  const preferredWorld = worldForBridgePort(preferredPort)
278
- const handshake = await bridgeHandshake(preferredPort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS)
284
+ const handshakeTimeoutMs = LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS * flowTimeoutScale()
285
+ const handshake = await bridgeHandshake(preferredPort, handshakeTimeoutMs)
279
286
  if ((explicitPort || preferredWorld !== null) && handshake === 'open') {
280
287
  return preferredPort
281
288
  }
282
289
  if (explicitPort && preferredWorld !== null) {
283
- throw new Error(unreachableBridgeMessage(preferredPort, handshake))
290
+ throw new Error(
291
+ unreachableBridgeMessage(preferredPort, handshake, handshakeTimeoutMs),
292
+ )
284
293
  }
285
294
  if (
286
295
  !explicitPort &&
@@ -288,7 +297,7 @@ async function connectBridgeForOpen(
288
297
  isDaemonLockfileFresh(lock) &&
289
298
  (lock.bridgePort === preferredPort
290
299
  ? handshake === 'open'
291
- : await canConnectToBridge(lock.bridgePort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS))
300
+ : await canConnectToBridge(lock.bridgePort, handshakeTimeoutMs))
292
301
  ) {
293
302
  return lock.bridgePort
294
303
  }
@@ -452,12 +461,16 @@ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandsha
452
461
  })
453
462
  }
454
463
 
455
- function unreachableBridgeMessage(port: number, handshake: BridgeHandshake): string {
464
+ function unreachableBridgeMessage(
465
+ port: number,
466
+ handshake: BridgeHandshake,
467
+ timeoutMs: number,
468
+ ): string {
456
469
  if (handshake === 'timeout') {
457
470
  const cores = Math.max(1, os.cpus().length)
458
471
  const load = os.loadavg()[0]
459
472
  if (load >= cores) {
460
- return `peach open: requested bridge port ${port} did not finish its handshake within 2s while load average was ${load.toFixed(1)} on ${cores} cores.`
473
+ return `peach open: requested bridge port ${port} did not finish its handshake within ${timeoutMs / 1000}s while load average was ${load.toFixed(1)} on ${cores} cores.`
461
474
  }
462
475
  }
463
476
  return `peach open: requested bridge port ${port} is not reachable.`
@@ -585,11 +598,12 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
585
598
  const releaseListenerScan = pinSharedListenerScan()
586
599
  try {
587
600
  for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
601
+ let resolutionTarget = normalized
588
602
  if (registeredPort && registeredPort > 0) {
589
603
  const discovered = await probePort(registeredPort)
590
604
  // a provisional bundle URL is enough to LIST the server but not to open
591
- // it; fall through to resolveConnectionInput, which asks the manifest
592
- // again with a budget that fits a real dev server.
605
+ // it; resolveConnectionInput joins the scanner's pending manifest with
606
+ // the full startup budget instead of starting the same work again.
593
607
  if (discovered && !discovered.bundleUrlProvisional) {
594
608
  return {
595
609
  bundleUrl: projectLoopbackUrl(discovered.bundleUrl),
@@ -598,10 +612,15 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
598
612
  projectName: discovered.projectName,
599
613
  }
600
614
  }
615
+ // keep the scanner's answering address and join its manifest request.
616
+ // probing both loopback families again duplicates cold config work.
617
+ if (discovered?.bundleUrlProvisional && /^\d+$/.test(normalized)) {
618
+ resolutionTarget = new URL(discovered.bundleUrl).origin
619
+ }
601
620
  }
602
621
 
603
622
  try {
604
- const resolved = await resolveConnectionInput(normalized)
623
+ const resolved = await resolveConnectionInput(resolutionTarget)
605
624
  return { ...resolved, bundleUrl: projectLoopbackUrl(resolved.bundleUrl) }
606
625
  } catch (error) {
607
626
  if (
@@ -850,7 +869,7 @@ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
850
869
  )
851
870
  }
852
871
 
853
- // stamp the engine page url with `?appFonts=<url[::family]>,...` so the
872
+ // stamp a bounded catalog reference on the engine page url so the
854
873
  // engine's app-font-loader registers native config-only fonts at boot. fonts
855
874
  // loaded by JS already flow through normal runtime fetch capture; this covers
856
875
  // UIAppFonts-style assets that Metro can serve but the guest bundle never
@@ -874,7 +893,11 @@ function stampAppFonts(
874
893
  nativeAssetOrigin,
875
894
  })
876
895
  if (specs.length === 0) return urlStr
877
- url.searchParams.set('appFonts', encodeAppFontsWire(specs))
896
+ const catalogUrl = stageHostBytes(
897
+ new TextEncoder().encode(encodeAppFontsWire(specs)),
898
+ nativeAssetOrigin,
899
+ )
900
+ url.searchParams.set('appFonts', `@${catalogUrl}`)
878
901
  return url.toString()
879
902
  } catch {
880
903
  return urlStr
@@ -1059,45 +1082,74 @@ export interface BuildShellUrlOptions {
1059
1082
  bridgePort?: number
1060
1083
  }
1061
1084
 
1062
- function readProjectNativeFirebaseConfig(
1085
+ type ProjectExpoConfigRead = { config: Record<string, unknown> } | { error: string }
1086
+
1087
+ function errorMessage(error: unknown): string {
1088
+ return error instanceof Error ? error.message : String(error)
1089
+ }
1090
+
1091
+ function readProjectExpoConfig(
1063
1092
  project: ResolvedAppProject,
1064
- ): Promise<Record<string, unknown> | null> {
1093
+ ): Promise<ProjectExpoConfigRead | null> {
1065
1094
  const require = createRequire(path.join(project.appDir, 'package.json'))
1066
1095
  try {
1067
1096
  require.resolve('@expo/config')
1068
1097
  } catch {
1069
1098
  return Promise.resolve(null)
1070
1099
  }
1071
- return readExpoConfigAsync(project.appDir)
1072
- .then((config) =>
1073
- readNativeFirebaseConfig(project.appDir, undefined, undefined, config),
1074
- )
1075
- .catch((error: unknown) => ({
1076
- error: error instanceof Error ? error.message : String(error),
1077
- }))
1100
+ return readExpoConfigAsync(project.appDir).then(
1101
+ (config) => ({ config }),
1102
+ (error: unknown) => ({ error: errorMessage(error) }),
1103
+ )
1078
1104
  }
1079
1105
 
1080
1106
  // evaluating the Expo config spawns node, so `peach open` starts the read as
1081
1107
  // soon as the project resolves and the shell url it builds takes that read. a
1082
1108
  // later build reads the config again.
1083
- const startedNativeFirebaseConfigs = new Map<
1084
- string,
1085
- Promise<Record<string, unknown> | null>
1086
- >()
1087
-
1088
- function startNativeFirebaseConfigRead(project: ResolvedAppProject | null) {
1089
- if (project)
1090
- startedNativeFirebaseConfigs.set(
1091
- project.appDir,
1092
- readProjectNativeFirebaseConfig(project),
1093
- )
1109
+ const startedExpoConfigReads = new Map<string, Promise<ProjectExpoConfigRead | null>>()
1110
+
1111
+ function startExpoConfigRead(project: ResolvedAppProject | null) {
1112
+ if (project) startedExpoConfigReads.set(project.appDir, readProjectExpoConfig(project))
1094
1113
  }
1095
1114
 
1096
- function takeNativeFirebaseConfig(project: ResolvedAppProject | null | undefined) {
1115
+ function takeExpoConfigRead(project: ResolvedAppProject | null | undefined) {
1097
1116
  if (!project) return Promise.resolve(null)
1098
- const started = startedNativeFirebaseConfigs.get(project.appDir)
1099
- startedNativeFirebaseConfigs.delete(project.appDir)
1100
- return started ?? readProjectNativeFirebaseConfig(project)
1117
+ const started = startedExpoConfigReads.get(project.appDir)
1118
+ startedExpoConfigReads.delete(project.appDir)
1119
+ return started ?? readProjectExpoConfig(project)
1120
+ }
1121
+
1122
+ function nativeFirebaseFromExpoConfig(
1123
+ project: ResolvedAppProject,
1124
+ read: ProjectExpoConfigRead,
1125
+ ): Record<string, unknown> {
1126
+ if ('error' in read) return read
1127
+ try {
1128
+ return readNativeFirebaseConfig(project.appDir, undefined, undefined, read.config)
1129
+ } catch (error) {
1130
+ return { error: errorMessage(error) }
1131
+ }
1132
+ }
1133
+
1134
+ // a built bundle staged on the bridge has no packager to serve its manifest.
1135
+ // the built binary embeds its evaluated app config (expo's `app.config`
1136
+ // resource, and the Info.plist prebuild writes from it), so the bundle's
1137
+ // manifest is that config, staged beside it
1138
+ function stageBuiltBundleManifest(
1139
+ bundleUrl: string,
1140
+ config: Record<string, unknown>,
1141
+ ): string | null {
1142
+ const bundle = new URL(bundleUrl)
1143
+ if (!isStagedHostFileRequest(bundle.pathname) || Object.keys(config).length === 0)
1144
+ return null
1145
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'peach-app-manifest-'))
1146
+ try {
1147
+ const file = path.join(directory, 'manifest.json')
1148
+ fs.writeFileSync(file, JSON.stringify({ extra: { expoClient: config } }))
1149
+ return stageHostFile(file, bundle.origin)
1150
+ } finally {
1151
+ fs.rmSync(directory, { recursive: true, force: true })
1152
+ }
1101
1153
  }
1102
1154
 
1103
1155
  // a plain Metro carries no identity footer or header, so the page carries the
@@ -1200,6 +1252,7 @@ export async function buildShellUrl(
1200
1252
  // own `@expo/config` (a dependency-free recorder) leaves it to the Peach
1201
1253
  // Metro plugin's endpoint.
1202
1254
  expoStamped.searchParams.delete('nativeFirebase')
1255
+ expoStamped.searchParams.delete('appManifest')
1203
1256
  expoStamped.searchParams.delete('packageVersions')
1204
1257
  if (project && expoStamped.searchParams.get('bundle')) {
1205
1258
  expoStamped.searchParams.set(
@@ -1207,9 +1260,19 @@ export async function buildShellUrl(
1207
1260
  JSON.stringify(readInstalledNativePackageVersions(project.appDir)),
1208
1261
  )
1209
1262
  }
1210
- const nativeFirebase = await takeNativeFirebaseConfig(project)
1211
- if (nativeFirebase && expoStamped.searchParams.get('bundle'))
1212
- expoStamped.searchParams.set('nativeFirebase', JSON.stringify(nativeFirebase))
1263
+ const expoConfigRead = await takeExpoConfigRead(project)
1264
+ const stampedBundle = expoStamped.searchParams.get('bundle')
1265
+ if (project && expoConfigRead && stampedBundle) {
1266
+ expoStamped.searchParams.set(
1267
+ 'nativeFirebase',
1268
+ JSON.stringify(nativeFirebaseFromExpoConfig(project, expoConfigRead)),
1269
+ )
1270
+ const manifest =
1271
+ 'config' in expoConfigRead
1272
+ ? stageBuiltBundleManifest(stampedBundle, expoConfigRead.config)
1273
+ : null
1274
+ if (manifest) expoStamped.searchParams.set('appManifest', manifest)
1275
+ }
1213
1276
  if (opts.bundleOriginOverride)
1214
1277
  expoStamped.searchParams.set('bundleOriginOverride', opts.bundleOriginOverride)
1215
1278
  if (opts.hostDriver) expoStamped.searchParams.set('peachHostDriver', opts.hostDriver)
@@ -1235,7 +1298,7 @@ export async function buildShellUrl(
1235
1298
  !opts.env?.length &&
1236
1299
  !opts.dotEnv
1237
1300
  )
1238
- return expoStamped.toString()
1301
+ return stageHostLaunchMetadata(expoStamped, `http://localhost:${port.port}`)
1239
1302
  const url = expoStamped
1240
1303
  if (opts.dotEnv) url.searchParams.set('dotEnv', JSON.stringify(opts.dotEnv))
1241
1304
  if (opts.device) url.searchParams.set('device', opts.device)
@@ -1246,7 +1309,7 @@ export async function buildShellUrl(
1246
1309
  if (opts.proof) url.searchParams.set('proof', '1')
1247
1310
  if (opts.ephemeral) url.searchParams.set('ephemeral', '1')
1248
1311
  for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
1249
- return url.toString()
1312
+ return stageHostLaunchMetadata(url, `http://localhost:${port.port}`)
1250
1313
  }
1251
1314
 
1252
1315
  function deriveCurrentSimBaseUrl(
@@ -1278,6 +1341,7 @@ function deriveCurrentSimBaseUrl(
1278
1341
  url.searchParams.delete('nativeFirebase')
1279
1342
  url.searchParams.delete('packageVersions')
1280
1343
  url.searchParams.delete('nativeAppIdentity')
1344
+ url.searchParams.delete(LAUNCH_METADATA_QUERY_PARAM)
1281
1345
  url.searchParams.delete('expoSwift')
1282
1346
  url.searchParams.delete('expoKotlin')
1283
1347
  return url.toString()
@@ -1836,7 +1900,9 @@ export async function runOpenCommand(
1836
1900
  peachExit(1)
1837
1901
  }
1838
1902
 
1839
- if (isAppDeepLinkTarget(target)) {
1903
+ // an absolute path to a built bundle is a file, not an app path
1904
+ const prebuiltBundle = prebuiltBundleFile(target)
1905
+ if (!prebuiltBundle && isAppDeepLinkTarget(target)) {
1840
1906
  if (openInNewSim) {
1841
1907
  console.error(
1842
1908
  ' peach open: deep links target the current app; remove --new, --profile, or --ephemeral',
@@ -1879,7 +1945,6 @@ export async function runOpenCommand(
1879
1945
  return
1880
1946
  }
1881
1947
 
1882
- const prebuiltBundle = prebuiltBundleFile(target)
1883
1948
  // a bundle file gets its url once the bridge that serves it is settled
1884
1949
  let resolvedBundle =
1885
1950
  !prebuiltBundle && shouldResolveBundleTargetInCli(target)
@@ -1895,7 +1960,7 @@ export async function runOpenCommand(
1895
1960
  resolvedBundle?.bundleUrl,
1896
1961
  projectRoot ?? (prebuiltBundle ? appDirAbove(prebuiltBundle) : undefined),
1897
1962
  )
1898
- startNativeFirebaseConfigRead(project)
1963
+ startExpoConfigRead(project)
1899
1964
  // playwright is the browser-sim launcher. it owns an isolated profile and
1900
1965
  // can reap exactly its own browser tree. desktop opens the native host and
1901
1966
  // electron the Electron companion. none opens the user's browser profile.