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,5 +1,5 @@
1
1
  // peach record — drives the engine's built-in canvas recorder over
2
- // the WS bridge. no playwright, no ffmpeg. encoding happens in the
2
+ // the WS bridge. scene export opens an isolated viewer; encoding happens in the
3
3
  // running peach page (webm via MediaRecorder, mp4 via WebCodecs, gif
4
4
  // via gifenc, raw png frames via a shared bitmap sampler).
5
5
 
@@ -76,18 +76,41 @@ const STORE_RECORD_START_TIMEOUT_MS = 90_000
76
76
  // (bitmap-capture.ts), so recovery is simply to retry.
77
77
  const CAPTURE_STEP_ENCODE_BUDGET_MS = 30_000
78
78
 
79
+ // waits out a recording's duration as an in-flight bridge command. the host
80
+ // closes a cli socket that sends nothing for 60s, but never one with a command
81
+ // awaiting its sim, so a client-side sleep through a long capture dropped the
82
+ // connection before the encode/flush step could use it.
83
+ async function holdCapture(bridge: WsBridge, durationMs: number) {
84
+ await bridge.send(
85
+ { type: 'evaluate', code: `new Promise((r) => setTimeout(r, ${durationMs}))` },
86
+ { timeoutMs: durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS },
87
+ )
88
+ }
89
+
90
+ // an explicit --timeout is the step's budget. a step that records for the
91
+ // whole duration (a gif) gets that duration on top of it.
92
+ function captureStepTimeoutMs(
93
+ parsed: ParsedBridgeCliArgs,
94
+ durationMs: number,
95
+ spansCapture: boolean,
96
+ ): number {
97
+ if (!parsed.explicitTimeout)
98
+ return Math.max(20_000, durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS)
99
+ return spansCapture ? durationMs + parsed.commandTimeoutMs : parsed.commandTimeoutMs
100
+ }
101
+
79
102
  async function evalCaptureStep<T>(
80
103
  bridge: WsBridge,
81
104
  code: string,
82
105
  step: string,
83
106
  durationMs: number,
107
+ timeoutMs: number,
84
108
  ): Promise<T> {
85
- const timeoutMs = Math.max(20_000, durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS)
86
109
  try {
87
110
  return (await bridge.send({ type: 'evaluate', code }, { timeoutMs })) as T
88
111
  } catch (err) {
89
112
  const msg = err instanceof Error ? err.message : String(err)
90
- if (/^command timed out after \d+s$/.test(msg)) {
113
+ if (/^command timed out after \d+(?:\.\d+)?s$/.test(msg)) {
91
114
  console.error(
92
115
  ` ${step} stalled — the render host did not finish within ` +
93
116
  `${Math.round(timeoutMs / 1000)}s; it is likely contended or wedged.\n` +
@@ -143,6 +166,8 @@ const RECORD_VALUE_FLAGS = new Set([
143
166
  '--origin',
144
167
  '--owner',
145
168
  '--repo',
169
+ '--scene',
170
+ '--shell-url',
146
171
  ])
147
172
  const RECORD_SUBCOMMANDS = new Set([
148
173
  'start',
@@ -151,6 +176,7 @@ const RECORD_SUBCOMMANDS = new Set([
151
176
  'status',
152
177
  'prelude-start',
153
178
  'upload',
179
+ 'render',
154
180
  ])
155
181
 
156
182
  // the record subcommand (start/stop/cancel/status) is the first non-flag
@@ -195,6 +221,23 @@ export async function runRecord(args: string[], opts: RecordOptions) {
195
221
  // drop the subcommand token; the rest (incl. any --sim/--port) flows on
196
222
  // to the subcommand handlers, which parse bridge flags themselves.
197
223
  const rest = [...args.slice(0, sub.index), ...args.slice(sub.index + 1)]
224
+ if (sub.name === 'render') {
225
+ const scene = valueOf(rest, '--scene')
226
+ const shellUrl = valueOf(rest, '--shell-url')
227
+ const output = valueOf(rest, '--output')
228
+ if (!scene || !shellUrl || !output)
229
+ throw new Error('record render requires --scene, --shell-url and --output')
230
+ const { renderSceneToVideo } = await import('../scene-video')
231
+ const result = await renderSceneToVideo({
232
+ scene: readFileSync(scene),
233
+ shellUrl,
234
+ output,
235
+ })
236
+ console.log(
237
+ ` video: ${resolve(output)} (${result.frameCount} frames at 60 fps, ${result.sizeBytes} bytes, rendered in ${(result.renderTimeMs / 1000).toFixed(1)}s)`,
238
+ )
239
+ return
240
+ }
198
241
  if (sub.name === 'start') {
199
242
  await recordStart(rest, opts)
200
243
  return
@@ -222,6 +265,8 @@ export async function runRecord(args: string[], opts: RecordOptions) {
222
265
 
223
266
  const parsed = parseBridgeCliArgs(args, {
224
267
  port: opts.port,
268
+ // an explicit --timeout wins over the default
269
+ commandTimeoutMs: 60_000,
225
270
  stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'],
226
271
  stripValueFlags: [
227
272
  '--mode',
@@ -336,7 +381,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
336
381
  originOverride,
337
382
  allowGitHubAuth: true,
338
383
  })
339
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
384
+ const bridge = createBridgeFromParsed(parsed)
340
385
  try {
341
386
  await assertStoreBridgesAvailable(bridge)
342
387
  await assertGuestBundleLoaded(bridge, mode)
@@ -356,7 +401,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
356
401
  peachExit(1)
357
402
  }
358
403
  console.log(` recording ${mode} for ${durationSec}s`)
359
- await new Promise((r) => setTimeout(r, durationMs))
404
+ await holdCapture(bridge, durationMs)
360
405
  await evalStoreStop(bridge)
361
406
  const result = await pollStoreUpload(bridge)
362
407
  handleStoreUploadResult(result, openAfter, mode)
@@ -378,7 +423,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
378
423
  )
379
424
  }
380
425
 
381
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
426
+ const bridge = createBridgeFromParsed(parsed)
382
427
  try {
383
428
  await assertRecorderAvailable(bridge)
384
429
  await assertTabVisibleForCapture(bridge)
@@ -414,7 +459,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
414
459
  console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`)
415
460
  peachExit(1)
416
461
  }
417
- await new Promise((r) => setTimeout(r, durationMs))
462
+ await holdCapture(bridge, durationMs)
418
463
  const deadline = Date.now() + Math.max(5_000, durationMs)
419
464
  // frames come back a few at a time: one message holding every base64 png
420
465
  // exceeds the bridge socket's message cap and closes it.
@@ -470,6 +515,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
470
515
  }${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`,
471
516
  'gif encode',
472
517
  durationMs,
518
+ captureStepTimeoutMs(parsed, durationMs, true),
473
519
  )
474
520
  if (!result) {
475
521
  console.error(' gif capture returned no frames')
@@ -503,6 +549,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
503
549
  `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
504
550
  `${format} start`,
505
551
  durationMs,
552
+ captureStepTimeoutMs(parsed, durationMs, false),
506
553
  )
507
554
  if (!startResult.ok) {
508
555
  console.error(` start failed: ${startResult.error ?? 'unknown error'}`)
@@ -523,7 +570,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
523
570
  }
524
571
 
525
572
  console.log(` recording ${format} for ${durationSec}s → ${out}`)
526
- await new Promise((r) => setTimeout(r, durationMs))
573
+ await holdCapture(bridge, durationMs)
527
574
 
528
575
  const stopResult: {
529
576
  ok: boolean
@@ -537,6 +584,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
537
584
  `window.__sootsimRecorder.stop()`,
538
585
  `${format} encode/flush`,
539
586
  durationMs,
587
+ captureStepTimeoutMs(parsed, durationMs, false),
540
588
  )
541
589
  if (!stopResult.ok) {
542
590
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
@@ -570,11 +618,11 @@ async function probeSim(bridge: WsBridge, code: string): Promise<boolean> {
570
618
  try {
571
619
  return await bridge.send(
572
620
  { type: 'evaluate', code },
573
- { timeoutMs: SIM_PROBE_TIMEOUT_MS },
621
+ { defaultTimeoutMs: SIM_PROBE_TIMEOUT_MS },
574
622
  )
575
623
  } catch (err) {
576
624
  const msg = err instanceof Error ? err.message : String(err)
577
- if (/^command timed out after \d+s$/.test(msg)) {
625
+ if (/^command timed out after \d+(?:\.\d+)?s$/.test(msg)) {
578
626
  console.error(
579
627
  ' sim did not respond — it is connected to the bridge but has not\n' +
580
628
  ' loaded an app (or its page is unresponsive). run `peach list`\n' +
@@ -766,7 +814,7 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
766
814
  ...parsed,
767
815
  simId: existing.simId ?? parsed.simId,
768
816
  simIdSource: existing.simId ? 'saved' : parsed.simIdSource,
769
- commandTimeoutMs: 8_000,
817
+ commandTimeoutMs: parsed.explicitTimeout ? parsed.commandTimeoutMs : 8_000,
770
818
  })
771
819
  try {
772
820
  const sims = await bridge.listSims()
@@ -788,8 +836,12 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
788
836
  }
789
837
 
790
838
  async function recordStart(args: string[], opts: RecordOptions) {
839
+ const mode = parseMode(valueOf(args, '--mode'))
791
840
  const parsed = parseBridgeCliArgs(args, {
792
841
  port: opts.port,
842
+ // an explicit --timeout wins over the per-mode default
843
+ commandTimeoutMs:
844
+ mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
793
845
  stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'],
794
846
  stripValueFlags: [
795
847
  '--mode',
@@ -803,7 +855,6 @@ async function recordStart(args: string[], opts: RecordOptions) {
803
855
  ],
804
856
  })
805
857
  await refuseLiveRecording(parsed)
806
- const mode = parseMode(valueOf(args, '--mode'))
807
858
  const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
808
859
  ? 'shell'
809
860
  : args.includes('--no-shell')
@@ -820,11 +871,19 @@ async function recordStart(args: string[], opts: RecordOptions) {
820
871
  const fps = Number(valueOf(args, '--fps') ?? '30')
821
872
  const repoScope = readRepoScope(args)
822
873
 
823
- const bridge = createBridgeFromParsed({
824
- ...parsed,
825
- commandTimeoutMs:
826
- mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
827
- })
874
+ // the session is recorded before the engine is asked to start, so a start
875
+ // that times out on this side while the engine records stays stoppable. a
876
+ // refusal clears it.
877
+ const session: RecordingState = {
878
+ simId: parsed.simId ?? null,
879
+ mode,
880
+ format,
881
+ fps,
882
+ layers,
883
+ startedAt: new Date().toISOString(),
884
+ }
885
+
886
+ const bridge = createBridgeFromParsed(parsed)
828
887
  try {
829
888
  if (mode === 'live' || mode === 'combined') {
830
889
  // live mode captures events only — --no-shell / --shell-only have no
@@ -849,6 +908,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
849
908
  await assertGuestBundleLoaded(bridge, mode)
850
909
  if (mode === 'combined') await assertTabVisibleForCapture(bridge)
851
910
  await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope)
911
+ writeRecordingState(session)
852
912
  const started = await evalStoreStart(
853
913
  bridge,
854
914
  mode,
@@ -856,6 +916,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
856
916
  mode === 'combined' ? layers : undefined,
857
917
  )
858
918
  if (!started.ok) {
919
+ clearRecordingState()
859
920
  console.error(
860
921
  ` start failed: recording store refused to start (${mode})` +
861
922
  (started.error ? `: ${started.error}` : ''),
@@ -878,23 +939,17 @@ async function recordStart(args: string[], opts: RecordOptions) {
878
939
  } = { format, fps }
879
940
  if (layers) startOpts.layers = layers
880
941
  if (lockstep) startOpts.lockstep = true
942
+ writeRecordingState(session)
881
943
  const result: { ok: boolean; error?: string } = await bridge.send({
882
944
  type: 'evaluate',
883
945
  code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
884
946
  })
885
947
  if (!result.ok) {
948
+ clearRecordingState()
886
949
  console.error(` start failed: ${result.error ?? 'unknown error'}`)
887
950
  peachExit(1)
888
951
  }
889
952
  }
890
- writeRecordingState({
891
- simId: parsed.simId ?? null,
892
- mode,
893
- format,
894
- fps,
895
- layers,
896
- startedAt: new Date().toISOString(),
897
- })
898
953
  if (mode === 'video') {
899
954
  console.log(
900
955
  ` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`peach record stop --output <path>\` when done`,
@@ -916,7 +971,7 @@ async function recordPreludeStart(args: string[], opts: RecordOptions) {
916
971
  stripValueFlags: ['--origin'],
917
972
  })
918
973
  await refuseLiveRecording(parsed)
919
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 })
974
+ const bridge = createBridgeFromParsed(parsed)
920
975
  try {
921
976
  await assertStoreBridgesAvailable(bridge)
922
977
  await assertTabVisibleForCapture(bridge)
@@ -1049,8 +1104,8 @@ async function recordStatus(args: string[], opts: RecordOptions) {
1049
1104
  }
1050
1105
  // no `record start` session — but an atomic run or the rail button may
1051
1106
  // still be recording. ask the engine before claiming nothing is happening.
1052
- const parsed = parseBridgeCliArgs(args, { port: opts.port })
1053
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 8_000 })
1107
+ const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 8_000 })
1108
+ const bridge = createBridgeFromParsed(parsed)
1054
1109
  try {
1055
1110
  const engineState = await probeEngineRecordingState(bridge)
1056
1111
  if (
@@ -1091,11 +1146,11 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1091
1146
  if (!state) {
1092
1147
  // no `record start` session to cancel — but check the engine so we
1093
1148
  // don't falsely claim nothing is happening while an atomic run records.
1094
- const probeParsed = parseBridgeCliArgs(args, { port: opts.port })
1095
- const probeBridge = createBridgeFromParsed({
1096
- ...probeParsed,
1149
+ const probeParsed = parseBridgeCliArgs(args, {
1150
+ port: opts.port,
1097
1151
  commandTimeoutMs: 8_000,
1098
1152
  })
1153
+ const probeBridge = createBridgeFromParsed(probeParsed)
1099
1154
  try {
1100
1155
  const engineState = await probeEngineRecordingState(probeBridge)
1101
1156
  if (
@@ -1132,18 +1187,14 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1132
1187
  }
1133
1188
  return
1134
1189
  }
1135
- const parsed = parseBridgeCliArgs(args, { port: opts.port })
1190
+ const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 15_000 })
1136
1191
  noteSimMismatch(parsed.simId, state)
1137
1192
  // the recorder (and its buffered blob) lives in the page of the sim the
1138
1193
  // recording was *started* on — `state.simId` wins over the requested
1139
1194
  // --sim, otherwise cancel would stop an idle recorder on the wrong sim
1140
1195
  // and leave the real recording running (F11-1).
1141
1196
  const simId = state.simId ?? parsed.simId ?? undefined
1142
- const bridge = createBridgeFromParsed({
1143
- ...parsed,
1144
- simId,
1145
- commandTimeoutMs: 15_000,
1146
- })
1197
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1147
1198
  try {
1148
1199
  if (state.mode === 'live' || state.mode === 'combined') {
1149
1200
  await bridge.send({
@@ -1174,6 +1225,9 @@ async function recordStop(args: string[], opts: RecordOptions) {
1174
1225
 
1175
1226
  const parsed = parseBridgeCliArgs(args, {
1176
1227
  port: opts.port,
1228
+ // an explicit --timeout wins over the per-mode default
1229
+ commandTimeoutMs:
1230
+ state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1177
1231
  stripBooleanFlags: ['--open'],
1178
1232
  stripValueFlags: ['--output'],
1179
1233
  })
@@ -1184,15 +1238,17 @@ async function recordStop(args: string[], opts: RecordOptions) {
1184
1238
  const simId = state.simId ?? parsed.simId ?? undefined
1185
1239
  const openAfter = args.includes('--open')
1186
1240
 
1187
- const bridge = createBridgeFromParsed({
1188
- ...parsed,
1189
- simId,
1190
- commandTimeoutMs:
1191
- state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1192
- })
1241
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1193
1242
  try {
1194
1243
  if (state.mode === 'live' || state.mode === 'combined') {
1195
- await evalStoreStop(bridge)
1244
+ if (!(await evalStoreStop(bridge))) {
1245
+ clearRecordingState()
1246
+ console.error(
1247
+ ` stop failed: the ${state.mode} start still in flight never began a take, so there is nothing to upload.\n` +
1248
+ ' run `peach record start` again.',
1249
+ )
1250
+ peachExit(1)
1251
+ }
1196
1252
  const result = await pollStoreUpload(bridge)
1197
1253
  clearRecordingState()
1198
1254
  handleStoreUploadResult(result, openAfter, state.mode)
@@ -1215,7 +1271,17 @@ async function recordStop(args: string[], opts: RecordOptions) {
1215
1271
  })
1216
1272
  if (!stopResult.ok) {
1217
1273
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
1218
- clearRecordingState()
1274
+ // a start that timed out on the client can still be on its way to the
1275
+ // engine. keep the state so a later stop finds the recording instead of
1276
+ // leaving it running with nothing to stop it.
1277
+ if (stopResult.error === 'not recording') {
1278
+ console.error(
1279
+ ' the start may not have reached the engine yet. retry `peach record stop`,\n' +
1280
+ ' or run `peach record cancel` to give up on it.',
1281
+ )
1282
+ } else {
1283
+ clearRecordingState()
1284
+ }
1219
1285
  peachExit(1)
1220
1286
  }
1221
1287
  if (!stopResult.size) {
@@ -1376,11 +1442,15 @@ async function evalStoreStart(
1376
1442
  return { ok: false, error: error ?? null }
1377
1443
  }
1378
1444
 
1379
- async function evalStoreStop(bridge: WsBridge): Promise<void> {
1380
- await bridge.send({
1445
+ // the engine stop waits for a start still in flight. false means that start
1446
+ // never began a take, so no upload will come and the store's last one belongs
1447
+ // to an earlier take.
1448
+ async function evalStoreStop(bridge: WsBridge): Promise<boolean> {
1449
+ const stopped: boolean | null = await bridge.send({
1381
1450
  type: 'evaluate',
1382
- code: `void window.SootSim.bridges.stopRecording()`,
1451
+ code: `window.SootSim.bridges.stopRecording()`,
1383
1452
  })
1453
+ return stopped !== false
1384
1454
  }
1385
1455
 
1386
1456
  interface StoreUploadResult {
@@ -140,7 +140,15 @@ examples:
140
140
  // --id / --text, missing device model). surface them as a one-line
141
141
  // failure instead of an uncaught internal stack trace.
142
142
  try {
143
- await captureViaBridge(parsed, outputArg, rect, withFrame, layers, allowLoading)
143
+ await captureViaBridge(
144
+ parsed,
145
+ outputArg,
146
+ rect,
147
+ withFrame,
148
+ layers,
149
+ allowLoading,
150
+ opts.verbose === true || args.includes('--verbose') || args.includes('-v'),
151
+ )
144
152
  } catch (err) {
145
153
  console.error(
146
154
  ` screenshot failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -206,7 +214,16 @@ async function captureViaBridge(
206
214
  withFrame: boolean,
207
215
  layers: 'full' | 'tenant' | 'shell' | undefined,
208
216
  allowLoading: boolean,
217
+ verbose: boolean,
209
218
  ) {
219
+ // --verbose names the phase a slow capture spent its time in.
220
+ const phases: string[] = []
221
+ let phaseStart = Date.now()
222
+ const phase = (name: string) => {
223
+ const now = Date.now()
224
+ phases.push(`${name} ${now - phaseStart}ms`)
225
+ phaseStart = now
226
+ }
210
227
  // 45s, not 10s: a first-paint capture on a headless CI runner with software
211
228
  // GL (SwiftShader) is bounded but slow. forceRenderAll caps at ~6s (two
212
229
  // 3s-bounded passes) and the WebGL→2D drawImage readback + PNG encode of a
@@ -225,6 +242,7 @@ async function captureViaBridge(
225
242
  // affects screenshot fidelity directly, so call it out explicitly so the
226
243
  // user doesn't accept a stale frame as ground truth.
227
244
  const health = await checkSimHealth(bridge)
245
+ phase('health')
228
246
  if (health.hidden) {
229
247
  process.stderr.write(
230
248
  ' ⚠ screenshot will capture the LAST PAINTED canvas — likely stale.\n' +
@@ -267,6 +285,7 @@ async function captureViaBridge(
267
285
  }
268
286
  }
269
287
 
288
+ phase('ready')
270
289
  let resolvedRect: SampleRect | null = rect
271
290
  const matcher = (rect as { __matcher?: { id?: string; text?: string } })?.__matcher
272
291
  if (matcher) {
@@ -297,6 +316,7 @@ async function captureViaBridge(
297
316
  if (layers) request.layers = layers
298
317
  if (cropRect) request.crop = cropRect
299
318
  const dataUrl: string = await bridge.send(request)
319
+ phase('capture')
300
320
  const pngPrefix = 'data:image/png;base64,'
301
321
  // never write a 0-byte "saved" file from an empty or malformed payload —
302
322
  // the sim may be alive but not painting (check `peach get errors`)
@@ -326,6 +346,8 @@ async function captureViaBridge(
326
346
  } else {
327
347
  await writeScreenshotOutput(outputPath, rawBuffer)
328
348
  }
349
+ phase('write')
350
+ if (verbose) console.log(` timing: ${phases.join(', ')}`)
329
351
  console.log(` saved: ${outputPath}`)
330
352
  } finally {
331
353
  bridge.close()
@@ -8,7 +8,10 @@
8
8
  // run in the foreground (ctrl-c to stop). the persistent launchd/systemd
9
9
  // daemon installation is handled by `peach daemon install`.
10
10
 
11
- import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
11
+ import {
12
+ assertPeachPortEnvironment,
13
+ DEFAULT_SOOTSIM_BRIDGE_PORT,
14
+ } from '../../src/bridge-constants'
12
15
  import {
13
16
  ensureRnxHome,
14
17
  isDaemonLockfileFresh,
@@ -58,6 +61,7 @@ examples:
58
61
  peachExit(0)
59
62
  }
60
63
 
64
+ assertPeachPortEnvironment(process.env)
61
65
  const portArgIdx = args.indexOf('--port')
62
66
  const port =
63
67
  portArgIdx >= 0 && args[portArgIdx + 1]
@@ -147,7 +151,8 @@ examples:
147
151
  if (sims.length > 0) lastUsedAt = Date.now()
148
152
  else if (
149
153
  starterGoneAt &&
150
- Date.now() - Math.max(starterGoneAt, lastUsedAt, host.lastRequestAt) >= UNUSED_EXIT_MS
154
+ Date.now() - Math.max(starterGoneAt, lastUsedAt, host.lastRequestAt) >=
155
+ UNUSED_EXIT_MS
151
156
  ) {
152
157
  void shutdown('unused')
153
158
  return
@@ -17,7 +17,7 @@ import {
17
17
  writePrivacyPreferences,
18
18
  } from '../../src/home-paths'
19
19
  import { rnxRuntime } from '../../src/runtime-delivery'
20
- import { findDesktopCompanion } from '../desktop-companion'
20
+ import { findDesktopCompanion, nativeDesktopIsDefault } from '../desktop-companion'
21
21
  import { isCi, PRIVACY_NOTICE } from '../privacy'
22
22
  import { confirm, select } from '../prompt'
23
23
  import {
@@ -155,12 +155,7 @@ export async function runSetup(
155
155
  serviceInstalled = true
156
156
  }
157
157
 
158
- if (
159
- simulatorDriver !== 'playwright' &&
160
- process.platform !== 'darwin' &&
161
- !options.dryRun &&
162
- (simulatorDriver === 'desktop' || !findDesktopCompanion({ native: false }))
163
- ) {
158
+ if (simulatorDriver !== 'playwright' && !options.dryRun && !findDesktopCompanion()) {
164
159
  await runInstallDesktop(
165
160
  simulatorDriver === 'desktop' ? ['--yes'] : ['--yes', '--electron'],
166
161
  )
@@ -272,7 +267,7 @@ function readSimulatorDriverFlag(args: string[]): SimulatorDriverPreference | un
272
267
  if (desktop && browser) {
273
268
  throw new Error('--desktop and --browser cannot be used together')
274
269
  }
275
- if (desktop) return desktopHostPlatform() === 'mac-arm64' ? 'desktop' : 'electron'
270
+ if (desktop) return desktopHostPlatform() ? 'desktop' : 'electron'
276
271
  if (browser) return 'playwright'
277
272
  return undefined
278
273
  }
@@ -331,46 +326,29 @@ async function decideService(
331
326
  return confirm('Enable the background service?', true)
332
327
  }
333
328
 
329
+ // the native app is the simulator window wherever it ships, and setup installs
330
+ // it without asking. Windows chooses between the browser and its Electron app.
334
331
  async function decideSimulatorDriver(
335
332
  options: SetupOptions,
336
333
  ): Promise<SimulatorDriverPreference> {
337
- if (process.platform === 'darwin' && !findDesktopCompanion()) {
338
- if (options.simulatorDriver && options.simulatorDriver !== 'playwright') {
339
- throw new Error('install the Mac app with `peach desktop install --native` first')
340
- }
341
- return 'playwright'
342
- }
343
334
  if (options.simulatorDriver) return options.simulatorDriver
344
335
  const current = readSimulatorDriverPreference()
345
336
  if (current) return current
337
+ if (isCi()) return 'playwright'
338
+ if (nativeDesktopIsDefault()) return 'desktop'
339
+ if (process.platform !== 'win32') return 'playwright'
346
340
  if (options.ci || options.yes || options.dryRun) return 'playwright'
347
341
  requireInteractive(options, '--desktop or --browser')
348
- const desktopDescription =
349
- process.platform === 'darwin'
350
- ? 'Desktop opens your installed native runtime (beta).'
351
- : process.platform === 'win32'
352
- ? 'Desktop downloads and runs the peach installer.'
353
- : 'Desktop installs the native .deb with sudo apt-get and needs a desktop session.'
354
- const desktopChoice =
355
- process.platform === 'darwin'
356
- ? 'Native app (beta) Faster, may have more issues'
357
- : process.platform === 'win32'
358
- ? 'Desktop app Runs the peach installer'
359
- : 'Native app (beta) Faster, may have more issues'
360
342
  printPromptBlock('Simulator window', [
361
343
  'Browser opens immediately in an isolated Chrome window.',
362
- desktopDescription,
363
- 'The Desktop app opens in a dedicated native window.',
344
+ 'Desktop downloads and runs the peach installer.',
345
+ 'The Desktop app opens in a dedicated window.',
364
346
  ])
365
347
  const choice = await select('Where should peach open?', [
366
348
  'Browser (default) No additional download',
367
- desktopChoice,
349
+ 'Desktop app Runs the peach installer',
368
350
  ])
369
- return choice === 0
370
- ? 'playwright'
371
- : process.platform === 'darwin'
372
- ? 'desktop'
373
- : 'electron'
351
+ return choice === 0 ? 'playwright' : 'electron'
374
352
  }
375
353
 
376
354
  // diagnostics and crash reports are on unless turned off; setup says so once
@@ -433,15 +411,14 @@ function printIntro(options: SetupOptions): void {
433
411
  if (options.json) return
434
412
  printBrand('Run any React Native app in a local simulator. No project setup required.')
435
413
  if (desktopHostPlatform()) {
436
- printPromptBlock('Optional native runtime (beta)', [
437
- 'Try the native runtime for faster simulation. It is beta, so you may run into more issues.',
438
- 'Install the app: peach desktop install',
439
- 'Open your app: peach open <port> --driver desktop',
440
- 'Open a browser window: peach open <port> --driver playwright --headed',
414
+ printPromptBlock('Native desktop app', [
415
+ 'peach opens your app in its native desktop app, which is faster than the browser.',
416
+ 'Setup installs it, and peach open <port> uses it.',
417
+ 'Open a browser window instead: peach open <port> --driver playwright --headed',
441
418
  ...(process.platform === 'linux'
442
- ? ['Linux desktop installation uses sudo apt-get and needs a desktop session.']
419
+ ? ['On Linux the app installs with sudo apt-get and needs a desktop session.']
443
420
  : []),
444
- 'The background service is a separate choice that keeps the bridge ready for either runtime.',
421
+ 'The background service is a separate choice that keeps the bridge ready for either window.',
445
422
  ])
446
423
  }
447
424
  }
@@ -451,7 +428,7 @@ function printHelp(): void {
451
428
  peach setup - configure optional local conveniences
452
429
 
453
430
  peach works without project setup. This command can enable the faster background
454
- service, choose Browser or Desktop, configure private diagnostics, and optionally
431
+ service, install the native desktop app, configure private diagnostics, and optionally
455
432
  add an exact peachsim dependency plus a peach script for teammates.
456
433
 
457
434
  usage:
@@ -5,6 +5,7 @@ import { WebSocket, WebSocketServer } from 'ws'
5
5
  import { resolveContrastBackendOrigin } from '../../src/backend-origin'
6
6
  import { produceConfiguredBundle } from '../../src/configured-bundle-producer'
7
7
  import { swiftGuestBundle } from '../../src/guest-bundle'
8
+ import { insertNativeAppIdentityFooter } from '../../src/native-app-identity'
8
9
  import {
9
10
  resolveSwiftModule,
10
11
  SWIFT_ENDPOINT_ENV,
@@ -73,6 +74,11 @@ export async function runSwift(
73
74
  } else if (arg === '--scheme') {
74
75
  scheme = input[++index]
75
76
  if (!scheme || scheme.startsWith('-')) throw new Error('--scheme requires a value')
77
+ } else if (arg === '--swift-sdks-path') {
78
+ const value = input[++index]
79
+ if (!value || value.startsWith('-'))
80
+ throw new Error('--swift-sdks-path requires a value')
81
+ process.env.PEACH_SWIFT_SDKS_PATH = path.resolve(value)
76
82
  } else if (arg === '--host-port' || valueFlags.has(arg)) {
77
83
  const value = input[++index]
78
84
  if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`)
@@ -163,7 +169,9 @@ export async function runSwift(
163
169
  const packageName = path.basename(packagePath)
164
170
  const appInfo = host.swiftProjectAppInfo(packagePath)
165
171
  const guestSource = swiftGuestBundle(packageName)
166
- let guest = guestSource
172
+ const withIdentity = (source: string) =>
173
+ appInfo.identity ? insertNativeAppIdentityFooter(source, appInfo.identity) : source
174
+ let guest = withIdentity(guestSource)
167
175
  const controller = new AbortController()
168
176
  const resources: OpenCommandResources = {}
169
177
  let metadata: (SwiftEntryModuleArgs & { hmrPath: string }) | null = null
@@ -187,7 +195,9 @@ export async function runSwift(
187
195
  try {
188
196
  const config = await loadRNXAppConfig(appProject)
189
197
  host.configureSwiftStandinBehavior(packagePath, config)
190
- guest = await produceConfiguredBundle(guestSource, config, new Set())
198
+ guest = withIdentity(
199
+ await produceConfiguredBundle(guestSource, config, new Set()),
200
+ )
191
201
  const next = await host.swiftPackageMetadata(
192
202
  packagePath,
193
203
  packageName,
@@ -343,7 +353,8 @@ export async function runSwift(
343
353
  console.log(` Swift preview host: ${origin}`)
344
354
  await refresh()
345
355
  controller.signal.throwIfAborted()
346
- if (!metadata || compileError) throw new Error(compileError)
356
+ // refresh already printed the error
357
+ if (!metadata || compileError) throw new Error('Swift compilation failed, see above')
347
358
  if (opts.device) openArgs.push('--device', opts.device)
348
359
  if (opts.driver) openArgs.push('--driver', opts.driver)
349
360
  if (opts.headless) openArgs.push('--headless')