peachsim 0.1.1625 → 0.1.1640

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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -1,6 +1,6 @@
1
- // peach record upload — capture the currently-loaded bundle + device spec from
2
- // a running peach sim and POST it to the Contrast upload endpoint. returns a
3
- // /preview/<id> url that loads this bundle in a frame.
1
+ // peach record upload — publish a recording from a running simulator. scene
2
+ // recordings carry their own content identity; --live-bundle adds the app
3
+ // bundle for interactive playback under the existing bundle identity.
4
4
  //
5
5
  // uploads with no repository association are private by default: only
6
6
  // signed-in members of the uploader's account can open the link until the
@@ -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'
@@ -57,6 +60,7 @@ import {
57
60
  parseBridgeCliArgs,
58
61
  } from '../ws-bridge'
59
62
  import type { SootSimTimelineEvent } from '@peach/globals'
63
+ import type { BundleSnapshot } from 'peach-engine/preview/capture-bundle'
60
64
 
61
65
  interface UploadOptions {
62
66
  port?: number
@@ -83,19 +87,6 @@ function parseServerMessage(responseText?: string): string | null {
83
87
  }
84
88
  }
85
89
 
86
- type BundleSnapshot = {
87
- bundleUrl: string | null
88
- entry: string | null
89
- deviceSpec: InitRequestBody['deviceSpec']
90
- runtimeVersion: string | null
91
- pageHref: string
92
- transformedBundle: {
93
- url: string
94
- byteLength: number
95
- timestamp: number
96
- } | null
97
- }
98
-
99
90
  type AttachedInteractionSnapshot = {
100
91
  meta: NonNullable<InitRequestBody['snapshots']>[number] & { id: string }
101
92
  bytes: Uint8Array
@@ -181,10 +172,10 @@ export function resolvePublicPreviewOrigin(
181
172
 
182
173
  function printHelp() {
183
174
  console.log(`
184
- peach record upload — publish the current recorded bundle as a /preview/<id> link
175
+ peach record upload — publish a recording as a /preview/<id> link
185
176
 
186
177
  usage:
187
- peach record upload [--origin <url>] [--public-origin <url>] (--events <path> | --video <path>) [--screenshot <path>] [--owner <org> --repo <repo>] [--sim <sim>] [--open] [--assets-only]
178
+ peach record upload [--origin <url>] [--public-origin <url>] (--events <path> | --video <path> | --scene <path>) [--live-bundle] [--screenshot <path>] [--owner <org> --repo <repo>] [--sim <sim>] [--open] [--assets-only]
188
179
 
189
180
  options:
190
181
  --origin <url> upload target (default: ${REMOTE_DEFAULT_ORIGIN})
@@ -194,8 +185,8 @@ options:
194
185
  for prod uploads, upload origin for localhost/custom origins).
195
186
  override with PEACH_PREVIEW_ORIGIN env var
196
187
  --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
188
+ required unless --video or --scene is present;
189
+ previews must have replay or recording playback data
199
190
  --screenshot <path>
200
191
  path to a PNG thumbnail to attach to the share
201
192
  --snapshot-manifest <path>
@@ -205,6 +196,11 @@ options:
205
196
  /api/preview/flow-video?id=<share-id>
206
197
  --video-duration-ms <ms>
207
198
  duration hint for attached flow video
199
+ --scene <path> a scene recording (.psr.gz) of a remote viewer's display
200
+ stream. the preview paints it through the simulator's
201
+ renderer instead of playing a video; uploads without a
202
+ live bundle by default
203
+ --live-bundle include the running app bundle for Live alongside --scene
208
204
  --timeline-events <path>
209
205
  extra trace timeline rows, JSONL or JSONL gz
210
206
  --trace-profile <path>
@@ -436,6 +432,13 @@ export async function runUpload(
436
432
  const screenshotPath = extractStringFlag(mutableArgs, '--screenshot')
437
433
  const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest')
438
434
  const videoPath = extractStringFlag(mutableArgs, '--video')
435
+ const scenePath = extractStringFlag(mutableArgs, '--scene')
436
+ const includeLiveBundle = extractBoolFlag(mutableArgs, '--live-bundle')
437
+ if (includeLiveBundle && !scenePath) {
438
+ console.error(' --live-bundle requires --scene')
439
+ peachExit(1)
440
+ }
441
+ const hasBundle = includeLiveBundle || !scenePath
439
442
  const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events')
440
443
  const traceProfilePath = extractStringFlag(mutableArgs, '--trace-profile')
441
444
  const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle')
@@ -468,9 +471,9 @@ export async function runUpload(
468
471
  console.error(' --billing-kind test_run is only for hosted test jobs')
469
472
  peachExit(1)
470
473
  }
471
- if (!eventsPath && !videoPath) {
474
+ if (!eventsPath && !videoPath && !scenePath) {
472
475
  console.error(
473
- ' preview uploads require playback data: pass --events or --video.\n' +
476
+ ' preview uploads require playback data: pass --events, --video or --scene.\n' +
474
477
  ' for a normal local preview, use `peach record --mode combined --open`\n' +
475
478
  ' or `peach maestro test --preview` instead of uploading a bundle-only snapshot.',
476
479
  )
@@ -493,6 +496,7 @@ export async function runUpload(
493
496
  })
494
497
 
495
498
  const bridge = createBridgeFromParsed(parsed)
499
+ let shellUrl: string | null = null
496
500
  let snapshot: BundleSnapshot
497
501
  // bundle-origin manifest (no bodies) — CLI re-fetches these over http so
498
502
  // they can be large without blowing the ws frame cap. sibling bundle
@@ -513,6 +517,7 @@ export async function runUpload(
513
517
  const localTraceTimelineCount = traceTimelineEvents.length
514
518
  let transformedBundleText: string | null = null
515
519
  try {
520
+ if (scenePath) shellUrl = await evalInBridge<string>(bridge, 'location.href')
516
521
  snapshot = await evalInBridge<BundleSnapshot>(
517
522
  bridge,
518
523
  '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
@@ -660,7 +665,7 @@ export async function runUpload(
660
665
  }
661
666
  }
662
667
  }
663
- if (snapshot?.transformedBundle) {
668
+ if (hasBundle && snapshot?.transformedBundle) {
664
669
  transformedBundleText = await pullTransformedBundle(
665
670
  bridge,
666
671
  snapshot.transformedBundle.byteLength,
@@ -703,7 +708,7 @@ export async function runUpload(
703
708
  )
704
709
  peachExit(2)
705
710
  }
706
- if (!snapshot.bundleUrl) {
711
+ if (hasBundle && !snapshot.bundleUrl) {
707
712
  console.error(
708
713
  ' no ?bundle= URL on the current peach tab.\n' +
709
714
  ' open the app you want to share first (e.g. peach open 8082), then run upload.',
@@ -716,15 +721,15 @@ export async function runUpload(
716
721
  // does not have to re-run sootsim's transforms. fall back to re-fetching
717
722
  // the raw metro URL + forcing lazy=false when the transform hasn't
718
723
  // published yet (e.g., tab was opened but bundle not finished loading).
719
- let bundleBytes: Uint8Array
724
+ let bundleBytes: Uint8Array | undefined
720
725
  let isTransformed = false
721
- if (transformedBundleText !== null) {
726
+ if (hasBundle && transformedBundleText !== null) {
722
727
  bundleBytes = new TextEncoder().encode(transformedBundleText)
723
728
  isTransformed = true
724
729
  console.log(
725
730
  ` using post-transform bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`,
726
731
  )
727
- } else {
732
+ } else if (hasBundle && snapshot.bundleUrl) {
728
733
  const fetchBundleUrl = forceInlineBundle(snapshot.bundleUrl)
729
734
  if (fetchBundleUrl !== snapshot.bundleUrl) {
730
735
  console.log(` forcing lazy=false for self-contained bundle`)
@@ -872,6 +877,17 @@ export async function runUpload(
872
877
  )
873
878
  }
874
879
 
880
+ let sceneBytes: Uint8Array | undefined
881
+ let sceneStream: InitRequestBody['sceneStream']
882
+ if (scenePath) {
883
+ sceneBytes = readFileSync(scenePath)
884
+ const { header, slot } = await readSceneRecordingUpload(sceneBytes)
885
+ sceneStream = slot
886
+ console.log(
887
+ ` attaching scene stream: ${(sceneBytes.byteLength / 1024).toFixed(1)} KiB, ${header.frameCount} frames over ${Math.round(header.durationMs)}ms`,
888
+ )
889
+ }
890
+
875
891
  // priority: PEACH_API_KEY env → github runner token → saved credential
876
892
  // file → session token. github runner auth is what keeps installed PR
877
893
  // previews from requiring a new Contrast secret.
@@ -883,11 +899,26 @@ export async function runUpload(
883
899
  console.error(' set PEACH_API_KEY=sk_rnx_... or run `peach login`.')
884
900
  peachExit(1)
885
901
  }
902
+ if (sceneBytes && shellUrl && cliAuth.kind === 'github') {
903
+ const { renderSceneToVideo } = await import('../scene-video')
904
+ const output = path.join(tmpdir(), `peach-scene-video-${Date.now()}.mp4`)
905
+ try {
906
+ const rendered = await renderSceneToVideo({ scene: sceneBytes, shellUrl, output })
907
+ videoBytes = readFileSync(output)
908
+ videoContentType = 'video/mp4'
909
+ videoDurationMs = rendered.durationMs
910
+ console.log(
911
+ ` rendered scene video: ${rendered.frameCount} frames at 60 fps, ${rendered.sizeBytes} bytes in ${(rendered.renderTimeMs / 1000).toFixed(1)}s`,
912
+ )
913
+ } finally {
914
+ rmSync(output, { force: true })
915
+ }
916
+ }
886
917
  const resolvedAuthHeader = authHeaderValue(cliAuth)
887
918
 
888
919
  const bundleOrigin = (() => {
889
920
  try {
890
- return new URL(snapshot.bundleUrl).origin
921
+ return snapshot.bundleUrl ? new URL(snapshot.bundleUrl).origin : null
891
922
  } catch {
892
923
  return null
893
924
  }
@@ -1002,16 +1033,16 @@ export async function runUpload(
1002
1033
  // explicitly so the installed app keeps its identity in share replay.
1003
1034
  const appIdentityFiles: PreparedFile[] = []
1004
1035
  let appManifest: string | undefined
1005
- const metroOrigin = loopbackOriginOf(snapshot.bundleUrl)
1006
- if (metroOrigin) {
1036
+ const metroOrigin = snapshot.bundleUrl ? loopbackOriginOf(snapshot.bundleUrl) : null
1037
+ if (metroOrigin && snapshot.bundleUrl) {
1007
1038
  const platform = new URL(snapshot.bundleUrl).searchParams.get('platform') || 'ios'
1008
1039
  const manifestUrl = `${metroOrigin}/`
1009
1040
  const response = await fetch(manifestUrl, {
1010
1041
  headers: { 'expo-platform': platform, accept: 'application/json' },
1011
1042
  signal: AbortSignal.timeout(30_000),
1012
1043
  })
1013
- if (response.ok && response.headers.get('content-type')?.includes('json')) {
1014
- const manifest: unknown = await response.json()
1044
+ if (response.ok && !response.headers.get('content-type')?.includes('html')) {
1045
+ const manifest: unknown = parseExpoManifestBody(await response.text())
1015
1046
  const config = expoConfigFromManifest(manifest)
1016
1047
  if (config) {
1017
1048
  appManifest = manifestUrl
@@ -1085,19 +1116,31 @@ export async function runUpload(
1085
1116
  // brotli source bytes for the upload. finalize verifies the source hash,
1086
1117
  // minifies in the node build container, and stores one canonical brotli
1087
1118
  // bundle. assets stay gzip below.
1088
- const bundleBr = brotliBundle(bundleBytes)
1089
- const bundleSha256 = createHash('sha256').update(bundleBytes).digest('hex')
1090
- console.log(
1091
- ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`,
1092
- )
1119
+ const bundleBr = bundleBytes ? brotliBundle(bundleBytes) : undefined
1120
+ const identityBytes = bundleBytes ?? sceneBytes
1121
+ if (!identityBytes) throw new Error('upload requires a bundle or scene recording')
1122
+ const contentHash = createHash('sha256').update(identityBytes).digest('hex')
1123
+ if (bundleBytes && bundleBr) {
1124
+ console.log(
1125
+ ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`,
1126
+ )
1127
+ }
1093
1128
 
1094
1129
  const initBody: InitRequestBody = {
1095
- contentHash: bundleSha256,
1096
- bundleSizeBytes: bundleBytes.byteLength,
1097
- bundleContentType: guessBundleContentType(snapshot.bundleUrl),
1098
- bundleEncoding: 'br',
1099
- bundleMinified: false,
1100
- bundleOrigin,
1130
+ contentHash,
1131
+ hasBundle,
1132
+ ...(bundleBytes && snapshot.bundleUrl
1133
+ ? {
1134
+ bundleSizeBytes: bundleBytes.byteLength,
1135
+ bundleNativeAppIdentityHeader: snapshot.bundleNativeAppIdentityHeader,
1136
+ bundleContentType: guessBundleContentType(snapshot.bundleUrl),
1137
+ bundleEncoding: 'br',
1138
+ bundleMinified: false,
1139
+ bundleOrigin,
1140
+ entry: snapshot.entry,
1141
+ isTransformed,
1142
+ }
1143
+ : {}),
1101
1144
  runtimeVersion: snapshot.runtimeVersion ?? undefined,
1102
1145
  // config-only fonts resolved by `peach open` and carried on the engine
1103
1146
  // URL. persist them onto the share meta so /preview/<id> re-applies
@@ -1108,8 +1151,6 @@ export async function runUpload(
1108
1151
  // /preview/<id> re-applies `?appSplash=` and paints the same splash; the
1109
1152
  // image bytes are staged above so the engine's <img> resolves in preview.
1110
1153
  appSplash: appSplashWire || undefined,
1111
- entry: snapshot.entry,
1112
- isTransformed,
1113
1154
  deviceSpec: snapshot.deviceSpec,
1114
1155
  installationId: gitHubIdentity?.installationId ?? undefined,
1115
1156
  repoId: gitHubIdentity?.repoId,
@@ -1179,6 +1220,7 @@ export async function runUpload(
1179
1220
  durationMs: videoDurationMs,
1180
1221
  }
1181
1222
  : undefined,
1223
+ sceneStream,
1182
1224
  }
1183
1225
 
1184
1226
  const filesByHash = new Map<string, Uint8Array>()
@@ -1191,7 +1233,7 @@ export async function runUpload(
1191
1233
  }
1192
1234
 
1193
1235
  const putT0 = Date.now()
1194
- let putBytes = bundleBr.byteLength
1236
+ let putBytes = bundleBr?.byteLength ?? 0
1195
1237
  for (const f of prepared) putBytes += f.bytes.byteLength
1196
1238
  if (eventsGz) putBytes += eventsGz.byteLength
1197
1239
  putBytes += timelineGz.byteLength
@@ -1201,6 +1243,7 @@ export async function runUpload(
1201
1243
  for (const snapshot of interactionSnapshots) putBytes += snapshot.bytes.byteLength
1202
1244
  if (failureBundleGz) putBytes += failureBundleGz.byteLength
1203
1245
  if (videoBytes) putBytes += videoBytes.byteLength
1246
+ if (sceneBytes) putBytes += sceneBytes.byteLength
1204
1247
 
1205
1248
  const initUrl = `${normalizeOrigin(origin)}/api/preview/upload/init`
1206
1249
  console.log(` init: ${initUrl}`)
@@ -1217,6 +1260,7 @@ export async function runUpload(
1217
1260
  timelineBytes: timelineGz,
1218
1261
  storageBytes: storageGz,
1219
1262
  videoBytes,
1263
+ sceneStreamBytes: sceneBytes,
1220
1264
  failureBundleBytes: failureBundleGz,
1221
1265
  snapshotBytes,
1222
1266
  },
@@ -1225,13 +1269,14 @@ export async function runUpload(
1225
1269
  })
1226
1270
  finalize = result.finalize
1227
1271
  const totalObjects =
1228
- 1 +
1272
+ (result.init.bundle ? 1 : 0) +
1229
1273
  result.init.files.length +
1230
1274
  (result.init.events ? 1 : 0) +
1231
1275
  (result.init.timeline ? 1 : 0) +
1232
1276
  (result.init.storage ? 1 : 0) +
1233
1277
  result.init.snapshots.length +
1234
1278
  (result.init.flowVideo ? 1 : 0) +
1279
+ (result.init.sceneStream ? 1 : 0) +
1235
1280
  (result.init.failureBundle ? 1 : 0)
1236
1281
  console.log(
1237
1282
  ` 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,