peachsim 0.1.1635 → 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 (129) hide show
  1. package/README.md +5 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +62 -13
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/commands/doctor.ts +13 -5
  7. package/cli/commands/flow.ts +1 -1
  8. package/cli/commands/inspect/shared.ts +9 -4
  9. package/cli/commands/inspect.ts +65 -47
  10. package/cli/commands/preview.ts +1 -1
  11. package/cli/commands/record.ts +97 -47
  12. package/cli/commands/swift.ts +9 -3
  13. package/cli/commands/upload.ts +48 -42
  14. package/cli/drivers/playwright-sim-host.ts +65 -42
  15. package/cli/main.ts +1 -1
  16. package/cli/outbound-endpoints.ts +7 -5
  17. package/cli/pause-aware-timeout.ts +4 -0
  18. package/cli/ws-bridge.ts +19 -10
  19. package/detox/attempt-events.cjs +62 -5
  20. package/detox/attempt-events.d.cts +7 -0
  21. package/detox/expectations.ts +7 -3
  22. package/detox/index.ts +121 -47
  23. package/detox/motion-change.cjs +170 -21
  24. package/detox/motion-change.d.cts +32 -0
  25. package/detox/navigation-lifecycle.ts +35 -3
  26. package/dist-lib/agent-daemon-client.cjs +1 -1
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +1 -1
  30. package/dist-lib/backend-origin.cjs +8 -7
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +4038 -1983
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +1 -1
  46. package/dist-lib/cloud.mjs +1 -1
  47. package/dist-lib/config.cjs +1 -1
  48. package/dist-lib/config.mjs +1 -1
  49. package/dist-lib/detox/index.cjs +509 -86
  50. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  51. package/dist-lib/expo-swift-bundle.cjs +3551 -1574
  52. package/dist-lib/expo-swift-metadata.cjs +9 -7
  53. package/dist-lib/expo-swift-metadata.mjs +9 -7
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +43633 -2635
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +2748 -128
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +1 -1
  66. package/dist-lib/menu.mjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +1 -1
  70. package/dist-lib/metro-production-bundle.mjs +1 -1
  71. package/dist-lib/metro.cjs +2547 -83
  72. package/dist-lib/native-app-identity-project.cjs +237 -50
  73. package/dist-lib/native-app-identity-project.mjs +237 -50
  74. package/dist-lib/native-app-identity.cjs +29 -30
  75. package/dist-lib/native-app-identity.mjs +29 -30
  76. package/dist-lib/native-firebase-metadata.cjs +1 -1
  77. package/dist-lib/native-firebase-metadata.mjs +1 -1
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +1 -1
  82. package/dist-lib/react-native-host-modules.cjs +2 -2
  83. package/dist-lib/react-native-host-modules.mjs +2 -2
  84. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  85. package/dist-lib/sdk.cjs +1 -1
  86. package/dist-lib/sdk.mjs +1 -1
  87. package/dist-lib/skills.cjs +65403 -22739
  88. package/dist-lib/swift-compile.cjs +93 -32
  89. package/dist-lib/swift-compile.mjs +93 -32
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +7 -5
  93. package/dist-lib/swift-submit.mjs +7 -5
  94. package/dist-lib/swift-toolchain.cjs +316 -41
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +7335 -5432
  97. package/dist-lib/vite.cjs +2857 -12
  98. package/dist-lib/worklet-executable.cjs +14 -2
  99. package/dist-lib/worklet-executable.mjs +14 -2
  100. package/package.json +1 -1
  101. package/src/backend-origin.ts +13 -6
  102. package/src/expo-swift-host.ts +5 -2
  103. package/src/expoKotlinContract.ts +4 -7
  104. package/src/expoSwiftContract.ts +4 -6
  105. package/src/host/staged-host-files.ts +9 -0
  106. package/src/native-app-identity-project.ts +1 -4
  107. package/src/native-app-identity.ts +1 -4
  108. package/src/nativeAppIconContract.ts +1 -4
  109. package/src/output-lock-stdio.ts +36 -0
  110. package/src/react-native-bridge-modules.ts +21 -6
  111. package/src/react-native-host-modules.ts +4 -1
  112. package/src/settings-bundle.ts +1 -4
  113. package/src/swift-compile.ts +91 -33
  114. package/src/swift-distribution.lock.json +22 -12
  115. package/src/swift-distribution.ts +103 -4
  116. package/src/swift-host.ts +89 -26
  117. package/src/swift-icu.ts +16 -10
  118. package/src/swift-objc-compile.ts +355 -0
  119. package/src/swift-objc-sdk.ts +689 -18
  120. package/src/swift-objc-standin.ts +126 -14
  121. package/src/swift-package-manifest.ts +39 -1
  122. package/src/swift-packages.ts +35 -21
  123. package/src/swift-standin-emit.ts +40 -7
  124. package/src/swift-standins.ts +404 -138
  125. package/src/swift-submit.ts +3 -5
  126. package/src/swift-toolchain.ts +378 -46
  127. package/src/worker-system-chunks.ts +11 -12
  128. package/src/worklet-executable.ts +19 -1
  129. package/src/xcode-project.ts +270 -30
