peachsim 0.1.1625 → 0.1.1635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -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,20 +28,28 @@ 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'
@@ -102,10 +111,11 @@ import {
102
111
  readCurrentSimId,
103
112
  saveCurrentSimId,
104
113
  } from '../current-sim'
114
+ import { findDesktopCompanion, nativeDesktopIsDefault } from '../desktop-companion'
105
115
  import { validateDeviceOption } from '../device-option'
106
116
  import { getDriver } from '../drivers/registry'
107
117
  import { isTeamMachineSession, openUrl as openUrlInBrowser } from '../open-url'
108
- import { diagnosticsAllowedForCurrentProcess } from '../privacy'
118
+ import { diagnosticsAllowedForCurrentProcess, isCi } from '../privacy'
109
119
  import { rethrowIfExit, peachExit } from '../run-rnx'
110
120
  import { collectRuntimeDiagnostics } from '../runtime-diagnostics'
111
121
  import { rnxSelfInvocation } from '../self-invocation'
@@ -123,8 +133,8 @@ import {
123
133
  } from '../ws-bridge'
124
134
  import { readNativeProjectRenderer } from './detect'
125
135
  import { inspectDescribe, inspectReadySnapshot, waitReadyReason } from './inspect/core'
126
- import { isAgentEnv } from './inspect/env'
127
136
  import { waitForSimReady } from './inspect/shared'
137
+ import { runInstallDesktop } from './install-desktop'
128
138
  import { hasInstalledPeachSkills } from './skills'
129
139
  import type { DriverId, DriverLaunchResult } from '../drivers/types'
130
140
 
@@ -152,8 +162,13 @@ interface DriverConnectWaitOptions {
152
162
  const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
153
163
  const DRIVER_CONNECT_INTERVAL_MS = 100
154
164
  const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
155
- const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
156
- 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
157
172
  // a closed port refuses at once, so this window only matters when something
158
173
  // listens: a live bridge on a loaded machine can take longer than 250 ms to
159
174
  // finish the handshake, and missing it spawned a second bridge or reported a
@@ -197,9 +212,19 @@ function isDriverId(value: string): value is DriverId {
197
212
  return value === 'desktop' || value === 'electron' || value === 'playwright'
198
213
  }
199
214
 
200
- 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 {
201
221
  if (driverId === 'playwright') {
202
- 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
+ )
203
228
  }
204
229
  if (driverId === 'desktop') {
205
230
  return Boolean(
@@ -236,11 +261,21 @@ function normalizeKnownTarget(target: string): string {
236
261
  return target.trim()
237
262
  }
238
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
+
239
273
  export async function ensureBridgeForOpen(
240
274
  preferredPort: number,
241
275
  explicitPort = false,
242
276
  resources?: OpenCommandResources,
243
277
  runtimeSource: 'bridge' | 'external' = 'bridge',
278
+ gate: BridgeOpenGate = { newShell: true },
244
279
  ): Promise<number> {
245
280
  const port = await connectBridgeForOpen(
246
281
  preferredPort,
@@ -249,22 +284,107 @@ export async function ensureBridgeForOpen(
249
284
  runtimeSource,
250
285
  )
251
286
  // a bridge accepts connections while its first runtime download is still
252
- // 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.
253
293
  if (runtimeSource === 'bridge') {
254
- await waitForBridgeRuntime(port)
255
- 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
+ }
256
300
  }
257
301
  return port
258
302
  }
259
303
 
260
- 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'> {
261
362
  const deadline = Date.now() + OPEN_BRIDGE_RUNTIME_TIMEOUT_MS
363
+ let lastAnswerAt = Date.now()
262
364
  while (Date.now() < deadline) {
263
365
  try {
264
- 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
+ })
265
369
  const health = (await res.json()) as { bootstrapping?: boolean }
266
- if (!health.bootstrapping) return
267
- } 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
+ }
268
388
  await sleep(150)
269
389
  }
