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
@@ -9,11 +9,13 @@
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'
16
17
  import { toProfileFile, type ProfileFile } from '@peach/globals/profiler'
18
+ import { resolveAppFontsWire } from 'peach-engine/engine/config-plugin-fonts'
17
19
  import { shouldCompress } from 'peach-engine/preview/compress'
18
20
  import { brotliBundle } from 'peach-engine/preview/compress-node'
19
21
  import {
@@ -35,11 +37,13 @@ import {
35
37
  mergeProfileTimeline,
36
38
  profileFrameSummary,
37
39
  } from 'peach-engine/profiler/profile-trace'
40
+ import { readSceneRecordingUpload } from 'peach-engine/remote-stream/scene-recording'
38
41
  import {
39
42
  resolvedExpoAppIconUrl,
40
43
  expoConfigFromManifest,
41
44
  } from '../../../peach-engine/src/engine/expo-manifest'
42
45
  import { loopbackOriginOf } from '../../src/backend-origin'
46
+ import { parseExpoManifestBody } from '../../src/expo-manifest-body'
43
47
  import { rnxPublicBrand } from '../../src/public-brand'
44
48
  import { fetchAppFontFiles } from '../app-fonts'
45
49
  import { fetchAppSplashImages } from '../app-splash'
@@ -193,8 +197,8 @@ options:
193
197
  for prod uploads, upload origin for localhost/custom origins).
194
198
  override with PEACH_PREVIEW_ORIGIN env var
195
199
  --events <path> path to a gzipped events .jsonl.gz file to attach
196
- required unless --video is present; previews must
197
- have replay or recording playback data
200
+ required unless --video or --scene is present;
201
+ previews must have replay or recording playback data
198
202
  --screenshot <path>
199
203
  path to a PNG thumbnail to attach to the share
200
204
  --snapshot-manifest <path>
@@ -204,6 +208,9 @@ options:
204
208
  /api/preview/flow-video?id=<share-id>
205
209
  --video-duration-ms <ms>
206
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
207
214
  --timeline-events <path>
208
215
  extra trace timeline rows, JSONL or JSONL gz
209
216
  --trace-profile <path>
