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
@@ -3,6 +3,7 @@ import fs from 'node:fs'
3
3
  import http from 'node:http'
4
4
  import https from 'node:https'
5
5
  import { createRequire } from 'node:module'
6
+ import net from 'node:net'
6
7
  import os from 'node:os'
7
8
  import path from 'node:path'
8
9
  import { bundleUrlForPlatform } from 'peach-engine/bundle-platform-url'
@@ -27,26 +28,40 @@ import {
27
28
  readPeachConfigFromSearchParams,
28
29
  type PeachConfig,
29
30
  } from '../../src/config'
31
+ import {
32
+ desktopHostDelivery,
33
+ desktopHostPlatform,
34
+ headlessHostCommand,
35
+ } from '../../src/desktop-host-delivery'
30
36
  import {
31
37
  resolveConnectionInput,
32
38
  type ResolvedDevBundle,
33
39
  } from '../../src/dev-bundle-resolution'
34
40
  import { readProjectDotEnv } from '../../src/dot-env'
41
+ import { awaitStampedExpoSwiftReady } from '../../src/expo-swift-ready'
35
42
  import { flowTimeoutScale } from '../../src/flow-timeout-scale'
36
43
  import {
37
44
  isDaemonLockfileFresh,
38
45
  isDevBridgeLockfileFresh,
46
+ PEACH_HOME_ENV,
39
47
  readActiveRuntime,
40
48
  readDaemonLockfile,
41
49
  readDevBridgeLockfiles,
42
50
  readLiveRuntimeVersions,
43
51
  readSimulatorDriverPreference,
52
+ rnxHomeDir,
44
53
  runtimeDir,
45
54
  runtimesDir,
46
55
  } from '../../src/home-paths'
47
56
  import { projectLoopbackUrl } from '../../src/host/loopback-ports'
48
- import { stageHostFile } from '../../src/host/staged-host-files'
57
+ import {
58
+ isStagedHostFileRequest,
59
+ stageHostBytes,
60
+ stageHostFile,
61
+ stageHostLaunchMetadata,
62
+ } from '../../src/host/staged-host-files'
49
63
  import { readInstalledNativePackageVersions } from '../../src/installed-package-versions'
64
+ import { LAUNCH_METADATA_QUERY_PARAM } from '../../src/launchMetadataContract'
50
65
  import { detectBundleModeFromText } from '../../src/metro-production-bundle'