@@ -1,6 +1,7 @@
1
1
  // peach inspect — dump node tree, accessibility info, query nodes
2
2
  // connects to the peach WS bridge (no Playwright)
3
3
 
4
+ import { Script } from 'node:vm'
4
5
  import { getCliInspectVerbMeta } from '@peach/skills/cli'
5
6
  import { renderCliCommandHelp, renderCliGroupHelp } from '@peach/skills/cli/renderers'
6
7
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
@@ -194,6 +195,8 @@ async function performSingleStep(
194
195
  }
195
196
  }
196
197
 
198
+ // runs after a command already spent its whole budget, so an explicit --timeout
199
+ // must not make the liveness check wait that long a second time
197
200
  async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
198
201
  try {
199
202
  await bridge.send({ type: 'evaluate', code: '1' }, { timeoutMs: 3000 })
@@ -210,6 +213,18 @@ async function reportMissingTarget(
210
213
  verb: 'tap' | 'double-tap' | 'long-press',
211
214
  target: { mode: 'testid' | 'text'; value: string },
212
215
  ): Promise<void> {
216
+ if (target.mode === 'testid') {
217
+ // the tree can hold the id while the engine has no point to press: the node
218
+ // is not painted yet, or was replaced since the query. say so, so the
219
+ // suggestion list never names the id that was just reported missing.
220
+ const exact = await inspectFind(bridge, { testId: target.value }).catch(() => null)
221
+ if (exact?.result) {
222
+ console.error(
223
+ ` found ${target.value} in the tree but it has no tap target yet; it may be unpainted, hidden or covered: wait for it with peach wait selector ${target.value}`,
224
+ )
225
+ return
226
+ }
227
+ }
213
228
  console.error(` not found: ${target.value}`)
214
229
  if (target.mode !== 'testid') return
215
230
  const byText = await inspectFind(bridge, { text: target.value }).catch(() => null)
@@ -858,11 +873,10 @@ async function waitForKeyboardVisible(
858
873
  }
859
874
 
860
875
  /**
861
- * keys for a CPU simulator. it has no keyboard bridge to probe and refuses the
862
- * `keyboard` command, so its `perform` presses each character against
863
- * whatever holds focus and fails the batch when nothing does.
876
+ * dispatch through the shared input executor, which checks the requested
877
+ * node identity before targeted typing and carries refusals out of the CLI.
864
878
  */
865
- async function performCloudKeys(
879
+ async function performKeys(
866
880
  bridge: WsBridge,
867
881
  steps: Array<Extract<PerformStep, { type: 'type' | 'typeInto' | 'key' }>>,
868
882
  actionLabel: string,
@@ -2462,7 +2476,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2462
2476
  console.error(' type-into requires a resolved text input node identity')
2463
2477
  peachExit(1)
2464
2478
  }
2465
- const typed = await performCloudKeys(
2479
+ const typed = await performKeys(
2466
2480
  bridge,
2467
2481
  [{ type: 'typeInto', text, nodeId }],
2468
2482
  'type-into',
@@ -2500,6 +2514,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2500
2514
  return {
2501
2515
  cx: n.absolutePosition.x + (n.layout.width || 0) / 2,
2502
2516
  cy: n.absolutePosition.y + (n.layout.height || 0) / 2,
2517
+ nodeId: n.nodeId,
2503
2518
  id: n.id,
2504
2519
  testID: n.testID,
2505
2520
  type: n.type,
@@ -2514,7 +2529,13 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2514
2529
  peachExit(1)
2515
2530
  }
2516
2531
  if (!tiPayload.isTextInput) {
2517
- console.error(` warning: ${targetId} is not a text input (isTextInput: false)`)
2532
+ console.error(` ${targetId} is not a text input (isTextInput: false)`)
2533
+ peachExit(1)
2534
+ }
2535
+ const nodeId: unknown = tiPayload.nodeId
2536
+ if (typeof nodeId !== 'number' || !Number.isSafeInteger(nodeId) || nodeId === 0) {
2537
+ console.error(' type-into requires a resolved text input node identity')
2538
+ peachExit(1)
2518
2539
  }
2519
2540
  // step 2: tap to focus
2520
2541
  const tapResult = await bridge.send({
@@ -2522,12 +2543,17 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2522
2543
  x: tiPayload.cx,
2523
2544
  y: tiPayload.cy,
2524
2545
  target: {
2546
+ nodeId,
2525
2547
  id: tiPayload.id ?? targetId,
2526
2548
  testID: tiPayload.testID ?? targetId,
2527
2549
  text: null,
2528
2550
  type: tiPayload.type ?? null,
2529
2551
  },
2530
2552
  })
2553
+ if (!isTapSuccess(tapResult)) {
2554
+ console.error(` type-into could not tap ${targetId}`)
2555
+ peachExit(1)
2556
+ }
2531
2557
  const targetSecureTextEntry = tiPayload.secureTextEntry === true
2532
2558
  const keyboardState = await waitForKeyboardVisible(bridge, 1000, {
2533
2559
  targetId,
@@ -2558,38 +2584,26 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2558
2584
  peachExit(1)
2559
2585
  }
2560
2586
  }
2561
- // step 3: clear existing text then type through the visible keyboard.
2562
- // count the field's value, masked while secure. the node's described
2563
- // text is its placeholder while empty, and the keyboard spec's text
2564
- // follows only predictive candidates.
2565
- const drawnCharacters = async () => {
2566
- const count: unknown = await bridge.send({
2567
- type: 'evaluate',
2568
- code: `(async () => {
2569
- const text = await window.__sootsimTest?.getTextInputValue(${tiArg})
2570
- return text ? [...new Intl.Segmenter().segment(text)].length : 0
2571
- })()`,
2572
- })
2573
- if (typeof count !== 'number') {
2574
- console.error(` could not read the current text of ${targetId}`)
2575
- peachExit(1)
2576
- }
2577
- return count
2578
- }
2579
- const existingCharacters = await drawnCharacters()
2580
- for (let i = 0; i < existingCharacters; i++) {
2581
- await bridge.send({ type: 'keyboard', action: 'press', text: 'delete' })
2582
- }
2583
- // backspace clears only what precedes the caret the tap placed
2584
- if (existingCharacters > 0 && (await drawnCharacters()) > 0) {
2585
- console.error(
2586
- ` type-into could not clear ${targetId}: text remains after the caret the tap placed`,
2587
- )
2587
+ // type at the live selection through the same guarded operation as
2588
+ // chained and cloud typing; clearing first changes controlled values.
2589
+ const typed = await performKeys(
2590
+ bridge,
2591
+ [{ type: 'typeInto', text, nodeId }],
2592
+ 'type-into',
2593
+ )
2594
+ const receipt: unknown = typed.steps[0]?.value
2595
+ if (
2596
+ !receipt ||
2597
+ typeof receipt !== 'object' ||
2598
+ typeof Reflect.get(receipt, 'secureTextEntry') !== 'boolean'
2599
+ ) {
2600
+ console.error(' type-into did not return its matched input secure state')
2588
2601
  peachExit(1)
2589
2602
  }
2590
- await bridge.send({ type: 'keyboard', action: 'type', text })
2591
2603
  const secureTextEntry =
2592
- targetSecureTextEntry || isSecureKeyboardState(keyboardState as any)
2604
+ targetSecureTextEntry ||
2605
+ Reflect.get(receipt, 'secureTextEntry') === true ||
2606
+ isSecureKeyboardState(keyboardState as any)
2593
2607
  const outputText = secureTextEntry
2594
2608
  ? SECURE_TEXT_REDACTION
2595
2609
  : redactTextForKeyboardState(text, keyboardState as any)
@@ -2629,7 +2643,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2629
2643
  peachExit(1)
2630
2644
  }
2631
2645
  if (bridge.plane === 'cloud') {
2632
- await performCloudKeys(bridge, [{ type: 'type', text }], 'type')
2646
+ await performKeys(bridge, [{ type: 'type', text }], 'type')
2633
2647
  await recordInspectAction(
2634
2648
  'inspect type',
2635
2649
  { inputText: text },
@@ -2663,7 +2677,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2663
2677
  peachExit(1)
2664
2678
  }
2665
2679
  if (bridge.plane === 'cloud') {
2666
- await performCloudKeys(bridge, [{ type: 'key', key: name }], 'key')
2680
+ await performKeys(bridge, [{ type: 'key', key: name }], 'key')
2667
2681
  } else {
2668
2682
  await requireVisualKeyboard(bridge, 'key')
2669
2683
  await bridge.send({ type: 'keyboard', action: 'press', text: name })
@@ -2680,7 +2694,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
2680
2694
  peachExit(1)
2681
2695
  }
2682
2696
  if (bridge.plane === 'cloud') {
2683
- await performCloudKeys(
2697
+ await performKeys(
2684
2698
  bridge,
2685
2699
  keys.map((key) => ({ type: 'key' as const, key })),
2686
2700
  'key-sequence',
@@ -3717,13 +3731,17 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3717
3731
  // wrap in async to support await in worker mode. no identifier
3718
3732
  // rewriting — the snippet runs verbatim so tokens like `root`,
3719
3733
  // `debug`, `test` refer to whatever the user actually typed.
3720
- // multi-statement snippets need a block body: the bare expression
3721
- // form terminates at the first `;` (Unexpected token).
3734
+ // compile without executing to distinguish expressions from statements;
3735
+ // punctuation inside callbacks, literals and comments is not a separator.
3722
3736
  let expandedCode = code
3723
3737
  if (!expandedCode.startsWith('(async')) {
3724
- expandedCode = /[;\n]/.test(expandedCode)
3725
- ? `(async () => { ${expandedCode} })()`
3726
- : `(async () => ${expandedCode})()`
3738
+ expandedCode = `(async () => (\n${code}\n))()`
3739
+ try {
3740
+ new Script(expandedCode)
3741
+ } catch (error) {
3742
+ if (!(error instanceof SyntaxError)) throw error
3743
+ expandedCode = `(async () => {\n${code}\n})()`
3744
+ }
3727
3745
  }
3728
3746
  const result = await bridge.send({ type: 'evaluate', code: expandedCode })
3729
3747
  printJson(result)
@@ -3959,11 +3977,11 @@ export async function runInspect(args: string[], opts: InspectOptions) {
3959
3977
  case 'start': {
3960
3978
  const result = await bridge.send({
3961
3979
  type: 'evaluate',
3962
- code: `(() => {
3980
+ code: `(async () => {
3963
3981
  if (!window.__sootsimShellPerf) {
3964
3982
  return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
3965
3983
  }
3966
- window.__sootsimShellPerf.start()
3984
+ await window.__sootsimShellPerf.start()
3967
3985
  return { started: true }
3968
3986
  })()`,
3969
3987
  })
@@ -4038,7 +4056,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4038
4056
  if (!window.__sootsimShellPerf) {
4039
4057
  return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
4040
4058
  }
4041
- window.__sootsimShellPerf.start()
4059
+ await window.__sootsimShellPerf.start()
4042
4060
  // give a frame for profiling to engage before the event
4043
4061
  await new Promise(r => requestAnimationFrame(() => r(undefined)))
4044
4062
  window.dispatchEvent(new Event('${eventName}'))
@@ -4626,7 +4644,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
4626
4644
  // re-incurs the full per-command timeout. three sequential probes turn a
4627
4645
  // single 15s timeout into ~60s of apparent hang (QA F19-1). there is
4628
4646
  // nothing useful to fetch from a sim that just timed out, so skip them.
4629
- const timedOut = /^command timed out after (\d+)s$/.exec(message)
4647
+ const timedOut = /^command timed out after (\d+(?:\.\d+)?)s$/.exec(message)
4630
4648
  // a disconnected bridge is genuinely gone; recover is right and probing
4631
4649
  // it would just re-incur a full timeout. no bridge at all means there is
4632
4650
  // no sim to recover, only one to open.
@@ -88,7 +88,7 @@ export async function runPreview(args: string[]): Promise<number> {
88
88
  bundleSizeBytes: artifact.bytes.byteLength,
89
89
  bundleContentType: 'application/javascript',
90
90
  bundleEncoding: 'br',
91
- bundleMinified: true,
91
+ bundleMinified: artifact.minified,
92
92
  bundleOrigin: null,
93
93
  entry: null,
94
94
  isTransformed: false,
@@ -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` +
@@ -242,6 +265,8 @@ export async function runRecord(args: string[], opts: RecordOptions) {
242
265
 
243
266
  const parsed = parseBridgeCliArgs(args, {
244
267
  port: opts.port,
268
+ // an explicit --timeout wins over the default
269
+ commandTimeoutMs: 60_000,
245
270
  stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'],
246
271
  stripValueFlags: [
247
272
  '--mode',
@@ -356,7 +381,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
356
381
  originOverride,
357
382
  allowGitHubAuth: true,
358
383
  })
359
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
384
+ const bridge = createBridgeFromParsed(parsed)
360
385
  try {
361
386
  await assertStoreBridgesAvailable(bridge)
362
387
  await assertGuestBundleLoaded(bridge, mode)
@@ -376,7 +401,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
376
401
  peachExit(1)
377
402
  }
378
403
  console.log(` recording ${mode} for ${durationSec}s`)
379
- await new Promise((r) => setTimeout(r, durationMs))
404
+ await holdCapture(bridge, durationMs)
380
405
  await evalStoreStop(bridge)
381
406
  const result = await pollStoreUpload(bridge)
382
407
  handleStoreUploadResult(result, openAfter, mode)
@@ -398,7 +423,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
398
423
  )
399
424
  }
400
425
 
401
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
426
+ const bridge = createBridgeFromParsed(parsed)
402
427
  try {
403
428
  await assertRecorderAvailable(bridge)
404
429
  await assertTabVisibleForCapture(bridge)
@@ -434,7 +459,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
434
459
  console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`)
435
460
  peachExit(1)
436
461
  }
437
- await new Promise((r) => setTimeout(r, durationMs))
462
+ await holdCapture(bridge, durationMs)
438
463
  const deadline = Date.now() + Math.max(5_000, durationMs)
439
464
  // frames come back a few at a time: one message holding every base64 png
440
465
  // exceeds the bridge socket's message cap and closes it.
@@ -490,6 +515,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
490
515
  }${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`,
491
516
  'gif encode',
492
517
  durationMs,
518
+ captureStepTimeoutMs(parsed, durationMs, true),
493
519
  )
494
520
  if (!result) {
495
521
  console.error(' gif capture returned no frames')
@@ -523,6 +549,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
523
549
  `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
524
550
  `${format} start`,
525
551
  durationMs,
552
+ captureStepTimeoutMs(parsed, durationMs, false),
526
553
  )
527
554
  if (!startResult.ok) {
528
555
  console.error(` start failed: ${startResult.error ?? 'unknown error'}`)
@@ -543,7 +570,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
543
570
  }
544
571
 
545
572
  console.log(` recording ${format} for ${durationSec}s → ${out}`)
546
- await new Promise((r) => setTimeout(r, durationMs))
573
+ await holdCapture(bridge, durationMs)
547
574
 
548
575
  const stopResult: {
549
576
  ok: boolean
@@ -557,6 +584,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
557
584
  `window.__sootsimRecorder.stop()`,
558
585
  `${format} encode/flush`,
559
586
  durationMs,
587
+ captureStepTimeoutMs(parsed, durationMs, false),
560
588
  )
561
589
  if (!stopResult.ok) {
562
590
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
@@ -590,11 +618,11 @@ async function probeSim(bridge: WsBridge, code: string): Promise<boolean> {
590
618
  try {
591
619
  return await bridge.send(
592
620
  { type: 'evaluate', code },
593
- { timeoutMs: SIM_PROBE_TIMEOUT_MS },
621
+ { defaultTimeoutMs: SIM_PROBE_TIMEOUT_MS },
594
622
  )
595
623
  } catch (err) {
596
624
  const msg = err instanceof Error ? err.message : String(err)
597
- if (/^command timed out after \d+s$/.test(msg)) {
625
+ if (/^command timed out after \d+(?:\.\d+)?s$/.test(msg)) {
598
626
  console.error(
599
627
  ' sim did not respond — it is connected to the bridge but has not\n' +
600
628
  ' loaded an app (or its page is unresponsive). run `peach list`\n' +
@@ -786,7 +814,7 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
786
814
  ...parsed,
787
815
  simId: existing.simId ?? parsed.simId,
788
816
  simIdSource: existing.simId ? 'saved' : parsed.simIdSource,
789
- commandTimeoutMs: 8_000,
817
+ commandTimeoutMs: parsed.explicitTimeout ? parsed.commandTimeoutMs : 8_000,
790
818
  })
791
819
  try {
792
820
  const sims = await bridge.listSims()
@@ -808,8 +836,12 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
808
836
  }
809
837
 
810
838
  async function recordStart(args: string[], opts: RecordOptions) {
839
+ const mode = parseMode(valueOf(args, '--mode'))
811
840
  const parsed = parseBridgeCliArgs(args, {
812
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,
813
845
  stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'],
814
846
  stripValueFlags: [
815
847
  '--mode',
@@ -823,7 +855,6 @@ async function recordStart(args: string[], opts: RecordOptions) {
823
855
  ],
824
856
  })
825
857
  await refuseLiveRecording(parsed)
826
- const mode = parseMode(valueOf(args, '--mode'))
827
858
  const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
828
859
  ? 'shell'
829
860
  : args.includes('--no-shell')
@@ -840,11 +871,19 @@ async function recordStart(args: string[], opts: RecordOptions) {
840
871
  const fps = Number(valueOf(args, '--fps') ?? '30')
841
872
  const repoScope = readRepoScope(args)
842
873
 
843
- const bridge = createBridgeFromParsed({
844
- ...parsed,
845
- commandTimeoutMs:
846
- mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
847
- })
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)
848
887
  try {
849
888
  if (mode === 'live' || mode === 'combined') {
850
889
  // live mode captures events only — --no-shell / --shell-only have no
@@ -869,6 +908,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
869
908
  await assertGuestBundleLoaded(bridge, mode)
870
909
  if (mode === 'combined') await assertTabVisibleForCapture(bridge)
871
910
  await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope)
911
+ writeRecordingState(session)
872
912
  const started = await evalStoreStart(
873
913
  bridge,
874
914
  mode,
@@ -876,6 +916,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
876
916
  mode === 'combined' ? layers : undefined,
877
917
  )
878
918
  if (!started.ok) {
919
+ clearRecordingState()
879
920
  console.error(
880
921
  ` start failed: recording store refused to start (${mode})` +
881
922
  (started.error ? `: ${started.error}` : ''),
@@ -898,23 +939,17 @@ async function recordStart(args: string[], opts: RecordOptions) {
898
939
  } = { format, fps }
899
940
  if (layers) startOpts.layers = layers
900
941
  if (lockstep) startOpts.lockstep = true
942
+ writeRecordingState(session)
901
943
  const result: { ok: boolean; error?: string } = await bridge.send({
902
944
  type: 'evaluate',
903
945
  code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
904
946
  })
905
947
  if (!result.ok) {
948
+ clearRecordingState()
906
949
  console.error(` start failed: ${result.error ?? 'unknown error'}`)
907
950
  peachExit(1)
908
951
  }
909
952
  }
910
- writeRecordingState({
911
- simId: parsed.simId ?? null,
912
- mode,
913
- format,
914
- fps,
915
- layers,
916
- startedAt: new Date().toISOString(),
917
- })
918
953
  if (mode === 'video') {
919
954
  console.log(
920
955
  ` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`peach record stop --output <path>\` when done`,
@@ -936,7 +971,7 @@ async function recordPreludeStart(args: string[], opts: RecordOptions) {
936
971
  stripValueFlags: ['--origin'],
937
972
  })
938
973
  await refuseLiveRecording(parsed)
939
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 })
974
+ const bridge = createBridgeFromParsed(parsed)
940
975
  try {
941
976
  await assertStoreBridgesAvailable(bridge)
942
977
  await assertTabVisibleForCapture(bridge)
@@ -1069,8 +1104,8 @@ async function recordStatus(args: string[], opts: RecordOptions) {
1069
1104
  }
1070
1105
  // no `record start` session — but an atomic run or the rail button may
1071
1106
  // still be recording. ask the engine before claiming nothing is happening.
1072
- const parsed = parseBridgeCliArgs(args, { port: opts.port })
1073
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 8_000 })
1107
+ const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 8_000 })
1108
+ const bridge = createBridgeFromParsed(parsed)
1074
1109
  try {
1075
1110
  const engineState = await probeEngineRecordingState(bridge)
1076
1111
  if (
@@ -1111,11 +1146,11 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1111
1146
  if (!state) {
1112
1147
  // no `record start` session to cancel — but check the engine so we
1113
1148
  // don't falsely claim nothing is happening while an atomic run records.
1114
- const probeParsed = parseBridgeCliArgs(args, { port: opts.port })
1115
- const probeBridge = createBridgeFromParsed({
1116
- ...probeParsed,
1149
+ const probeParsed = parseBridgeCliArgs(args, {
1150
+ port: opts.port,
1117
1151
  commandTimeoutMs: 8_000,
1118
1152
  })
1153
+ const probeBridge = createBridgeFromParsed(probeParsed)
1119
1154
  try {
1120
1155
  const engineState = await probeEngineRecordingState(probeBridge)
1121
1156
  if (
@@ -1152,18 +1187,14 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1152
1187
  }
1153
1188
  return
1154
1189
  }
1155
- const parsed = parseBridgeCliArgs(args, { port: opts.port })
1190
+ const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 15_000 })
1156
1191
  noteSimMismatch(parsed.simId, state)
1157
1192
  // the recorder (and its buffered blob) lives in the page of the sim the
1158
1193
  // recording was *started* on — `state.simId` wins over the requested
1159
1194
  // --sim, otherwise cancel would stop an idle recorder on the wrong sim
1160
1195
  // and leave the real recording running (F11-1).
1161
1196
  const simId = state.simId ?? parsed.simId ?? undefined
1162
- const bridge = createBridgeFromParsed({
1163
- ...parsed,
1164
- simId,
1165
- commandTimeoutMs: 15_000,
1166
- })
1197
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1167
1198
  try {
1168
1199
  if (state.mode === 'live' || state.mode === 'combined') {
1169
1200
  await bridge.send({
@@ -1194,6 +1225,9 @@ async function recordStop(args: string[], opts: RecordOptions) {
1194
1225
 
1195
1226
  const parsed = parseBridgeCliArgs(args, {
1196
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,
1197
1231
  stripBooleanFlags: ['--open'],
1198
1232
  stripValueFlags: ['--output'],
1199
1233
  })
@@ -1204,15 +1238,17 @@ async function recordStop(args: string[], opts: RecordOptions) {
1204
1238
  const simId = state.simId ?? parsed.simId ?? undefined
1205
1239
  const openAfter = args.includes('--open')
1206
1240
 
1207
- const bridge = createBridgeFromParsed({
1208
- ...parsed,
1209
- simId,
1210
- commandTimeoutMs:
1211
- state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1212
- })
1241
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1213
1242
  try {
1214
1243
  if (state.mode === 'live' || state.mode === 'combined') {
1215
- 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
+ }
1216
1252
  const result = await pollStoreUpload(bridge)
1217
1253
  clearRecordingState()
1218
1254
  handleStoreUploadResult(result, openAfter, state.mode)
@@ -1235,7 +1271,17 @@ async function recordStop(args: string[], opts: RecordOptions) {
1235
1271
  })
1236
1272
  if (!stopResult.ok) {
1237
1273
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
1238
- 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
+ }
1239
1285
  peachExit(1)
1240
1286
  }
1241
1287
  if (!stopResult.size) {
@@ -1396,11 +1442,15 @@ async function evalStoreStart(
1396
1442
  return { ok: false, error: error ?? null }
1397
1443
  }
1398
1444
 
1399
- async function evalStoreStop(bridge: WsBridge): Promise<void> {
1400
- 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({
1401
1450
  type: 'evaluate',
1402
- code: `void window.SootSim.bridges.stopRecording()`,
1451
+ code: `window.SootSim.bridges.stopRecording()`,
1403
1452
  })
1453
+ return stopped !== false
1404
1454
  }
1405
1455
 
1406
1456
  interface StoreUploadResult {
@@ -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,
@@ -168,7 +169,9 @@ export async function runSwift(
168
169
  const packageName = path.basename(packagePath)
169
170
  const appInfo = host.swiftProjectAppInfo(packagePath)
170
171
  const guestSource = swiftGuestBundle(packageName)
171
- let guest = guestSource
172
+ const withIdentity = (source: string) =>
173
+ appInfo.identity ? insertNativeAppIdentityFooter(source, appInfo.identity) : source
174
+ let guest = withIdentity(guestSource)
172
175
  const controller = new AbortController()
173
176
  const resources: OpenCommandResources = {}
174
177
  let metadata: (SwiftEntryModuleArgs & { hmrPath: string }) | null = null
@@ -192,7 +195,9 @@ export async function runSwift(
192
195
  try {
193
196
  const config = await loadRNXAppConfig(appProject)
194
197
  host.configureSwiftStandinBehavior(packagePath, config)
195
- guest = await produceConfiguredBundle(guestSource, config, new Set())
198
+ guest = withIdentity(
199
+ await produceConfiguredBundle(guestSource, config, new Set()),
200
+ )
196
201
  const next = await host.swiftPackageMetadata(
197
202
  packagePath,
198
203
  packageName,
@@ -348,7 +353,8 @@ export async function runSwift(
348
353
  console.log(` Swift preview host: ${origin}`)
349
354
  await refresh()
350
355
  controller.signal.throwIfAborted()
351
- 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')
352
358
  if (opts.device) openArgs.push('--device', opts.device)
353
359
  if (opts.driver) openArgs.push('--driver', opts.driver)
354
360
  if (opts.headless) openArgs.push('--headless')