@@ -435,6 +442,7 @@ export async function runUpload(
435
442
  const screenshotPath = extractStringFlag(mutableArgs, '--screenshot')
436
443
  const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest')
437
444
  const videoPath = extractStringFlag(mutableArgs, '--video')
445
+ const scenePath = extractStringFlag(mutableArgs, '--scene')
438
446
  const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events')
439
447
  const traceProfilePath = extractStringFlag(mutableArgs, '--trace-profile')
440
448
  const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle')
@@ -467,9 +475,9 @@ export async function runUpload(
467
475
  console.error(' --billing-kind test_run is only for hosted test jobs')
468
476
  peachExit(1)
469
477
  }
470
- if (!eventsPath && !videoPath) {
478
+ if (!eventsPath && !videoPath && !scenePath) {
471
479
  console.error(
472
- ' preview uploads require playback data: pass --events or --video.\n' +
480
+ ' preview uploads require playback data: pass --events, --video or --scene.\n' +
473
481
  ' for a normal local preview, use `peach record --mode combined --open`\n' +
474
482
  ' or `peach maestro test --preview` instead of uploading a bundle-only snapshot.',
475
483
  )
@@ -492,6 +500,7 @@ export async function runUpload(
492
500
  })
493
501
 
494
502
  const bridge = createBridgeFromParsed(parsed)
503
+ let shellUrl: string | null = null
495
504
  let snapshot: BundleSnapshot
496
505
  // bundle-origin manifest (no bodies) — CLI re-fetches these over http so
497
506
  // they can be large without blowing the ws frame cap. sibling bundle
@@ -512,6 +521,7 @@ export async function runUpload(
512
521
  const localTraceTimelineCount = traceTimelineEvents.length
513
522
  let transformedBundleText: string | null = null
514
523
  try {
524
+ if (scenePath) shellUrl = await evalInBridge<string>(bridge, 'location.href')
515
525
  snapshot = await evalInBridge<BundleSnapshot>(
516
526
  bridge,
517
527
  '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
@@ -871,6 +881,17 @@ export async function runUpload(
871
881
  )
872
882
  }
873
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
+
874
895
  // priority: PEACH_API_KEY env → github runner token → saved credential
875
896
  // file → session token. github runner auth is what keeps installed PR
876
897
  // previews from requiring a new Contrast secret.
@@ -882,6 +903,21 @@ export async function runUpload(
882
903
  console.error(' set PEACH_API_KEY=sk_rnx_... or run `peach login`.')
883
904
  peachExit(1)
884
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
+ }
885
921
  const resolvedAuthHeader = authHeaderValue(cliAuth)
886
922
 
887
923
  const bundleOrigin = (() => {
@@ -939,7 +975,10 @@ export async function runUpload(
939
975
  // fetcher (same code path as the build's `peach app-fonts stage`) so both
940
976
  // pipelines store identical bytes keyed by sha256(url).
941
977
  const appFontFiles: PreparedFile[] = []
942
- const appFontsWire = appFontsWireFromPageHref(snapshot.pageHref) || ''
978
+ const appFontsWire = await resolveAppFontsWire(
979
+ appFontsWireFromPageHref(snapshot.pageHref),
980
+ fetch,
981
+ )
943
982
  if (appFontsWire) {
944
983
  const fonts = await fetchAppFontFiles(appFontsWire, {
945
984
  onStaged: ({ url, byteLength }) =>
@@ -1006,8 +1045,8 @@ export async function runUpload(
1006
1045
  headers: { 'expo-platform': platform, accept: 'application/json' },
1007
1046
  signal: AbortSignal.timeout(30_000),
1008
1047
  })
1009
- if (response.ok && response.headers.get('content-type')?.includes('json')) {
1010
- 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())
1011
1050
  const config = expoConfigFromManifest(manifest)
1012
1051
  if (config) {
1013
1052
  appManifest = manifestUrl
@@ -1175,6 +1214,7 @@ export async function runUpload(
1175
1214
  durationMs: videoDurationMs,
1176
1215
  }
1177
1216
  : undefined,
1217
+ sceneStream,
1178
1218
  }
1179
1219
 
1180
1220
  const filesByHash = new Map<string, Uint8Array>()
@@ -1197,6 +1237,7 @@ export async function runUpload(
1197
1237
  for (const snapshot of interactionSnapshots) putBytes += snapshot.bytes.byteLength
1198
1238
  if (failureBundleGz) putBytes += failureBundleGz.byteLength
1199
1239
  if (videoBytes) putBytes += videoBytes.byteLength
1240
+ if (sceneBytes) putBytes += sceneBytes.byteLength
1200
1241
 
1201
1242
  const initUrl = `${normalizeOrigin(origin)}/api/preview/upload/init`
1202
1243
  console.log(` init: ${initUrl}`)
@@ -1213,6 +1254,7 @@ export async function runUpload(
1213
1254
  timelineBytes: timelineGz,
1214
1255
  storageBytes: storageGz,
1215
1256
  videoBytes,
1257
+ sceneStreamBytes: sceneBytes,
1216
1258
  failureBundleBytes: failureBundleGz,
1217
1259
  snapshotBytes,
1218
1260
  },
@@ -1228,6 +1270,7 @@ export async function runUpload(
1228
1270
  (result.init.storage ? 1 : 0) +
1229
1271
  result.init.snapshots.length +
1230
1272
  (result.init.flowVideo ? 1 : 0) +
1273
+ (result.init.sceneStream ? 1 : 0) +
1231
1274
  (result.init.failureBundle ? 1 : 0)
1232
1275
  console.log(
1233
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}`)
@@ -14,8 +14,8 @@
14
14
  // watcher would bundle chokidar and private engine/bundler modules into the cli.
15
15
  // deps-web belongs to a different served tree.
16
16
 
17
- import { readdirSync, statSync, type Dirent } from 'node:fs'
18
- import { join, relative, sep } from 'node:path'
17
+ import { readdirSync, statSync, utimesSync, type Dirent } from 'node:fs'
18
+ import { join, relative, resolve, sep } from 'node:path'
19
19
 
20
20
  export const DEV_BUILD_REBUILD_COMMAND = 'bun run build:peach'
21
21
 
@@ -159,6 +159,41 @@ function newestForEntry(monorepoRoot: string, repoPath: string): NewestFile | nu
159
159
  return null
160
160
  }
161
161
 
162
+ /** stamps the sentinels under `installedRoot` as built at `sourceCapturedAt`,
163
+ * when the build captured its source. an install that finds identical bytes
164
+ * (remote cache hit, byte-identical promotion) leaves their old mtimes, which
165
+ * the gate would read as a stale build; stamping the install time instead
166
+ * would hide a source edit made while the build ran. */
167
+ export function markDevBuildCurrent(
168
+ monorepoRoot: string,
169
+ installedRoot: string,
170
+ sourceCapturedAt: Date,
171
+ ): void {
172
+ const installed = resolve(installedRoot)
173
+ for (const route of ROUTES) {
174
+ for (const artifact of route.artifacts) {
175
+ const absolute = join(monorepoRoot, artifact)
176
+ if (absolute !== installed && !absolute.startsWith(installed + sep)) continue
177
+ const stack = [absolute]
178
+ while (stack.length > 0) {
179
+ const path = stack.pop()
180
+ if (path === undefined) continue
181
+ let stat: ReturnType<typeof statSync>
182
+ try {
183
+ stat = statSync(path)
184
+ } catch {
185
+ continue
186
+ }
187
+ if (stat.isDirectory()) {
188
+ for (const name of readdirSync(path)) stack.push(join(path, name))
189
+ } else {
190
+ utimesSync(path, sourceCapturedAt, sourceCapturedAt)
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+
162
197
  function toRepoPath(monorepoRoot: string, absolutePath: string): string {
163
198
  return relative(monorepoRoot, absolutePath).split(sep).join('/')
164
199
  }
@@ -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,
@@ -37,6 +39,7 @@ import {
37
39
  webViewAutomationHelpersSource,
38
40
  } from '../../../peach-engine/src/webview-automation-host'
39
41
  import { DISPOSABLE_BROWSER_CACHE_DIR_NAMES } from '../../src/browser-cache'
42
+ import { listProcessChildren } from '../../src/process-group'
40
43
 
41
44
  // a person's tap on the device canvas is a trusted input, which gives the page
42
45
  // the user activation browsers require before media plays with sound, and an
@@ -68,6 +71,9 @@ const callerUserDataDir = process.env.SOOTSIM_PW_USERDATADIR || '';
68
71
  // SOOTSIM_PW_OWNER_POLL_MS is a test-only override of the 5s poll interval;
69
72
  // production never sets it.
70
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);
71
77
  const ownerPollMs = Number(process.env.SOOTSIM_PW_OWNER_POLL_MS || 5000);
72
78
  // optional CDP port — exposes Chrome's remote-debugging endpoint so a
73
79
  // bridge-driven sim can also be cpu-profiled (peach perf cpu --cdp-port).
@@ -109,16 +115,19 @@ const viewport = viewportSpec
109
115
  const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2);
110
116
  const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500);
111
117
  const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || '';
118
+ const admittedFile = process.env.SOOTSIM_PW_ADMITTED_FILE || '';
112
119
  const completionFile = process.env.PEACH_PW_COMPLETION_FILE || '';
113
120
  const disposableBrowserCacheDirs = new Set(${JSON.stringify(
114
121
  DISPOSABLE_BROWSER_CACHE_DIR_NAMES,
115
122
  )});
116
- const { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, writeFileSync } = require('fs');
117
- const { execSync, spawn } = require('child_process');
123
+ const { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } = require('fs');
124
+ const { execSync, spawn, spawnSync } = require('child_process');
125
+ const listProcessChildren = ${listProcessChildren.toString()};
118
126
  const { tmpdir } = require('os');
119
127
  const { join } = require('path');
120
128
  const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
121
129
  const automateWebViewFrames = ${automateWebViewFrames.toString()};
130
+ const automateWebAuthenticationSessions = ${automateWebAuthenticationSessions.toString()};
122
131
  const webViewAutomationHelpers = ${webViewAutomationHelpersSource()};
123
132
  const readBrowserCompositeCaptureRequest = ${readBrowserCompositeCaptureRequest.toString()};
124
133
  const resolveBrowserCompositeCdpClip = ${resolveBrowserCompositeCdpClip.toString()};
@@ -193,10 +202,9 @@ const assertNoUnsafeSwiftShaderFlag = () => {
193
202
  const killTree = (pid) => {
194
203
  let kids = [];
195
204
  try {
196
- kids = execSync('pgrep -P ' + pid + ' || true', { stdio: ['ignore', 'pipe', 'ignore'] })
197
- .toString().split('\\n').filter(Boolean);
205
+ kids = listProcessChildren([pid]);
198
206
  } catch {}
199
- for (const k of kids) killTree(Number(k));
207
+ for (const k of kids) killTree(k);
200
208
  try { process.kill(pid, 'SIGKILL'); } catch {}
201
209
  };
202
210
  const reapChrome = () => { for (const pid of chromePidsByProfile()) killTree(pid); };
@@ -401,6 +409,9 @@ const cleanupProfile = () => {
401
409
  if (connectAckFile) {
402
410
  try { unlinkSync(connectAckFile); } catch {}
403
411
  }
412
+ if (admittedFile) {
413
+ try { unlinkSync(admittedFile); } catch {}
414
+ }
404
415
  process.exit(code);
405
416
  };
406
417
  process.once('SIGTERM', () => { void shutdown(0); });
@@ -433,13 +444,25 @@ const cleanupProfile = () => {
433
444
  throw new Error('[playwright-driver] missing valid owning process; refusing unsupervised browser host');
434
445
  }
435
446
  const intervalMs = Number.isFinite(ownerPollMs) && ownerPollMs > 0 ? ownerPollMs : 5000;
436
- ownerWatch = setInterval(() => {
447
+ const gone = (pid) => {
437
448
  try {
438
- process.kill(ownerPid, 0);
449
+ process.kill(pid, 0);
450
+ return false;
439
451
  } catch (err) {
440
- if (err && err.code === 'EPERM') return;
452
+ return !(err && err.code === 'EPERM');
453
+ }
454
+ };
455
+ ownerWatch = setInterval(() => {
456
+ if (gone(ownerPid)) {
441
457
  process.stderr.write('[owner-gone] owning process ' + ownerPid + ' exited; closing playwright host\\n');
442
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);
443
466
  }
444
467
  }, intervalMs);
445
468
  if (typeof ownerWatch.unref === 'function') ownerWatch.unref();
@@ -478,6 +501,11 @@ const cleanupProfile = () => {
478
501
  LD_LIBRARY_PATH: [libraryPath, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'),
479
502
  };
480
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
+ }
481
509
  // playwright injects --force-color-profile=srgb by default, which reads
482
510
  // visibly washed-out next to a normal chrome window on a wide-gamut mac
483
511
  // display. strip it so driver-launched sims color-match the user's own
@@ -548,6 +576,11 @@ const cleanupProfile = () => {
548
576
  label: 'peach sim host ' + profileDir,
549
577
  readQueue: readChromiumQueue,
550
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
+ }
551
584
  clearStaleSingletonLock();
552
585
  let launchErr = null;
553
586
  let launchedChannel = null;
@@ -590,6 +623,8 @@ const cleanupProfile = () => {
590
623
  await context.grantPermissions(['camera']);
591
624
  const hostMeta = {
592
625
  sootsimHostDriver: 'playwright',
626
+ peachRenderer: hostCommand ? 'native' : 'chromium',
627
+ peachHostExecutable: hostCommand ? hostCommand[0] : null,
593
628
  sootsimHostPid: process.pid,
594
629
  sootsimHostOwnerPid: ownerPid,
595
630
  sootsimHostStartedAt: Date.now(),
@@ -676,6 +711,7 @@ const cleanupProfile = () => {
676
711
  }, Number.isFinite(connectTimeoutMs) && connectTimeoutMs > 0 ? connectTimeoutMs : 120000);
677
712
  if (typeof connectTimer.unref === 'function') connectTimer.unref();
678
713
  const page = await context.newPage();
714
+ await automateWebAuthenticationSessions(context, '__peachWebAuthenticationHost', page, !headless);
679
715
  if (hardwareGpuRequired) {
680
716
  assertNoUnsafeSwiftShaderFlag();
681
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>