peachsim 0.1.1625 → 0.1.1635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -9,7 +9,8 @@
9
9
  // associated with the user's account.
10
10
 
11
11
  import { createHash, randomUUID } from 'crypto'
12
- import { readFileSync } from 'fs'
12
+ import { readFileSync, rmSync } from 'fs'
13
+ import { tmpdir } from 'os'
13
14
  import path from 'path'
14
15
  import { gzipSync, gunzipSync } from 'zlib'
15
16
  import { readProfileTrace } from '@peach/globals/profile-trace'
@@ -36,11 +37,13 @@ import {
36
37
  mergeProfileTimeline,
37
38
  profileFrameSummary,
38
39
  } from 'peach-engine/profiler/profile-trace'
40
+ import { readSceneRecordingUpload } from 'peach-engine/remote-stream/scene-recording'
39
41
  import {
40
42
  resolvedExpoAppIconUrl,
41
43
  expoConfigFromManifest,
42
44
  } from '../../../peach-engine/src/engine/expo-manifest'
43
45
  import { loopbackOriginOf } from '../../src/backend-origin'
46
+ import { parseExpoManifestBody } from '../../src/expo-manifest-body'
44
47
  import { rnxPublicBrand } from '../../src/public-brand'
45
48
  import { fetchAppFontFiles } from '../app-fonts'
46
49
  import { fetchAppSplashImages } from '../app-splash'
@@ -194,8 +197,8 @@ options:
194
197
  for prod uploads, upload origin for localhost/custom origins).
195
198
  override with PEACH_PREVIEW_ORIGIN env var
196
199
  --events <path> path to a gzipped events .jsonl.gz file to attach
197
- required unless --video is present; previews must
198
- have replay or recording playback data
200
+ required unless --video or --scene is present;
201
+ previews must have replay or recording playback data
199
202
  --screenshot <path>
200
203
  path to a PNG thumbnail to attach to the share
201
204
  --snapshot-manifest <path>
@@ -205,6 +208,9 @@ options:
205
208
  /api/preview/flow-video?id=<share-id>
206
209
  --video-duration-ms <ms>
207
210
  duration hint for attached flow video
211
+ --scene <path> a scene recording (.psr.gz) of a remote viewer's display
212
+ stream. the preview paints it through the simulator's
213
+ renderer instead of playing a video
208
214
  --timeline-events <path>
209
215
  extra trace timeline rows, JSONL or JSONL gz
210
216
  --trace-profile <path>