51
66
  import {
52
67
  canExtractNativeAppIdentity,
@@ -96,10 +111,11 @@ import {
96
111
  readCurrentSimId,
97
112
  saveCurrentSimId,
98
113
  } from '../current-sim'
114
+ import { findDesktopCompanion, nativeDesktopIsDefault } from '../desktop-companion'
99
115
  import { validateDeviceOption } from '../device-option'
100
116
  import { getDriver } from '../drivers/registry'
101
117
  import { isTeamMachineSession, openUrl as openUrlInBrowser } from '../open-url'
102
- import { diagnosticsAllowedForCurrentProcess } from '../privacy'
118
+ import { diagnosticsAllowedForCurrentProcess, isCi } from '../privacy'
103
119
  import { rethrowIfExit, peachExit } from '../run-rnx'
104
120
  import { collectRuntimeDiagnostics } from '../runtime-diagnostics'
105
121
  import { rnxSelfInvocation } from '../self-invocation'
@@ -117,8 +133,8 @@ import {
117
133
  } from '../ws-bridge'
118
134
  import { readNativeProjectRenderer } from './detect'
119
135
  import { inspectDescribe, inspectReadySnapshot, waitReadyReason } from './inspect/core'
120
- import { isAgentEnv } from './inspect/env'
121
136
  import { waitForSimReady } from './inspect/shared'
137
+ import { runInstallDesktop } from './install-desktop'
122
138
  import { hasInstalledPeachSkills } from './skills'
123
139
  import type { DriverId, DriverLaunchResult } from '../drivers/types'
124
140
 
@@ -146,8 +162,13 @@ interface DriverConnectWaitOptions {
146
162
  const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
147
163
  const DRIVER_CONNECT_INTERVAL_MS = 100
148
164
  const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
149
- const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
150
- const OPEN_BRIDGE_RUNTIME_TIMEOUT_MS = 120_000
165
+ // ceiling for a bridge that is alive but never becomes ready. a bridge that
166
+ // exits fails at once and a slow one is judged by its own lockfile and
167
+ // handshake, so this is not the expected startup time.
168
+ const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 60_000
169
+ const OPEN_BRIDGE_RUNTIME_TIMEOUT_MS = 600_000
170
+ const OPEN_BRIDGE_HEALTH_TIMEOUT_MS = 10_000
171
+ const OPEN_BRIDGE_SILENCE_MS = 30_000
151
172
  // a closed port refuses at once, so this window only matters when something
152
173
  // listens: a live bridge on a loaded machine can take longer than 250 ms to
153
174
  // finish the handshake, and missing it spawned a second bridge or reported a
@@ -191,9 +212,19 @@ function isDriverId(value: string): value is DriverId {
191
212
  return value === 'desktop' || value === 'electron' || value === 'playwright'
192
213
  }
193
214
 
194
- function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean {
215
+ function simUsesDriver(
216
+ sim: BridgeSimInfo,
217
+ driverId: DriverId,
218
+ renderer?: 'chromium' | 'native',
219
+ hostExecutable?: string | null,
220
+ ): boolean {
195
221
  if (driverId === 'playwright') {
196
- return sim.meta?.sootsimHostDriver === 'playwright'
222
+ return (
223
+ sim.meta?.sootsimHostDriver === 'playwright' &&
224
+ (!renderer || sim.meta?.peachRenderer === renderer) &&
225
+ (hostExecutable === undefined ||
226
+ (hostExecutable !== null && sim.meta?.peachHostExecutable === hostExecutable))
227
+ )
197
228
  }
198
229
  if (driverId === 'desktop') {
199
230
  return Boolean(
@@ -230,11 +261,21 @@ function normalizeKnownTarget(target: string): string {
230
261
  return target.trim()
231
262
  }
232
263
 
264
+ type BridgeOpenGate = {
265
+ newShell: boolean
266
+ simId?: string
267
+ simIdSource?: 'flag' | 'saved' | 'none'
268
+ driverId?: DriverId
269
+ renderer?: 'chromium' | 'native'
270
+ pinnedHostExecutable?: string | null
271
+ }
272
+
233
273
  export async function ensureBridgeForOpen(
234
274
  preferredPort: number,
235
275
  explicitPort = false,
236
276
  resources?: OpenCommandResources,
237
277
  runtimeSource: 'bridge' | 'external' = 'bridge',
278
+ gate: BridgeOpenGate = { newShell: true },
238
279
  ): Promise<number> {
239
280
  const port = await connectBridgeForOpen(
240
281
  preferredPort,
@@ -243,22 +284,107 @@ export async function ensureBridgeForOpen(
243
284
  runtimeSource,
244
285
  )
245
286
  // a bridge accepts connections while its first runtime download is still
246
- // running, and every shell page answers 503 until that finishes.
287
+ // running, and a shell page served by that bridge answers 503 until the
288
+ // runtime directory exists. a registered page is not that runtime: --new
289
+ // would hand the driver the 503 url. skip the wait only when this open
290
+ // will reuse a page whose origin already answers. a new shell waits until
291
+ // the bridge install settles or the shell itself answers, and does not
292
+ // start a second install over the one already in flight.
247
293
  if (runtimeSource === 'bridge') {
248
- await waitForBridgeRuntime(port)
249
- await ensureRuntimeForOpen()
294
+ const reusingReadyOrigin =
295
+ !gate.newShell && (await provenReadyReuseOrigin(port, gate))
296
+ if (!reusingReadyOrigin) {
297
+ const readiness = await waitForBridgeRuntime(port)
298
+ if (readiness === 'settled') await ensureRuntimeForOpen()
299
+ }
250
300
  }
251
301
  return port
252
302
  }
253
303
 
254
- async function waitForBridgeRuntime(port: number) {
304
+ async function provenReadyReuseOrigin(
305
+ port: number,
306
+ gate: BridgeOpenGate,
307
+ ): Promise<boolean> {
308
+ if (!gate.driverId || !gate.simId || gate.simIdSource === 'none' || !gate.simIdSource) {
309
+ return false
310
+ }
311
+ const bridge = createBridge(port, { commandTimeoutMs: 2_000 })
312
+ try {
313
+ const sims = await bridge.listSims()
314
+ const sim = sims.find((candidate) => candidate.id === gate.simId)
315
+ if (!sim?.url || sim.readyState !== 'open') return false
316
+ if (
317
+ !simUsesDriver(
318
+ sim,
319
+ gate.driverId,
320
+ gate.renderer,
321
+ gate.pinnedHostExecutable ?? undefined,
322
+ )
323
+ ) {
324
+ return false
325
+ }
326
+ return await originServesShell(sim.url)
327
+ } catch {
328
+ return false
329
+ } finally {
330
+ bridge.close()
331
+ }
332
+ }
333
+
334
+ async function originServesShell(pageOrOrigin: string): Promise<boolean> {
335
+ try {
336
+ const origin = new URL(pageOrOrigin).origin
337
+ const res = await fetch(origin, { signal: AbortSignal.timeout(2_000) })
338
+ return res.status !== 503
339
+ } catch {
340
+ return false
341
+ }
342
+ }
343
+
344
+ // the install runs inside the bridge and reports completion through
345
+ // /healthz. a bridge that answers is installing, so only a bridge that is gone
346
+ // (connection refused) or silent for a while fails the wait; the ceiling only
347
+ // bounds an install that never finishes.
348
+ function portRefusesConnections(port: number): Promise<boolean> {
349
+ return new Promise((resolve) => {
350
+ let refused = false
351
+ const socket = net.connect(port, '127.0.0.1')
352
+ socket.setTimeout(OPEN_BRIDGE_HEALTH_TIMEOUT_MS, () => socket.destroy())
353
+ socket.once('connect', () => socket.destroy())
354
+ socket.once('close', () => resolve(refused))
355
+ socket.once('error', (err: NodeJS.ErrnoException) => {
356
+ refused = err.code === 'ECONNREFUSED'
357
+ })
358
+ })
359
+ }
360
+
361
+ async function waitForBridgeRuntime(port: number): Promise<'settled' | 'serving'> {
255
362
  const deadline = Date.now() + OPEN_BRIDGE_RUNTIME_TIMEOUT_MS
363
+ let lastAnswerAt = Date.now()
256
364
  while (Date.now() < deadline) {
257
365
  try {
258
- const res = await fetch(`http://127.0.0.1:${port}/healthz`)
366
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, {
367
+ signal: AbortSignal.timeout(OPEN_BRIDGE_HEALTH_TIMEOUT_MS),
368
+ })
259
369
  const health = (await res.json()) as { bootstrapping?: boolean }
260
- if (!health.bootstrapping) return
261
- } catch {}
370
+ if (!health.bootstrapping) return 'settled'
371
+ lastAnswerAt = Date.now()
372
+ // the install flag can stay true after the shell directory is already
373
+ // being served. a new page can use that origin, and installing again
374
+ // would race the download still in flight.
375
+ if (await originServesShell(`http://127.0.0.1:${port}/`)) return 'serving'
376
+ } catch {
377
+ if (await portRefusesConnections(port)) {
378
+ throw new Error(
379
+ `peach bridge on port ${port} exited while installing its runtime`,
380
+ )
381
+ }
382
+ if (Date.now() - lastAnswerAt > OPEN_BRIDGE_SILENCE_MS) {
383
+ throw new Error(
384
+ `peach bridge on port ${port} stopped answering for ${Math.round(OPEN_BRIDGE_SILENCE_MS / 1000)}s while installing its runtime`,
385
+ )
386
+ }
387
+ }
262
388
  await sleep(150)
263
389
  }
264
390
  throw new Error(
@@ -272,15 +398,18 @@ async function connectBridgeForOpen(
272
398
  resources: OpenCommandResources | undefined,
273
399
  runtimeSource: 'bridge' | 'external',
274
400
  ): Promise<number> {
401
+ isolateHomeForExplicitPort(preferredPort, explicitPort, readDaemonLockfile())
275
402
  const lock = readDaemonLockfile()
276
- assertExplicitBridgeLock(preferredPort, explicitPort, lock)
277
403
  const preferredWorld = worldForBridgePort(preferredPort)
278
- const handshake = await bridgeHandshake(preferredPort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS)
404
+ const handshakeTimeoutMs = LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS * flowTimeoutScale()
405
+ const handshake = await bridgeHandshake(preferredPort, handshakeTimeoutMs)
279
406
  if ((explicitPort || preferredWorld !== null) && handshake === 'open') {
280
407
  return preferredPort
281
408
  }
282
409
  if (explicitPort && preferredWorld !== null) {
283
- throw new Error(unreachableBridgeMessage(preferredPort, handshake))
410
+ throw new Error(
411
+ unreachableBridgeMessage(preferredPort, handshake, handshakeTimeoutMs),
412
+ )
284
413
  }
285
414
  if (
286
415
  !explicitPort &&
@@ -288,7 +417,7 @@ async function connectBridgeForOpen(
288
417
  isDaemonLockfileFresh(lock) &&
289
418
  (lock.bridgePort === preferredPort
290
419
  ? handshake === 'open'
291
- : await canConnectToBridge(lock.bridgePort, LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS))
420
+ : await canConnectToBridge(lock.bridgePort, handshakeTimeoutMs))
292
421
  ) {
293
422
  return lock.bridgePort
294
423
  }
@@ -331,8 +460,10 @@ async function connectBridgeForOpen(
331
460
  resources.port = preferredPort
332
461
  }
333
462
  let childExit: string | null = null
463
+ const exited = new AbortController()
334
464
  child.once('exit', (code, signal) => {
335
465
  childExit = String(code ?? signal ?? 'unknown')
466
+ exited.abort()
336
467
  })
337
468
  child.unref()
338
469
 
@@ -355,11 +486,18 @@ async function connectBridgeForOpen(
355
486
  )
356
487
  }
357
488
  const selectedPort = explicitPort ? preferredPort : nextLock?.bridgePort
489
+ // ready is the spawned bridge's own lockfile plus a completed handshake. a
490
+ // loaded machine needs the full handshake budget, not a 250ms probe.
358
491
  if (
359
492
  nextLock &&
493
+ nextLock.pid === child.pid &&
360
494
  isDaemonLockfileFresh(nextLock) &&
361
495
  selectedPort !== undefined &&
362
- (await canConnectToBridge(selectedPort, 250))
496
+ (await canConnectToBridge(
497
+ selectedPort,
498
+ Math.max(1, Math.min(handshakeTimeoutMs, deadline - Date.now())),
499
+ exited.signal,
500
+ ))
363
501
  ) {
364
502
  console.error(
365
503
  ` Started a local bridge on port ${selectedPort}. Run \`peach daemon install\` to keep it ready between commands.`,
@@ -371,7 +509,7 @@ async function connectBridgeForOpen(
371
509
  return selectedPort
372
510
  }
373
511
  if (childExit) break
374
- await sleep(150)
512
+ await sleep(Math.max(0, Math.min(150, deadline - Date.now())))
375
513
  }
376
514
 
377
515
  const reason = childExit
@@ -383,7 +521,10 @@ async function connectBridgeForOpen(
383
521
  )
384
522
  }
385
523
 
386
- function assertExplicitBridgeLock(
524
+ // one bridge per peach home, so an explicit port the active daemon does not own
525
+ // gets its own home: this command and the bridge it starts both run in it,
526
+ // sharing the daemon's installed runtimes.
527
+ function isolateHomeForExplicitPort(
387
528
  preferredPort: number,
388
529
  explicitPort: boolean,
389
530
  lock: ReturnType<typeof readDaemonLockfile>,
@@ -404,9 +545,14 @@ function assertExplicitBridgeLock(
404
545
  ) {
405
546
  return
406
547
  }
407
- throw new Error(
408
- `${rnxPublicBrand.commandName} open: requested bridge port ${preferredPort}, but the active daemon owns port ${lock.bridgePort}. explicit --port never selects another bridge; stop that daemon or use an isolated PEACH_HOME.`,
409
- )
548
+ const sharedRuntimes = runtimesDir()
549
+ const home = path.join(rnxHomeDir(), 'port-homes', String(preferredPort))
550
+ fs.mkdirSync(home, { recursive: true })
551
+ const runtimes = path.join(home, 'runtimes')
552
+ if (fs.existsSync(sharedRuntimes) && !fs.existsSync(runtimes)) {
553
+ fs.symlinkSync(sharedRuntimes, runtimes)
554
+ }
555
+ process.env[PEACH_HOME_ENV] = home
410
556
  }
411
557
 
412
558
  async function ensureRuntimeForOpen() {
@@ -430,7 +576,11 @@ async function ensureRuntimeForOpen() {
430
576
 
431
577
  type BridgeHandshake = 'open' | 'refused' | 'timeout'
432
578
 
433
- function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandshake> {
579
+ function bridgeHandshake(
580
+ port: number,
581
+ timeoutMs: number,
582
+ signal?: AbortSignal,
583
+ ): Promise<BridgeHandshake> {
434
584
  return new Promise((resolve) => {
435
585
  const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
436
586
  let settled = false
@@ -438,11 +588,15 @@ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandsha
438
588
  if (settled) return
439
589
  settled = true
440
590
  clearTimeout(timer)
591
+ signal?.removeEventListener('abort', onAbort)
441
592
  if (ws.readyState === WebSocket.OPEN) ws.close()
442
593
  else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
443
594
  resolve(result)
444
595
  }
445
596
  const timer = setTimeout(() => finish('timeout'), timeoutMs)
597
+ const onAbort = () => finish('timeout')
598
+ if (signal?.aborted) onAbort()
599
+ else signal?.addEventListener('abort', onAbort)
446
600
  ws.once('open', () => finish('open'))
447
601
  ws.on('error', (err: NodeJS.ErrnoException) => {
448
602
  finish(
@@ -452,19 +606,27 @@ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandsha
452
606
  })
453
607
  }
454
608
 
455
- function unreachableBridgeMessage(port: number, handshake: BridgeHandshake): string {
609
+ function unreachableBridgeMessage(
610
+ port: number,
611
+ handshake: BridgeHandshake,
612
+ timeoutMs: number,
613
+ ): string {
456
614
  if (handshake === 'timeout') {
457
615
  const cores = Math.max(1, os.cpus().length)
458
616
  const load = os.loadavg()[0]
459
617
  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.`
618
+ 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
619
  }
462
620
  }
463
621
  return `peach open: requested bridge port ${port} is not reachable.`
464
622
  }
465
623
 
466
- function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
467
- return bridgeHandshake(port, timeoutMs).then((result) => result === 'open')
624
+ function canConnectToBridge(
625
+ port: number,
626
+ timeoutMs: number,
627
+ signal?: AbortSignal,
628
+ ): Promise<boolean> {
629
+ return bridgeHandshake(port, timeoutMs, signal).then((result) => result === 'open')
468
630
  }
469
631
 
470
632
  function looksLikeSootsimUrl(target: string): boolean {
@@ -585,11 +747,12 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
585
747
  const releaseListenerScan = pinSharedListenerScan()
586
748
  try {
587
749
  for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
750
+ let resolutionTarget = normalized
588
751
  if (registeredPort && registeredPort > 0) {
589
752
  const discovered = await probePort(registeredPort)
590
753
  // 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.
754
+ // it; resolveConnectionInput joins the scanner's pending manifest with
755
+ // the full startup budget instead of starting the same work again.
593
756
  if (discovered && !discovered.bundleUrlProvisional) {
594
757
  return {
595
758
  bundleUrl: projectLoopbackUrl(discovered.bundleUrl),
@@ -598,10 +761,15 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
598
761
  projectName: discovered.projectName,
599
762
  }
600
763
  }
764
+ // keep the scanner's answering address and join its manifest request.
765
+ // probing both loopback families again duplicates cold config work.
766
+ if (discovered?.bundleUrlProvisional && /^\d+$/.test(normalized)) {
767
+ resolutionTarget = new URL(discovered.bundleUrl).origin
768
+ }
601
769
  }
602
770
 
603
771
  try {
604
- const resolved = await resolveConnectionInput(normalized)
772
+ const resolved = await resolveConnectionInput(resolutionTarget)
605
773
  return { ...resolved, bundleUrl: projectLoopbackUrl(resolved.bundleUrl) }
606
774
  } catch (error) {
607
775
  if (
@@ -850,7 +1018,7 @@ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
850
1018
  )
851
1019
  }
852
1020
 
853
- // stamp the engine page url with `?appFonts=<url[::family]>,...` so the
1021
+ // stamp a bounded catalog reference on the engine page url so the
854
1022
  // engine's app-font-loader registers native config-only fonts at boot. fonts
855
1023
  // loaded by JS already flow through normal runtime fetch capture; this covers
856
1024
  // UIAppFonts-style assets that Metro can serve but the guest bundle never
@@ -865,16 +1033,18 @@ function stampAppFonts(
865
1033
  ): string {
866
1034
  try {
867
1035
  const url = new URL(urlStr)
868
- const bundleUrl = url.searchParams.get('bundle') || ''
869
- if (!bundleUrl || !project) return urlStr
1036
+ if (!url.searchParams.get('bundle') || !project) return urlStr
870
1037
  const specs = discoverNativeLinkedAppFonts({
871
- bundleUrl,
872
1038
  projectDir: project.appDir,
873
1039
  platform,
874
1040
  nativeAssetOrigin,
875
1041
  })
876
1042
  if (specs.length === 0) return urlStr
877
- url.searchParams.set('appFonts', encodeAppFontsWire(specs))
1043
+ const catalogUrl = stageHostBytes(
1044
+ new TextEncoder().encode(encodeAppFontsWire(specs)),
1045
+ nativeAssetOrigin,
1046
+ )
1047
+ url.searchParams.set('appFonts', `@${catalogUrl}`)
878
1048
  return url.toString()
879
1049
  } catch {
880
1050
  return urlStr
@@ -1012,6 +1182,10 @@ export interface BuildShellUrlOptions {
1012
1182
  // after the app path lands inside the bundle param and never reaches the
1013
1183
  // shell. non-foldable devices resolve it to closed at boot.
1014
1184
  posture?: string
1185
+ // raster quality (high|medium|low) stamped as ?renderResolution=. high
1186
+ // renders at the device's native scale, so a native-parity capture is not
1187
+ // left at the interactive medium default of 2.
1188
+ renderResolution?: string
1015
1189
  // interface orientation (portrait|landscape) stamped as ?orientation=,
1016
1190
  // which the shell reads at boot over the device's default: an open duo
1017
1191
  // boots landscape unless this asks for it upright.
@@ -1059,45 +1233,74 @@ export interface BuildShellUrlOptions {
1059
1233
  bridgePort?: number
1060
1234
  }
1061
1235
 
1062
- function readProjectNativeFirebaseConfig(
1236
+ type ProjectExpoConfigRead = { config: Record<string, unknown> } | { error: string }
1237
+
1238
+ function errorMessage(error: unknown): string {
1239
+ return error instanceof Error ? error.message : String(error)
1240
+ }
1241
+
1242
+ function readProjectExpoConfig(
1063
1243
  project: ResolvedAppProject,
1064
- ): Promise<Record<string, unknown> | null> {
1244
+ ): Promise<ProjectExpoConfigRead | null> {
1065
1245
  const require = createRequire(path.join(project.appDir, 'package.json'))
1066
1246
  try {
1067
1247
  require.resolve('@expo/config')
1068
1248
  } catch {
1069
1249
  return Promise.resolve(null)
1070
1250
  }
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
- }))
1251
+ return readExpoConfigAsync(project.appDir).then(
1252
+ (config) => ({ config }),
1253
+ (error: unknown) => ({ error: errorMessage(error) }),
1254
+ )
1078
1255
  }
1079
1256
 
1080
1257
  // evaluating the Expo config spawns node, so `peach open` starts the read as
1081
1258
  // soon as the project resolves and the shell url it builds takes that read. a
1082
1259
  // 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
- )
1260
+ const startedExpoConfigReads = new Map<string, Promise<ProjectExpoConfigRead | null>>()
1261
+
1262
+ function startExpoConfigRead(project: ResolvedAppProject | null) {
1263
+ if (project) startedExpoConfigReads.set(project.appDir, readProjectExpoConfig(project))
1094
1264
  }
1095
1265
 
1096
- function takeNativeFirebaseConfig(project: ResolvedAppProject | null | undefined) {
1266
+ function takeExpoConfigRead(project: ResolvedAppProject | null | undefined) {
1097
1267
  if (!project) return Promise.resolve(null)
1098
- const started = startedNativeFirebaseConfigs.get(project.appDir)
1099
- startedNativeFirebaseConfigs.delete(project.appDir)
1100
- return started ?? readProjectNativeFirebaseConfig(project)
1268
+ const started = startedExpoConfigReads.get(project.appDir)
1269
+ startedExpoConfigReads.delete(project.appDir)
1270
+ return started ?? readProjectExpoConfig(project)
1271
+ }
1272
+
1273
+ function nativeFirebaseFromExpoConfig(
1274
+ project: ResolvedAppProject,
1275
+ read: ProjectExpoConfigRead,
1276
+ ): Record<string, unknown> {
1277
+ if ('error' in read) return read
1278
+ try {
1279
+ return readNativeFirebaseConfig(project.appDir, undefined, undefined, read.config)
1280
+ } catch (error) {
1281
+ return { error: errorMessage(error) }
1282
+ }
1283
+ }
1284
+
1285
+ // a built bundle staged on the bridge has no packager to serve its manifest.
1286
+ // the built binary embeds its evaluated app config (expo's `app.config`
1287
+ // resource, and the Info.plist prebuild writes from it), so the bundle's
1288
+ // manifest is that config, staged beside it
1289
+ function stageBuiltBundleManifest(
1290
+ bundleUrl: string,
1291
+ config: Record<string, unknown>,
1292
+ ): string | null {
1293
+ const bundle = new URL(bundleUrl)
1294
+ if (!isStagedHostFileRequest(bundle.pathname) || Object.keys(config).length === 0)
1295
+ return null
1296
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'peach-app-manifest-'))
1297
+ try {
1298
+ const file = path.join(directory, 'manifest.json')
1299
+ fs.writeFileSync(file, JSON.stringify({ extra: { expoClient: config } }))
1300
+ return stageHostFile(file, bundle.origin)
1301
+ } finally {
1302
+ fs.rmSync(directory, { recursive: true, force: true })
1303
+ }
1101
1304
  }
1102
1305
 
1103
1306
  // a plain Metro carries no identity footer or header, so the page carries the
@@ -1200,6 +1403,7 @@ export async function buildShellUrl(
1200
1403
  // own `@expo/config` (a dependency-free recorder) leaves it to the Peach
1201
1404
  // Metro plugin's endpoint.
1202
1405
  expoStamped.searchParams.delete('nativeFirebase')
1406
+ expoStamped.searchParams.delete('appManifest')
1203
1407
  expoStamped.searchParams.delete('packageVersions')
1204
1408
  if (project && expoStamped.searchParams.get('bundle')) {
1205
1409
  expoStamped.searchParams.set(
@@ -1207,9 +1411,25 @@ export async function buildShellUrl(
1207
1411
  JSON.stringify(readInstalledNativePackageVersions(project.appDir)),
1208
1412
  )
1209
1413
  }
1210
- const nativeFirebase = await takeNativeFirebaseConfig(project)
1211
- if (nativeFirebase && expoStamped.searchParams.get('bundle'))
1212
- expoStamped.searchParams.set('nativeFirebase', JSON.stringify(nativeFirebase))
1414
+ const expoConfigRead = await takeExpoConfigRead(project)
1415
+ const stampedBundle = expoStamped.searchParams.get('bundle')
1416
+ if (project && !expoConfigRead && stampedBundle) {
1417
+ // no Expo config tooling, but the native project still configures Firebase
1418
+ const nativeFirebase = nativeFirebaseFromExpoConfig(project, { config: {} })
1419
+ if (Object.keys(nativeFirebase).length > 0)
1420
+ expoStamped.searchParams.set('nativeFirebase', JSON.stringify(nativeFirebase))
1421
+ }
1422
+ if (project && expoConfigRead && stampedBundle) {
1423
+ expoStamped.searchParams.set(
1424
+ 'nativeFirebase',
1425
+ JSON.stringify(nativeFirebaseFromExpoConfig(project, expoConfigRead)),
1426
+ )
1427
+ const manifest =
1428
+ 'config' in expoConfigRead
1429
+ ? stageBuiltBundleManifest(stampedBundle, expoConfigRead.config)
1430
+ : null
1431
+ if (manifest) expoStamped.searchParams.set('appManifest', manifest)
1432
+ }
1213
1433
  if (opts.bundleOriginOverride)
1214
1434
  expoStamped.searchParams.set('bundleOriginOverride', opts.bundleOriginOverride)
1215
1435
  if (opts.hostDriver) expoStamped.searchParams.set('peachHostDriver', opts.hostDriver)
@@ -1229,24 +1449,27 @@ export async function buildShellUrl(
1229
1449
  !opts.proof &&
1230
1450
  !opts.ephemeral &&
1231
1451
  !opts.posture &&
1452
+ !opts.renderResolution &&
1232
1453
  !opts.orientation &&
1233
1454
  !opts.osVersion &&
1234
1455
  !opts.theme &&
1235
1456
  !opts.env?.length &&
1236
1457
  !opts.dotEnv
1237
1458
  )
1238
- return expoStamped.toString()
1459
+ return stageHostLaunchMetadata(expoStamped, `http://localhost:${port.port}`)
1239
1460
  const url = expoStamped
1240
1461
  if (opts.dotEnv) url.searchParams.set('dotEnv', JSON.stringify(opts.dotEnv))
1241
1462
  if (opts.device) url.searchParams.set('device', opts.device)
1242
1463
  if (opts.posture) url.searchParams.set('posture', opts.posture)
1464
+ if (opts.renderResolution)
1465
+ url.searchParams.set('renderResolution', opts.renderResolution)
1243
1466
  if (opts.orientation) url.searchParams.set('orientation', opts.orientation)
1244
1467
  if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
1245
1468
  if (opts.theme) url.searchParams.set('theme', opts.theme)
1246
1469
  if (opts.proof) url.searchParams.set('proof', '1')
1247
1470
  if (opts.ephemeral) url.searchParams.set('ephemeral', '1')
1248
1471
  for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
1249
- return url.toString()
1472
+ return stageHostLaunchMetadata(url, `http://localhost:${port.port}`)
1250
1473
  }
1251
1474
 
1252
1475
  function deriveCurrentSimBaseUrl(
@@ -1278,6 +1501,7 @@ function deriveCurrentSimBaseUrl(
1278
1501
  url.searchParams.delete('nativeFirebase')
1279
1502
  url.searchParams.delete('packageVersions')
1280
1503
  url.searchParams.delete('nativeAppIdentity')
1504
+ url.searchParams.delete(LAUNCH_METADATA_QUERY_PARAM)
1281
1505
  url.searchParams.delete('expoSwift')
1282
1506
  url.searchParams.delete('expoKotlin')
1283
1507
  return url.toString()
@@ -1331,13 +1555,27 @@ export async function waitForSimMatchOrHostExit(
1331
1555
  commandTimeoutMs: number,
1332
1556
  predicate: (sim: BridgeSimInfo) => boolean,
1333
1557
  hostPid: number | undefined,
1334
- opts: { attempts?: number; intervalMs?: number; signal?: AbortSignal } = {},
1558
+ opts: {
1559
+ attempts?: number
1560
+ intervalMs?: number
1561
+ signal?: AbortSignal
1562
+ admittedFile?: string
1563
+ } = {},
1335
1564
  ): Promise<WaitForSimMatchOrHostExitResult> {
1336
1565
  const attempts = opts.attempts ?? 30
1337
1566
  const intervalMs = opts.intervalMs ?? 500
1338
1567
  const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
1339
1568
  const hostExited = () =>
1340
1569
  watchHost && hostPid !== undefined && !isProcessRunning(hostPid)
1570
+ // a host queued behind the machine's browser cap has not started its connect
1571
+ // budget, so neither does this wait; the caller's signal still bounds it.
1572
+ if (opts.admittedFile && watchHost) {
1573
+ while (!fs.existsSync(opts.admittedFile)) {
1574
+ opts.signal?.throwIfAborted()
1575
+ if (hostExited()) return { kind: 'host-exit' }
1576
+ await sleep(intervalMs)
1577
+ }
1578
+ }
1341
1579
  for (let i = 0; i < attempts; i++) {
1342
1580
  opts.signal?.throwIfAborted()
1343
1581
  if (hostExited()) {
@@ -1745,6 +1983,7 @@ export async function runOpenCommand(
1745
1983
  stripBooleanFlags: [
1746
1984
  '--new',
1747
1985
  '--headless',
1986
+ '--chromium',
1748
1987
  '--headed',
1749
1988
  '--ephemeral',
1750
1989
  '--hot',
@@ -1764,6 +2003,7 @@ export async function runOpenCommand(
1764
2003
  '--cdp-port',
1765
2004
  '--device',
1766
2005
  '--posture',
2006
+ '--render-resolution',
1767
2007
  '--orientation',
1768
2008
  '--os-version',
1769
2009
  '--ios-target',
@@ -1836,7 +2076,9 @@ export async function runOpenCommand(
1836
2076
  peachExit(1)
1837
2077
  }
1838
2078
 
1839
- if (isAppDeepLinkTarget(target)) {
2079
+ // an absolute path to a built bundle is a file, not an app path
2080
+ const prebuiltBundle = prebuiltBundleFile(target)
2081
+ if (!prebuiltBundle && isAppDeepLinkTarget(target)) {
1840
2082
  if (openInNewSim) {
1841
2083
  console.error(
1842
2084
  ' peach open: deep links target the current app; remove --new, --profile, or --ephemeral',
@@ -1879,7 +2121,6 @@ export async function runOpenCommand(
1879
2121
  return
1880
2122
  }
1881
2123
 
1882
- const prebuiltBundle = prebuiltBundleFile(target)
1883
2124
  // a bundle file gets its url once the bridge that serves it is settled
1884
2125
  let resolvedBundle =
1885
2126
  !prebuiltBundle && shouldResolveBundleTargetInCli(target)
@@ -1895,7 +2136,7 @@ export async function runOpenCommand(
1895
2136
  resolvedBundle?.bundleUrl,
1896
2137
  projectRoot ?? (prebuiltBundle ? appDirAbove(prebuiltBundle) : undefined),
1897
2138
  )
1898
- startNativeFirebaseConfigRead(project)
2139
+ startExpoConfigRead(project)
1899
2140
  // playwright is the browser-sim launcher. it owns an isolated profile and
1900
2141
  // can reap exactly its own browser tree. desktop opens the native host and
1901
2142
  // electron the Electron companion. none opens the user's browser profile.
@@ -1906,25 +2147,49 @@ export async function runOpenCommand(
1906
2147
  )
1907
2148
  peachExit(1)
1908
2149
  }
1909
- const driverId: DriverId = isDriverId(requestedDriverId)
1910
- ? requestedDriverId === 'electron' && process.platform !== 'win32'
1911
- ? 'desktop'
1912
- : requestedDriverId
1913
- : process.env.CI === '1' ||
1914
- process.env.CI === 'true' ||
1915
- process.env.GITHUB_ACTIONS === 'true' ||
1916
- isTeamMachineSession() ||
1917
- isAgentEnv() ||
1918
- !process.stdin.isTTY
2150
+ // a person watching the sim, directly or through their coding agent, gets the
2151
+ // native app where it ships. CI and Team Machine fleet sessions stay in the
2152
+ // headless browser, as do the browser-only --headless and --chromium.
2153
+ const defaultDriverId: DriverId =
2154
+ isCi() ||
2155
+ isTeamMachineSession() ||
2156
+ args.includes('--headless') ||
2157
+ args.includes('--chromium')
1919
2158
  ? 'playwright'
1920
- : readSimulatorDriverPreference() === 'electron' && process.platform !== 'win32'
1921
- ? 'desktop'
1922
- : readSimulatorDriverPreference() || 'playwright'
2159
+ : (readSimulatorDriverPreference() ??
2160
+ (nativeDesktopIsDefault() ? 'desktop' : 'playwright'))
2161
+ const selectedDriverId = isDriverId(requestedDriverId)
2162
+ ? requestedDriverId
2163
+ : defaultDriverId
2164
+ const driverId: DriverId =
2165
+ selectedDriverId === 'electron' && process.platform !== 'win32'
2166
+ ? 'desktop'
2167
+ : selectedDriverId
1923
2168
  if (requestedDriverId === 'electron' && driverId === 'desktop')
1924
2169
  console.error(
1925
2170
  ' Electron is Windows-only; using the native desktop app (peach desktop install).',
1926
2171
  )
2172
+ // the first open on a machine without the native app installs it.
2173
+ if (driverId === 'desktop' && !findDesktopCompanion())
2174
+ await runInstallDesktop(['--yes'])
1927
2175
  const driverHeadless = resolveDriverHeadless(args, driverId)
2176
+ const chromium = args.includes('--chromium')
2177
+ if (chromium && driverId !== 'playwright') {
2178
+ console.error(' peach open: --chromium requires the playwright driver')
2179
+ peachExit(1)
2180
+ }
2181
+ const renderer = chromium
2182
+ ? 'chromium'
2183
+ : driverHeadless && desktopHostPlatform() === 'linux-x64'
2184
+ ? 'native'
2185
+ : undefined
2186
+ const pinnedHostExecutable =
2187
+ !chromium &&
2188
+ driverHeadless &&
2189
+ desktopHostPlatform() === 'linux-x64' &&
2190
+ desktopHostDelivery().resolvePin()
2191
+ ? (headlessHostCommand()?.[0] ?? null)
2192
+ : undefined
1928
2193
  if (cdpPort && driverId !== 'playwright') {
1929
2194
  console.error(' peach open: --cdp-port is only honored by the playwright driver')
1930
2195
  peachExit(1)
@@ -2006,9 +2271,21 @@ export async function runOpenCommand(
2006
2271
  parsed.wsPort,
2007
2272
  parsed.explicitPort,
2008
2273
  opts.resources,
2009
- !configuredRuntimeVersion && (explicitBaseUrl || looksLikeSootsimUrl(target))
2274
+ !configuredRuntimeVersion &&
2275
+ (explicitBaseUrl ||
2276
+ looksLikeSootsimUrl(target) ||
2277
+ (parsed.explicitPort &&
2278
+ worldForBridgePort(parsed.wsPort)?.source === 'dev-bridge'))
2010
2279
  ? 'external'
2011
2280
  : 'bridge',
2281
+ {
2282
+ newShell: openInNewSim,
2283
+ simId: parsed.simId,
2284
+ simIdSource: parsed.simIdSource,
2285
+ driverId,
2286
+ renderer,
2287
+ pinnedHostExecutable,
2288
+ },
2012
2289
  )
2013
2290
  if (opts.resources) opts.resources.port = parsed.wsPort
2014
2291
  opts.signal?.throwIfAborted()
@@ -2035,6 +2312,18 @@ export async function runOpenCommand(
2035
2312
  console.error(` peach open: --posture must be open or closed, got "${postureArg}"`)
2036
2313
  peachExit(1)
2037
2314
  }
2315
+ const renderResolutionArg = args.find((_, i) => args[i - 1] === '--render-resolution')
2316
+ if (
2317
+ args.includes('--render-resolution') &&
2318
+ renderResolutionArg !== 'high' &&
2319
+ renderResolutionArg !== 'medium' &&
2320
+ renderResolutionArg !== 'low'
2321
+ ) {
2322
+ console.error(
2323
+ ` peach open: --render-resolution must be high, medium, or low, got "${renderResolutionArg ?? ''}"`,
2324
+ )
2325
+ peachExit(1)
2326
+ }
2038
2327
  const orientationFlag = args.find((_, i) => args[i - 1] === '--orientation')
2039
2328
  const orientationArg =
2040
2329
  orientationFlag === 'portrait' || orientationFlag === 'landscape'
@@ -2131,6 +2420,7 @@ export async function runOpenCommand(
2131
2420
  hostDriver: driverId === 'desktop' ? 'desktop' : undefined,
2132
2421
  device: deviceArg,
2133
2422
  posture: postureArg,
2423
+ renderResolution: renderResolutionArg,
2134
2424
  orientation: orientationArg,
2135
2425
  osVersion: osVersionArg,
2136
2426
  theme: themeArg,
@@ -2185,14 +2475,14 @@ export async function runOpenCommand(
2185
2475
  (sim) =>
2186
2476
  sim.id === savedSimId &&
2187
2477
  sim.readyState === 'open' &&
2188
- simUsesDriver(sim, driverId),
2478
+ simUsesDriver(sim, driverId, renderer, pinnedHostExecutable),
2189
2479
  ) ?? null
2190
2480
  if (!targetSim) {
2191
2481
  clearCurrentSimId()
2192
2482
  }
2193
2483
  } else if (parsed.simIdSource === 'flag') {
2194
2484
  targetSim = resolveTargetSim(sims, parsed.simId)
2195
- if (!simUsesDriver(targetSim, driverId)) {
2485
+ if (!simUsesDriver(targetSim, driverId, renderer, pinnedHostExecutable)) {
2196
2486
  throw new Error(
2197
2487
  `sim ${targetSim.id} is not managed by the ${driverId} driver`,
2198
2488
  )
@@ -2211,7 +2501,7 @@ export async function runOpenCommand(
2211
2501
  if (
2212
2502
  sim.readyState !== 'open' ||
2213
2503
  !sim.url ||
2214
- !simUsesDriver(sim, driverId)
2504
+ !simUsesDriver(sim, driverId, renderer, pinnedHostExecutable)
2215
2505
  ) {
2216
2506
  return false
2217
2507
  }
@@ -2279,7 +2569,7 @@ export async function runOpenCommand(
2279
2569
  parsed.commandTimeoutMs,
2280
2570
  (sim) =>
2281
2571
  sim.readyState === 'open' &&
2282
- simUsesDriver(sim, driverId) &&
2572
+ simUsesDriver(sim, driverId, renderer, pinnedHostExecutable) &&
2283
2573
  simMatchesToken(sim),
2284
2574
  { attempts: Math.round(30 * flowTimeoutScale()) },
2285
2575
  )
@@ -2330,11 +2620,11 @@ export async function runOpenCommand(
2330
2620
  }
2331
2621
 
2332
2622
  const configuredBaseUrl = applyPeachConfigToUrl(baseUrl, runtimeConfig)
2333
- const openUrl = withOpenToken(
2623
+ const builtShellUrl =
2334
2624
  newSimShellUrl ??
2335
- (await buildShellUrl(shellTarget, configuredBaseUrl, buildShellUrlOpts)),
2336
- token,
2337
- )
2625
+ (await buildShellUrl(shellTarget, configuredBaseUrl, buildShellUrlOpts))
2626
+ await awaitStampedExpoSwiftReady(builtShellUrl)
2627
+ const openUrl = withOpenToken(builtShellUrl, token)
2338
2628
  const ownerPid = opts.ownerPid ?? getStableOwnerPid()
2339
2629
  if (!ownerPid) {
2340
2630
  console.error(
@@ -2359,6 +2649,7 @@ export async function runOpenCommand(
2359
2649
  // live world of its own.
2360
2650
  bridgePort: parsed.wsPort,
2361
2651
  headless: driverHeadless,
2652
+ chromium,
2362
2653
  profileId,
2363
2654
  ephemeralProfile,
2364
2655
  cdpPort,
@@ -2386,6 +2677,7 @@ export async function runOpenCommand(
2386
2677
  attempts: connectWait.attempts,
2387
2678
  intervalMs: connectWait.intervalMs,
2388
2679
  signal: opts.signal,
2680
+ admittedFile: result.admittedFile,
2389
2681
  },
2390
2682
  )
2391
2683
  if (outcome.kind === 'host-exit') {
@@ -2436,6 +2728,13 @@ export async function runOpenCommand(
2436
2728
  simId: match.id,
2437
2729
  simIdSource: 'flag',
2438
2730
  })
2731
+ const attached = await bridge
2732
+ .listSims()
2733
+ .then((sims) => sims.find((sim) => sim.id === match.id)?.readyState ?? 'gone')
2734
+ .catch(() => 'unknown')
2735
+ console.error(
2736
+ ` sim ${match.id} bridge state: ${attached}; host load ${os.loadavg()[0].toFixed(1)} on ${os.cpus().length} cpus (a loaded host slows a cold first bundle past the budget; a sim that is open but never answers has a blocked page)`,
2737
+ )
2439
2738
  await printBridgeFailureDiagnostics(bridge, {
2440
2739
  errorsCommand: `peach get errors 5${hint}`,
2441
2740
  warningsCommand: `peach get warnings 5${hint}`,