270
390
  throw new Error(
@@ -278,8 +398,8 @@ async function connectBridgeForOpen(
278
398
  resources: OpenCommandResources | undefined,
279
399
  runtimeSource: 'bridge' | 'external',
280
400
  ): Promise<number> {
401
+ isolateHomeForExplicitPort(preferredPort, explicitPort, readDaemonLockfile())
281
402
  const lock = readDaemonLockfile()
282
- assertExplicitBridgeLock(preferredPort, explicitPort, lock)
283
403
  const preferredWorld = worldForBridgePort(preferredPort)
284
404
  const handshakeTimeoutMs = LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS * flowTimeoutScale()
285
405
  const handshake = await bridgeHandshake(preferredPort, handshakeTimeoutMs)
@@ -340,8 +460,10 @@ async function connectBridgeForOpen(
340
460
  resources.port = preferredPort
341
461
  }
342
462
  let childExit: string | null = null
463
+ const exited = new AbortController()
343
464
  child.once('exit', (code, signal) => {
344
465
  childExit = String(code ?? signal ?? 'unknown')
466
+ exited.abort()
345
467
  })
346
468
  child.unref()
347
469
 
@@ -364,11 +486,18 @@ async function connectBridgeForOpen(
364
486
  )
365
487
  }
366
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.
367
491
  if (
368
492
  nextLock &&
493
+ nextLock.pid === child.pid &&
369
494
  isDaemonLockfileFresh(nextLock) &&
370
495
  selectedPort !== undefined &&
371
- (await canConnectToBridge(selectedPort, 250))
496
+ (await canConnectToBridge(
497
+ selectedPort,
498
+ Math.max(1, Math.min(handshakeTimeoutMs, deadline - Date.now())),
499
+ exited.signal,
500
+ ))
372
501
  ) {
373
502
  console.error(
374
503
  ` Started a local bridge on port ${selectedPort}. Run \`peach daemon install\` to keep it ready between commands.`,
@@ -380,7 +509,7 @@ async function connectBridgeForOpen(
380
509
  return selectedPort
381
510
  }
382
511
  if (childExit) break
383
- await sleep(150)
512
+ await sleep(Math.max(0, Math.min(150, deadline - Date.now())))
384
513
  }
385
514
 
386
515
  const reason = childExit
@@ -392,7 +521,10 @@ async function connectBridgeForOpen(
392
521
  )
393
522
  }
394
523
 
395
- 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(
396
528
  preferredPort: number,
397
529
  explicitPort: boolean,
398
530
  lock: ReturnType<typeof readDaemonLockfile>,
@@ -413,9 +545,14 @@ function assertExplicitBridgeLock(
413
545
  ) {
414
546
  return
415
547
  }
416
- throw new Error(
417
- `${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.`,
418
- )
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
419
556
  }
420
557
 
421
558
  async function ensureRuntimeForOpen() {
@@ -439,7 +576,11 @@ async function ensureRuntimeForOpen() {
439
576
 
440
577
  type BridgeHandshake = 'open' | 'refused' | 'timeout'
441
578
 
442
- function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandshake> {
579
+ function bridgeHandshake(
580
+ port: number,
581
+ timeoutMs: number,
582
+ signal?: AbortSignal,
583
+ ): Promise<BridgeHandshake> {
443
584
  return new Promise((resolve) => {
444
585
  const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
445
586
  let settled = false
@@ -447,11 +588,15 @@ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandsha
447
588
  if (settled) return
448
589
  settled = true
449
590
  clearTimeout(timer)
591
+ signal?.removeEventListener('abort', onAbort)
450
592
  if (ws.readyState === WebSocket.OPEN) ws.close()
451
593
  else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
452
594
  resolve(result)
453
595
  }
454
596
  const timer = setTimeout(() => finish('timeout'), timeoutMs)
597
+ const onAbort = () => finish('timeout')
598
+ if (signal?.aborted) onAbort()
599
+ else signal?.addEventListener('abort', onAbort)
455
600
  ws.once('open', () => finish('open'))
456
601
  ws.on('error', (err: NodeJS.ErrnoException) => {
457
602
  finish(
@@ -476,8 +621,12 @@ function unreachableBridgeMessage(
476
621
  return `peach open: requested bridge port ${port} is not reachable.`
477
622
  }
478
623
 
479
- function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
480
- 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')
481
630
  }
482
631
 
483
632
  function looksLikeSootsimUrl(target: string): boolean {
@@ -884,10 +1033,8 @@ function stampAppFonts(
884
1033
  ): string {
885
1034
  try {
886
1035
  const url = new URL(urlStr)
887
- const bundleUrl = url.searchParams.get('bundle') || ''
888
- if (!bundleUrl || !project) return urlStr
1036
+ if (!url.searchParams.get('bundle') || !project) return urlStr
889
1037
  const specs = discoverNativeLinkedAppFonts({
890
- bundleUrl,
891
1038
  projectDir: project.appDir,
892
1039
  platform,
893
1040
  nativeAssetOrigin,
@@ -1035,6 +1182,10 @@ export interface BuildShellUrlOptions {
1035
1182
  // after the app path lands inside the bundle param and never reaches the
1036
1183
  // shell. non-foldable devices resolve it to closed at boot.
1037
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
1038
1189
  // interface orientation (portrait|landscape) stamped as ?orientation=,
1039
1190
  // which the shell reads at boot over the device's default: an open duo
1040
1191
  // boots landscape unless this asks for it upright.
@@ -1262,6 +1413,12 @@ export async function buildShellUrl(
1262
1413
  }
1263
1414
  const expoConfigRead = await takeExpoConfigRead(project)
1264
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
+ }
1265
1422
  if (project && expoConfigRead && stampedBundle) {
1266
1423
  expoStamped.searchParams.set(
1267
1424
  'nativeFirebase',
@@ -1292,6 +1449,7 @@ export async function buildShellUrl(
1292
1449
  !opts.proof &&
1293
1450
  !opts.ephemeral &&
1294
1451
  !opts.posture &&
1452
+ !opts.renderResolution &&
1295
1453
  !opts.orientation &&
1296
1454
  !opts.osVersion &&
1297
1455
  !opts.theme &&
@@ -1303,6 +1461,8 @@ export async function buildShellUrl(
1303
1461
  if (opts.dotEnv) url.searchParams.set('dotEnv', JSON.stringify(opts.dotEnv))
1304
1462
  if (opts.device) url.searchParams.set('device', opts.device)
1305
1463
  if (opts.posture) url.searchParams.set('posture', opts.posture)
1464
+ if (opts.renderResolution)
1465
+ url.searchParams.set('renderResolution', opts.renderResolution)
1306
1466
  if (opts.orientation) url.searchParams.set('orientation', opts.orientation)
1307
1467
  if (opts.osVersion) url.searchParams.set('osVersion', opts.osVersion)
1308
1468
  if (opts.theme) url.searchParams.set('theme', opts.theme)
@@ -1395,13 +1555,27 @@ export async function waitForSimMatchOrHostExit(
1395
1555
  commandTimeoutMs: number,
1396
1556
  predicate: (sim: BridgeSimInfo) => boolean,
1397
1557
  hostPid: number | undefined,
1398
- opts: { attempts?: number; intervalMs?: number; signal?: AbortSignal } = {},
1558
+ opts: {
1559
+ attempts?: number
1560
+ intervalMs?: number
1561
+ signal?: AbortSignal
1562
+ admittedFile?: string
1563
+ } = {},
1399
1564
  ): Promise<WaitForSimMatchOrHostExitResult> {
1400
1565
  const attempts = opts.attempts ?? 30
1401
1566
  const intervalMs = opts.intervalMs ?? 500
1402
1567
  const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
1403
1568
  const hostExited = () =>
1404
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
+ }
1405
1579
  for (let i = 0; i < attempts; i++) {
1406
1580
  opts.signal?.throwIfAborted()
1407
1581
  if (hostExited()) {
@@ -1809,6 +1983,7 @@ export async function runOpenCommand(
1809
1983
  stripBooleanFlags: [
1810
1984
  '--new',
1811
1985
  '--headless',
1986
+ '--chromium',
1812
1987
  '--headed',
1813
1988
  '--ephemeral',
1814
1989
  '--hot',
@@ -1828,6 +2003,7 @@ export async function runOpenCommand(
1828
2003
  '--cdp-port',
1829
2004
  '--device',
1830
2005
  '--posture',
2006
+ '--render-resolution',
1831
2007
  '--orientation',
1832
2008
  '--os-version',
1833
2009
  '--ios-target',
@@ -1971,25 +2147,49 @@ export async function runOpenCommand(
1971
2147
  )
1972
2148
  peachExit(1)
1973
2149
  }
1974
- const driverId: DriverId = isDriverId(requestedDriverId)
1975
- ? requestedDriverId === 'electron' && process.platform !== 'win32'
1976
- ? 'desktop'
1977
- : requestedDriverId
1978
- : process.env.CI === '1' ||
1979
- process.env.CI === 'true' ||
1980
- process.env.GITHUB_ACTIONS === 'true' ||
1981
- isTeamMachineSession() ||
1982
- isAgentEnv() ||
1983
- !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')
1984
2158
  ? 'playwright'
1985
- : readSimulatorDriverPreference() === 'electron' && process.platform !== 'win32'
1986
- ? 'desktop'
1987
- : 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
1988
2168
  if (requestedDriverId === 'electron' && driverId === 'desktop')
1989
2169
  console.error(
1990
2170
  ' Electron is Windows-only; using the native desktop app (peach desktop install).',
1991
2171
  )
2172
+ // the first open on a machine without the native app installs it.
2173
+ if (driverId === 'desktop' && !findDesktopCompanion())
2174
+ await runInstallDesktop(['--yes'])
1992
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
1993
2193
  if (cdpPort && driverId !== 'playwright') {
1994
2194
  console.error(' peach open: --cdp-port is only honored by the playwright driver')
1995
2195
  peachExit(1)
@@ -2071,9 +2271,21 @@ export async function runOpenCommand(
2071
2271
  parsed.wsPort,
2072
2272
  parsed.explicitPort,
2073
2273
  opts.resources,
2074
- !configuredRuntimeVersion && (explicitBaseUrl || looksLikeSootsimUrl(target))
2274
+ !configuredRuntimeVersion &&
2275
+ (explicitBaseUrl ||
2276
+ looksLikeSootsimUrl(target) ||
2277
+ (parsed.explicitPort &&
2278
+ worldForBridgePort(parsed.wsPort)?.source === 'dev-bridge'))
2075
2279
  ? 'external'
2076
2280
  : 'bridge',
2281
+ {
2282
+ newShell: openInNewSim,
2283
+ simId: parsed.simId,
2284
+ simIdSource: parsed.simIdSource,
2285
+ driverId,
2286
+ renderer,
2287
+ pinnedHostExecutable,
2288
+ },
2077
2289
  )
2078
2290
  if (opts.resources) opts.resources.port = parsed.wsPort
2079
2291
  opts.signal?.throwIfAborted()
@@ -2100,6 +2312,18 @@ export async function runOpenCommand(
2100
2312
  console.error(` peach open: --posture must be open or closed, got "${postureArg}"`)
2101
2313
  peachExit(1)
2102
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
+ }
2103
2327
  const orientationFlag = args.find((_, i) => args[i - 1] === '--orientation')
2104
2328
  const orientationArg =
2105
2329
  orientationFlag === 'portrait' || orientationFlag === 'landscape'
@@ -2196,6 +2420,7 @@ export async function runOpenCommand(
2196
2420
  hostDriver: driverId === 'desktop' ? 'desktop' : undefined,
2197
2421
  device: deviceArg,
2198
2422
  posture: postureArg,
2423
+ renderResolution: renderResolutionArg,
2199
2424
  orientation: orientationArg,
2200
2425
  osVersion: osVersionArg,
2201
2426
  theme: themeArg,
@@ -2250,14 +2475,14 @@ export async function runOpenCommand(
2250
2475
  (sim) =>
2251
2476
  sim.id === savedSimId &&
2252
2477
  sim.readyState === 'open' &&
2253
- simUsesDriver(sim, driverId),
2478
+ simUsesDriver(sim, driverId, renderer, pinnedHostExecutable),
2254
2479
  ) ?? null
2255
2480
  if (!targetSim) {
2256
2481
  clearCurrentSimId()
2257
2482
  }
2258
2483
  } else if (parsed.simIdSource === 'flag') {
2259
2484
  targetSim = resolveTargetSim(sims, parsed.simId)
2260
- if (!simUsesDriver(targetSim, driverId)) {
2485
+ if (!simUsesDriver(targetSim, driverId, renderer, pinnedHostExecutable)) {
2261
2486
  throw new Error(
2262
2487
  `sim ${targetSim.id} is not managed by the ${driverId} driver`,
2263
2488
  )
@@ -2276,7 +2501,7 @@ export async function runOpenCommand(
2276
2501
  if (
2277
2502
  sim.readyState !== 'open' ||
2278
2503
  !sim.url ||
2279
- !simUsesDriver(sim, driverId)
2504
+ !simUsesDriver(sim, driverId, renderer, pinnedHostExecutable)
2280
2505
  ) {
2281
2506
  return false
2282
2507
  }
@@ -2344,7 +2569,7 @@ export async function runOpenCommand(
2344
2569
  parsed.commandTimeoutMs,
2345
2570
  (sim) =>
2346
2571
  sim.readyState === 'open' &&
2347
- simUsesDriver(sim, driverId) &&
2572
+ simUsesDriver(sim, driverId, renderer, pinnedHostExecutable) &&
2348
2573
  simMatchesToken(sim),
2349
2574
  { attempts: Math.round(30 * flowTimeoutScale()) },
2350
2575
  )
@@ -2395,11 +2620,11 @@ export async function runOpenCommand(
2395
2620
  }
2396
2621
 
2397
2622
  const configuredBaseUrl = applyPeachConfigToUrl(baseUrl, runtimeConfig)
2398
- const openUrl = withOpenToken(
2623
+ const builtShellUrl =
2399
2624
  newSimShellUrl ??
2400
- (await buildShellUrl(shellTarget, configuredBaseUrl, buildShellUrlOpts)),
2401
- token,
2402
- )
2625
+ (await buildShellUrl(shellTarget, configuredBaseUrl, buildShellUrlOpts))
2626
+ await awaitStampedExpoSwiftReady(builtShellUrl)
2627
+ const openUrl = withOpenToken(builtShellUrl, token)
2403
2628
  const ownerPid = opts.ownerPid ?? getStableOwnerPid()
2404
2629
  if (!ownerPid) {
2405
2630
  console.error(
@@ -2424,6 +2649,7 @@ export async function runOpenCommand(
2424
2649
  // live world of its own.
2425
2650
  bridgePort: parsed.wsPort,
2426
2651
  headless: driverHeadless,
2652
+ chromium,
2427
2653
  profileId,
2428
2654
  ephemeralProfile,
2429
2655
  cdpPort,
@@ -2451,6 +2677,7 @@ export async function runOpenCommand(
2451
2677
  attempts: connectWait.attempts,
2452
2678
  intervalMs: connectWait.intervalMs,
2453
2679
  signal: opts.signal,
2680
+ admittedFile: result.admittedFile,
2454
2681
  },
2455
2682
  )
2456
2683
  if (outcome.kind === 'host-exit') {
@@ -2501,6 +2728,13 @@ export async function runOpenCommand(
2501
2728
  simId: match.id,
2502
2729
  simIdSource: 'flag',
2503
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
+ )
2504
2738
  await printBridgeFailureDiagnostics(bridge, {
2505
2739
  errorsCommand: `peach get errors 5${hint}`,
2506
2740
  warningsCommand: `peach get warnings 5${hint}`,