@@ -436,6 +442,7 @@ export async function runUpload(
436
442
  const screenshotPath = extractStringFlag(mutableArgs, '--screenshot')
437
443
  const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest')
438
444
  const videoPath = extractStringFlag(mutableArgs, '--video')
445
+ const scenePath = extractStringFlag(mutableArgs, '--scene')
439
446
  const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events')
440
447
  const traceProfilePath = extractStringFlag(mutableArgs, '--trace-profile')
441
448
  const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle')
@@ -468,9 +475,9 @@ export async function runUpload(
468
475
  console.error(' --billing-kind test_run is only for hosted test jobs')
469
476
  peachExit(1)
470
477
  }
471
- if (!eventsPath && !videoPath) {
478
+ if (!eventsPath && !videoPath && !scenePath) {
472
479
  console.error(
473
- ' preview uploads require playback data: pass --events or --video.\n' +
480
+ ' preview uploads require playback data: pass --events, --video or --scene.\n' +
474
481
  ' for a normal local preview, use `peach record --mode combined --open`\n' +
475
482
  ' or `peach maestro test --preview` instead of uploading a bundle-only snapshot.',
476
483
  )
@@ -493,6 +500,7 @@ export async function runUpload(
493
500
  })
494
501
 
495
502
  const bridge = createBridgeFromParsed(parsed)
503
+ let shellUrl: string | null = null
496
504
  let snapshot: BundleSnapshot
497
505
  // bundle-origin manifest (no bodies) — CLI re-fetches these over http so
498
506
  // they can be large without blowing the ws frame cap. sibling bundle
@@ -513,6 +521,7 @@ export async function runUpload(
513
521
  const localTraceTimelineCount = traceTimelineEvents.length
514
522
  let transformedBundleText: string | null = null
515
523
  try {
524
+ if (scenePath) shellUrl = await evalInBridge<string>(bridge, 'location.href')
516
525
  snapshot = await evalInBridge<BundleSnapshot>(
517
526
  bridge,
518
527
  '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
@@ -872,6 +881,17 @@ export async function runUpload(
872
881
  )
873
882
  }
874
883
 
884
+ let sceneBytes: Uint8Array | undefined
885
+ let sceneStream: InitRequestBody['sceneStream']
886
+ if (scenePath) {
887
+ sceneBytes = readFileSync(scenePath)
888
+ const { header, slot } = await readSceneRecordingUpload(sceneBytes)
889
+ sceneStream = slot
890
+ console.log(
891
+ ` attaching scene stream: ${(sceneBytes.byteLength / 1024).toFixed(1)} KiB, ${header.frameCount} frames over ${Math.round(header.durationMs)}ms`,
892
+ )
893
+ }
894
+
875
895
  // priority: PEACH_API_KEY env → github runner token → saved credential
876
896
  // file → session token. github runner auth is what keeps installed PR
877
897
  // previews from requiring a new Contrast secret.
@@ -883,6 +903,21 @@ export async function runUpload(
883
903
  console.error(' set PEACH_API_KEY=sk_rnx_... or run `peach login`.')
884
904
  peachExit(1)
885
905
  }
906
+ if (sceneBytes && shellUrl && cliAuth.kind === 'github') {
907
+ const { renderSceneToVideo } = await import('../scene-video')
908
+ const output = path.join(tmpdir(), `peach-scene-video-${Date.now()}.mp4`)
909
+ try {
910
+ const rendered = await renderSceneToVideo({ scene: sceneBytes, shellUrl, output })
911
+ videoBytes = readFileSync(output)
912
+ videoContentType = 'video/mp4'
913
+ videoDurationMs = rendered.durationMs
914
+ console.log(
915
+ ` rendered scene video: ${rendered.frameCount} frames at 60 fps, ${rendered.sizeBytes} bytes in ${(rendered.renderTimeMs / 1000).toFixed(1)}s`,
916
+ )
917
+ } finally {
918
+ rmSync(output, { force: true })
919
+ }
920
+ }
886
921
  const resolvedAuthHeader = authHeaderValue(cliAuth)
887
922
 
888
923
  const bundleOrigin = (() => {
@@ -1010,8 +1045,8 @@ export async function runUpload(
1010
1045
  headers: { 'expo-platform': platform, accept: 'application/json' },
1011
1046
  signal: AbortSignal.timeout(30_000),
1012
1047
  })
1013
- if (response.ok && response.headers.get('content-type')?.includes('json')) {
1014
- const manifest: unknown = await response.json()
1048
+ if (response.ok && !response.headers.get('content-type')?.includes('html')) {
1049
+ const manifest: unknown = parseExpoManifestBody(await response.text())
1015
1050
  const config = expoConfigFromManifest(manifest)
1016
1051
  if (config) {
1017
1052
  appManifest = manifestUrl
@@ -1179,6 +1214,7 @@ export async function runUpload(
1179
1214
  durationMs: videoDurationMs,
1180
1215
  }
1181
1216
  : undefined,
1217
+ sceneStream,
1182
1218
  }
1183
1219
 
1184
1220
  const filesByHash = new Map<string, Uint8Array>()
@@ -1201,6 +1237,7 @@ export async function runUpload(
1201
1237
  for (const snapshot of interactionSnapshots) putBytes += snapshot.bytes.byteLength
1202
1238
  if (failureBundleGz) putBytes += failureBundleGz.byteLength
1203
1239
  if (videoBytes) putBytes += videoBytes.byteLength
1240
+ if (sceneBytes) putBytes += sceneBytes.byteLength
1204
1241
 
1205
1242
  const initUrl = `${normalizeOrigin(origin)}/api/preview/upload/init`
1206
1243
  console.log(` init: ${initUrl}`)
@@ -1217,6 +1254,7 @@ export async function runUpload(
1217
1254
  timelineBytes: timelineGz,
1218
1255
  storageBytes: storageGz,
1219
1256
  videoBytes,
1257
+ sceneStreamBytes: sceneBytes,
1220
1258
  failureBundleBytes: failureBundleGz,
1221
1259
  snapshotBytes,
1222
1260
  },
@@ -1232,6 +1270,7 @@ export async function runUpload(
1232
1270
  (result.init.storage ? 1 : 0) +
1233
1271
  result.init.snapshots.length +
1234
1272
  (result.init.flowVideo ? 1 : 0) +
1273
+ (result.init.sceneStream ? 1 : 0) +
1235
1274
  (result.init.failureBundle ? 1 : 0)
1236
1275
  console.log(
1237
1276
  ` PUT ${totalObjects} objects in ${Date.now() - putT0}ms (${(putBytes / 1024).toFixed(1)} KiB)`,
@@ -1,9 +1,29 @@
1
1
  import { execFileSync, spawn } from 'child_process'
2
- import { existsSync, mkdirSync, writeFileSync } from 'fs'
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ symlinkSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'fs'
11
+ import { createHash } from 'node:crypto'
12
+ import { tmpdir } from 'os'
3
13
  import { dirname, join, resolve } from 'path'
4
14
  import { fileURLToPath } from 'url'
5
- import { activeDesktopHostApp, isNativeHostBundle } from '../src/desktop-host-delivery'
6
- import { rnxHomeDir } from '../src/home-paths'
15
+ import {
16
+ activeDesktopHostApp,
17
+ desktopHostPlatform,
18
+ isNativeHostBundle,
19
+ } from '../src/desktop-host-delivery'
20
+ import {
21
+ isDaemonLockfileFresh,
22
+ isDevBridgeLockfileFresh,
23
+ readDaemonLockfile,
24
+ readDevBridgeLockfiles,
25
+ rnxHomeDir,
26
+ } from '../src/home-paths'
7
27
 
8
28
  // resolved lazily: peach-engine is a workspace package in dev, but absent
9
29
  // from the published npm tarball. don't crash module load just because the
@@ -95,22 +115,31 @@ export function findInstalledDesktopCompanion(): DesktopCompanionInstall | null
95
115
  : findNativeDesktopCompanion()
96
116
  }
97
117
 
118
+ // leftover Electron bundles. /Applications/Peach.app is the native host on a
119
+ // case-insensitive volume, so bundle id decides, not the path's letter case.
120
+ function retiredElectronApps(): string[] {
121
+ if (process.platform === 'darwin') {
122
+ return [
123
+ '/Applications/peach.app',
124
+ resolve(process.env.HOME || '', 'Applications/peach.app'),
125
+ ].filter((candidate) => existsSync(candidate) && !isNativeHostBundle(candidate))
126
+ }
127
+ if (process.platform !== 'linux') return []
128
+ return [
129
+ resolve(process.env.HOME || '', 'Applications/peach.AppImage'),
130
+ resolve(process.env.HOME || '', '.local/bin/peach.AppImage'),
131
+ '/opt/peach/peach.AppImage',
132
+ '/opt/peach/peach',
133
+ ].filter((candidate) => existsSync(candidate))
134
+ }
135
+
98
136
  // existing Electron apps stay on disk; CLI users receive the migration once.
137
+ // the notice names those apps and does not reject a native host beside them.
99
138
  export function notifyDesktopMigration(): void {
100
139
  if (process.platform !== 'darwin' && process.platform !== 'linux') return
101
- const legacy =
102
- process.platform === 'darwin'
103
- ? [
104
- '/Applications/peach.app',
105
- resolve(process.env.HOME || '', 'Applications/peach.app'),
106
- ].some((candidate) => existsSync(candidate) && !isNativeHostBundle(candidate))
107
- : ['Applications/peach.AppImage', '.local/bin/peach.AppImage'].some((candidate) =>
108
- existsSync(resolve(process.env.HOME || '', candidate)),
109
- ) ||
110
- existsSync('/opt/peach/peach.AppImage') ||
111
- existsSync('/opt/peach/peach')
140
+ const legacy = retiredElectronApps()
112
141
  const marker = join(rnxHomeDir(), 'electron-native-migration')
113
- if (!legacy || existsSync(marker)) return
142
+ if (legacy.length === 0 || existsSync(marker)) return
114
143
  mkdirSync(rnxHomeDir(), { recursive: true })
115
144
  try {
116
145
  writeFileSync(marker, '', { flag: 'wx' })
@@ -119,7 +148,7 @@ export function notifyDesktopMigration(): void {
119
148
  throw error
120
149
  }
121
150
  console.error(
122
- ' Electron has retired on Mac and Linux. Install the native app: peach desktop install',
151
+ ` Electron has retired on Mac and Linux (${legacy.join(', ')}). Install the native app: peach desktop install`,
123
152
  )
124
153
  }
125
154
 
@@ -154,6 +183,21 @@ export function wantsNativeDesktop(opts: DesktopCompanionOptions = {}): boolean
154
183
  )
155
184
  }
156
185
 
186
+ // the native app is the simulator window wherever it ships: always on Apple
187
+ // silicon, and on Linux x64 in a desktop session once installed or when a
188
+ // person is present to approve its package install's sudo prompt.
189
+ export function nativeDesktopIsDefault(): boolean {
190
+ const platform = desktopHostPlatform()
191
+ if (platform === 'mac-arm64') return true
192
+ if (platform !== 'linux-x64') return false
193
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false
194
+ return (
195
+ findNativeDesktopCompanion() !== null ||
196
+ process.stdin.isTTY === true ||
197
+ process.getuid?.() === 0
198
+ )
199
+ }
200
+
157
201
  export function getNativeDesktopAppPath(): string | null {
158
202
  const explicitApp = process.env.PEACH_DESKTOP_APP
159
203
  if (!explicitApp)
@@ -284,6 +328,47 @@ async function openInConnectedMacApp(
284
328
  }
285
329
  }
286
330
 
331
+ // PeachDesktop shipped before PEACH_BRIDGE_PORT seeds the page from
332
+ // PEACH_HOME/daemon.json and ignores the launch URL's wsPort. when this
333
+ // command is waiting on a different bridge, give the child a home whose
334
+ // lock names that bridge. the caller's daemon.json is left untouched.
335
+ // a newer host that prefers the env, the URL pin, or a fresh daemon still
336
+ // sees this same port (the isolated-home rule in desktop host bridge selection).
337
+ function macDesktopLaunchHome(bridgePort: number): string {
338
+ const home = rnxHomeDir()
339
+ const daemon = readDaemonLockfile()
340
+ if (isDaemonLockfileFresh(daemon) && daemon.bridgePort === bridgePort) return home
341
+ const dev = readDevBridgeLockfiles().find(
342
+ (lock) => lock.bridgePort === bridgePort && isDevBridgeLockfileFresh(lock),
343
+ )
344
+ const homeKey = createHash('sha256').update(home).digest('hex')
345
+ const dir = join(tmpdir(), 'peach-desktop-launch', homeKey, String(bridgePort))
346
+ mkdirSync(dir, { recursive: true })
347
+ if (existsSync(home)) {
348
+ for (const name of readdirSync(home)) {
349
+ if (name === 'daemon.json') continue
350
+ const dest = join(dir, name)
351
+ try {
352
+ lstatSync(dest)
353
+ } catch {
354
+ symlinkSync(join(home, name), dest)
355
+ }
356
+ }
357
+ }
358
+ const lockPath = join(dir, 'daemon.json')
359
+ try {
360
+ if (lstatSync(lockPath).isSymbolicLink()) unlinkSync(lockPath)
361
+ } catch {
362
+ // no lock yet
363
+ }
364
+ const pid = dev && dev.pid > 1 ? dev.pid : process.pid
365
+ writeFileSync(
366
+ lockPath,
367
+ JSON.stringify({ schema: 1, pid, bridgePort, heartbeatAt: Date.now() }),
368
+ )
369
+ return dir
370
+ }
371
+
287
372
  export async function launchDesktopCompanion(
288
373
  url?: string,
289
374
  install = findDesktopCompanion(),
@@ -339,7 +424,7 @@ export async function launchDesktopCompanion(
339
424
  '-a',
340
425
  install.path,
341
426
  '--env',
342
- `PEACH_HOME=${rnxHomeDir()}`,
427
+ `PEACH_HOME=${opts.bridgePort && launchUrl ? macDesktopLaunchHome(opts.bridgePort) : rnxHomeDir()}`,
343
428
  ]
344
429
  if (opts.bridgePort) {
345
430
  openArgs.push('--env', `PEACH_BRIDGE_PORT=${opts.bridgePort}`)
@@ -1,5 +1,6 @@
1
1
  // desktop driver selection is shared with companion discovery.
2
2
 
3
+ import { desktopHostPlatform, headlessHostCommand } from '../../src/desktop-host-delivery'
3
4
  import {
4
5
  findDesktopCompanion,
5
6
  launchDesktopCompanion,
@@ -18,7 +19,19 @@ export function createDesktopDriver(id: 'desktop' | 'electron'): Driver {
18
19
  const missing = `peach ${id} app not installed (run \`peach desktop install${native ? '' : ' --electron'}\`)`
19
20
  function probe(): DriverAvailability {
20
21
  const install = findDesktopCompanion({ native })
21
- if (!install) return { available: false, reason: missing }
22
+ if (!install) {
23
+ const host =
24
+ id === 'desktop' && native && desktopHostPlatform() === 'linux-x64'
25
+ ? headlessHostCommand()
26
+ : null
27
+ return host
28
+ ? {
29
+ available: true,
30
+ reason: null,
31
+ detail: `headless host @ ${host[0]} (use --driver playwright --headless)`,
32
+ }
33
+ : { available: false, reason: missing }
34
+ }
22
35
  return {
23
36
  available: true,
24
37
  reason: null,
@@ -18,6 +18,7 @@ import {
18
18
  // require, in a child process the CLI spawns as itself.
19
19
  import {
20
20
  chromeDisabledFeaturesSwitch,
21
+ LINUX_CHROME_SCOPE_ENV,
21
22
  softwareChromeLaunchOptions,
22
23
  } from '../../../../scripts/lib/reap-browser'
23
24
  import { createBrowserPageTargetReader } from '../../../peach-engine/src/browser-page-target'
@@ -27,6 +28,7 @@ import {
27
28
  } from '../../../peach-engine/src/capture/browser-composite'
28
29
  import { WEBVIEW_HOST_BOOTSTRAP_SOURCE } from '../../../peach-engine/src/components/native-ui/webview-frame-bridge'
29
30
  import { createBrowserPointerDispatcher } from '../../../peach-engine/src/render-worker/browser-pointer'
31
+ import { automateWebAuthenticationSessions } from '../../../peach-engine/src/web-authentication-automation-host'
30
32
  import {
31
33
  automateWebViewFrames,
32
34
  WEBVIEW_HOST_BINDING,
@@ -69,6 +71,9 @@ const callerUserDataDir = process.env.SOOTSIM_PW_USERDATADIR || '';
69
71
  // SOOTSIM_PW_OWNER_POLL_MS is a test-only override of the 5s poll interval;
70
72
  // production never sets it.
71
73
  const ownerPid = Number(process.env.SOOTSIM_PW_OWNER_PID || 0);
74
+ // the open command waiting for this host to connect; it may queue for the
75
+ // browser slot and then be killed by its own caller's budget.
76
+ const launcherPid = Number(process.env.SOOTSIM_PW_LAUNCHER_PID || 0);
72
77
  const ownerPollMs = Number(process.env.SOOTSIM_PW_OWNER_POLL_MS || 5000);
73
78
  // optional CDP port — exposes Chrome's remote-debugging endpoint so a
74
79
  // bridge-driven sim can also be cpu-profiled (peach perf cpu --cdp-port).
@@ -110,6 +115,7 @@ const viewport = viewportSpec
110
115
  const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2);
111
116
  const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500);
112
117
  const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || '';
118
+ const admittedFile = process.env.SOOTSIM_PW_ADMITTED_FILE || '';
113
119
  const completionFile = process.env.PEACH_PW_COMPLETION_FILE || '';
114
120
  const disposableBrowserCacheDirs = new Set(${JSON.stringify(
115
121
  DISPOSABLE_BROWSER_CACHE_DIR_NAMES,
@@ -121,6 +127,7 @@ const { tmpdir } = require('os');
121
127
  const { join } = require('path');
122
128
  const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
123
129
  const automateWebViewFrames = ${automateWebViewFrames.toString()};
130
+ const automateWebAuthenticationSessions = ${automateWebAuthenticationSessions.toString()};
124
131
  const webViewAutomationHelpers = ${webViewAutomationHelpersSource()};
125
132
  const readBrowserCompositeCaptureRequest = ${readBrowserCompositeCaptureRequest.toString()};
126
133
  const resolveBrowserCompositeCdpClip = ${resolveBrowserCompositeCdpClip.toString()};
@@ -402,6 +409,9 @@ const cleanupProfile = () => {
402
409
  if (connectAckFile) {
403
410
  try { unlinkSync(connectAckFile); } catch {}
404
411
  }
412
+ if (admittedFile) {
413
+ try { unlinkSync(admittedFile); } catch {}
414
+ }
405
415
  process.exit(code);
406
416
  };
407
417
  process.once('SIGTERM', () => { void shutdown(0); });
@@ -434,13 +444,25 @@ const cleanupProfile = () => {
434
444
  throw new Error('[playwright-driver] missing valid owning process; refusing unsupervised browser host');
435
445
  }
436
446
  const intervalMs = Number.isFinite(ownerPollMs) && ownerPollMs > 0 ? ownerPollMs : 5000;
437
- ownerWatch = setInterval(() => {
447
+ const gone = (pid) => {
438
448
  try {
439
- process.kill(ownerPid, 0);
449
+ process.kill(pid, 0);
450
+ return false;
440
451
  } catch (err) {
441
- if (err && err.code === 'EPERM') return;
452
+ return !(err && err.code === 'EPERM');
453
+ }
454
+ };
455
+ ownerWatch = setInterval(() => {
456
+ if (gone(ownerPid)) {
442
457
  process.stderr.write('[owner-gone] owning process ' + ownerPid + ' exited; closing playwright host\\n');
443
458
  void shutdown(0);
459
+ return;
460
+ }
461
+ // until the sim connects, the open command is the only one waiting for
462
+ // it; once that command is gone nobody receives the sim.
463
+ if (!connected && !ackFileExists() && Number.isInteger(launcherPid) && launcherPid > 1 && gone(launcherPid)) {
464
+ process.stderr.write('[launcher-gone] open command ' + launcherPid + ' exited before the sim connected; closing playwright host\\n');
465
+ void shutdown(2);
444
466
  }
445
467
  }, intervalMs);
446
468
  if (typeof ownerWatch.unref === 'function') ownerWatch.unref();
@@ -479,6 +501,11 @@ const cleanupProfile = () => {
479
501
  LD_LIBRARY_PATH: [libraryPath, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'),
480
502
  };
481
503
  }
504
+ // keep the browser main process with its children in the caller's cgroup.
505
+ // otherwise chromium moves only main into a systemd app scope over dbus.
506
+ if (process.platform === 'linux') {
507
+ opts.env = { ...process.env, ...opts.env, ...${JSON.stringify(LINUX_CHROME_SCOPE_ENV)} };
508
+ }
482
509
  // playwright injects --force-color-profile=srgb by default, which reads
483
510
  // visibly washed-out next to a normal chrome window on a wide-gamut mac
484
511
  // display. strip it so driver-launched sims color-match the user's own
@@ -549,6 +576,11 @@ const cleanupProfile = () => {
549
576
  label: 'peach sim host ' + profileDir,
550
577
  readQueue: readChromiumQueue,
551
578
  });
579
+ // the launcher's connect wait starts here: time queued for the slot is not
580
+ // time the sim had to connect.
581
+ if (admittedFile) {
582
+ try { writeFileSync(admittedFile, JSON.stringify({ admittedAt: Date.now(), pid: process.pid }) + '\\n'); } catch {}
583
+ }
552
584
  clearStaleSingletonLock();
553
585
  let launchErr = null;
554
586
  let launchedChannel = null;
@@ -591,6 +623,8 @@ const cleanupProfile = () => {
591
623
  await context.grantPermissions(['camera']);
592
624
  const hostMeta = {
593
625
  sootsimHostDriver: 'playwright',
626
+ peachRenderer: hostCommand ? 'native' : 'chromium',
627
+ peachHostExecutable: hostCommand ? hostCommand[0] : null,
594
628
  sootsimHostPid: process.pid,
595
629
  sootsimHostOwnerPid: ownerPid,
596
630
  sootsimHostStartedAt: Date.now(),
@@ -677,6 +711,7 @@ const cleanupProfile = () => {
677
711
  }, Number.isFinite(connectTimeoutMs) && connectTimeoutMs > 0 ? connectTimeoutMs : 120000);
678
712
  if (typeof connectTimer.unref === 'function') connectTimer.unref();
679
713
  const page = await context.newPage();
714
+ await automateWebAuthenticationSessions(context, '__peachWebAuthenticationHost', page, !headless);
680
715
  if (hardwareGpuRequired) {
681
716
  assertNoUnsafeSwiftShaderFlag();
682
717
  const gpu = await page.evaluate(() => {
@@ -151,18 +151,15 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
151
151
  // renders natively and lets the page present only on capture; the host
152
152
  // reaches the browser over cdp, so it needs a debugging port.
153
153
  let hostCommand: string[] | null = null
154
- if (headless && desktopHostPlatform()) {
154
+ if (headless && !opts.chromium && desktopHostPlatform()) {
155
155
  try {
156
156
  hostCommand = headlessHostCommand()
157
157
  if (!hostCommand && desktopHostPlatform() === 'linux-x64') {
158
- // a missing or older host upgrades to the channel's current release; one published
159
- // before the compatible headless host existed leaves this run on the browser engine alone
158
+ // delivery honors PEACH_DESKTOP_VERSION; a pin never follows channel latest.
160
159
  await desktopHostDelivery().install()
161
160
  hostCommand = headlessHostCommand()
162
161
  if (!hostCommand)
163
- console.error(
164
- ' native host release has no compatible headless host yet; rendering in the browser',
165
- )
162
+ throw new Error('selected native host has no compatible headless entrypoint')
166
163
  }
167
164
  } catch (error) {
168
165
  return {
@@ -175,6 +172,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
175
172
 
176
173
  const errLog = join(tmpdir(), playwrightHostLogName(Date.now(), process.pid))
177
174
  const connectAckFile = `${errLog}.connected`
175
+ const admittedFile = `${errLog}.admitted`
178
176
  const completionFile = `${errLog}.completed`
179
177
  const errFd = openSync(errLog, 'a')
180
178
  try {
@@ -210,9 +208,11 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
210
208
  ? { SOOTSIM_PW_VIEWPORT: `${opts.viewport.width}x${opts.viewport.height}` }
211
209
  : {}),
212
210
  SOOTSIM_PW_CONNECTED_ACK_FILE: connectAckFile,
211
+ SOOTSIM_PW_ADMITTED_FILE: admittedFile,
213
212
  PEACH_PW_COMPLETION_FILE: completionFile,
214
213
  SOOTSIM_PW_CONNECT_TIMEOUT_MS: String(opts.connectTimeoutMs ?? 120_000),
215
214
  SOOTSIM_PW_OWNER_PID: String(ownerPid),
215
+ SOOTSIM_PW_LAUNCHER_PID: String(process.pid),
216
216
  SOOTSIM_PW_LIBRARY_PATH: provisioning.libraryPath ?? '',
217
217
  },
218
218
  },
@@ -233,11 +233,12 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
233
233
  const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits, or after ${idleMinutes} min with no peach command or input if another browser launch is waiting for its slot`
234
234
  return {
235
235
  launched: true,
236
- message: `playwright chrome launched → ${targetUrl.href}${ownerNote}`,
236
+ message: `playwright ${hostCommand ? 'native host' : 'Chromium'} launched → ${targetUrl.href}${ownerNote}`,
237
237
  pid: child.pid,
238
238
  target: resolved.spec,
239
239
  attachUrl: targetUrl.href,
240
240
  connectAckFile,
241
+ admittedFile,
241
242
  diagnosticLogPath: errLog,
242
243
  completion,
243
244
  completionFile,
@@ -257,7 +258,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
257
258
  export const playwrightDriver: Driver = {
258
259
  id: 'playwright',
259
260
  name: 'playwright',
260
- description: 'programmatic chromium via the playwright package — headless default',
261
+ description: 'isolated Chromium; headless uses the native host when available',
261
262
  kind: 'automation',
262
263
  availability: probe,
263
264
  launch,
@@ -30,6 +30,8 @@ export interface DriverLaunchOptions {
30
30
  url?: string
31
31
  // run in headless mode where the driver supports it. ignored by electron.
32
32
  headless?: boolean
33
+ // opt this open into browser rendering without acquiring or attaching a native host.
34
+ chromium?: boolean
33
35
  // initial device model seed forwarded to the peach shell. the electron
34
36
  // driver forwards it via argv (--device=<model>); browser drivers receive
35
37
  // it already stamped on the URL (?device=<model>) by runOpenCommand.
@@ -86,6 +88,9 @@ export interface DriverLaunchResult {
86
88
  target?: string
87
89
  attachUrl?: string
88
90
  connectAckFile?: string
91
+ // written once the host holds its machine-wide browser slot; its connect
92
+ // budget starts there, so the launcher's wait starts there too.
93
+ admittedFile?: string
89
94
  diagnosticLogPath?: string
90
95
  // foreground guest hosts await the detached driver's exit without polling.
91
96
  completion?: Promise<DriverCompletion>
@@ -1,7 +1,7 @@
1
1
  import type { PeachAccessibilityNode } from '@peach/globals'
2
2
 
3
3
  // maestro reads ios frames as integer edges (AXElement.boundsString).
4
- export function accessibilityBounds(node: PeachAccessibilityNode): {
4
+ export function accessibilityBounds(node: Pick<PeachAccessibilityNode, 'frame'>): {
5
5
  x: number
6
6
  y: number
7
7
  width: number