peachsim 0.1.1635 → 0.1.1641

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 (142) hide show
  1. package/README.md +10 -1
  2. package/cli/app-config.ts +14 -5
  3. package/cli/app-stubs.ts +96 -22
  4. package/cli/bridge-flow-runner.ts +2 -2
  5. package/cli/cloud-client.ts +60 -40
  6. package/cli/cloud-session.ts +16 -0
  7. package/cli/commands/assert.ts +17 -4
  8. package/cli/commands/box.ts +33 -19
  9. package/cli/commands/doctor.ts +37 -20
  10. package/cli/commands/flow.ts +1 -1
  11. package/cli/commands/inspect/actions.ts +3 -2
  12. package/cli/commands/inspect/core.ts +0 -8
  13. package/cli/commands/inspect/shared.ts +9 -4
  14. package/cli/commands/inspect.ts +65 -61
  15. package/cli/commands/platform.ts +2 -1
  16. package/cli/commands/preview.ts +1 -1
  17. package/cli/commands/record.ts +154 -68
  18. package/cli/commands/swift.ts +18 -4
  19. package/cli/commands/three-mode.ts +4 -38
  20. package/cli/commands/upload.ts +55 -49
  21. package/cli/drivers/playwright-sim-host.ts +65 -42
  22. package/cli/flow-live-status.ts +0 -2
  23. package/cli/main.ts +2 -2
  24. package/cli/outbound-endpoints.ts +7 -5
  25. package/cli/pause-aware-timeout.ts +4 -0
  26. package/cli/ws-bridge.ts +19 -16
  27. package/detox/attempt-events.cjs +99 -6
  28. package/detox/attempt-events.d.cts +7 -0
  29. package/detox/expectations.ts +7 -3
  30. package/detox/index.ts +259 -139
  31. package/detox/jest-environment.cjs +5 -1
  32. package/detox/motion-change.cjs +170 -21
  33. package/detox/motion-change.d.cts +32 -0
  34. package/detox/navigation-lifecycle.ts +35 -3
  35. package/dist-lib/agent-daemon-client.cjs +1 -1
  36. package/dist-lib/agent-events.cjs +1 -1
  37. package/dist-lib/attached-projects.cjs +1 -1
  38. package/dist-lib/auth/shared-session.cjs +1 -1
  39. package/dist-lib/backend-origin.cjs +17 -7
  40. package/dist-lib/beta.cjs +1 -1
  41. package/dist-lib/beta.mjs +1 -1
  42. package/dist-lib/bridge-constants.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.cjs +1 -1
  44. package/dist-lib/bridge-contract-input.mjs +1 -1
  45. package/dist-lib/bridge-contract.cjs +1 -1
  46. package/dist-lib/bridge-contract.mjs +1 -1
  47. package/dist-lib/bundle-channel.cjs +5014 -2268
  48. package/dist-lib/capture-contract.cjs +1 -1
  49. package/dist-lib/capture-contract.mjs +1 -1
  50. package/dist-lib/cloud-contract.cjs +1 -1
  51. package/dist-lib/cloud-contract.mjs +1 -1
  52. package/dist-lib/cloud-test-args.cjs +1 -1
  53. package/dist-lib/cloud-test-args.mjs +1 -1
  54. package/dist-lib/cloud.cjs +1 -1
  55. package/dist-lib/cloud.mjs +1 -1
  56. package/dist-lib/config.cjs +1 -1
  57. package/dist-lib/config.mjs +1 -1
  58. package/dist-lib/detox/index.cjs +1036 -166
  59. package/dist-lib/detox/jest-environment.cjs +5 -1
  60. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  61. package/dist-lib/expo-swift-bundle.cjs +4613 -1963
  62. package/dist-lib/expo-swift-metadata.cjs +9 -7
  63. package/dist-lib/expo-swift-metadata.mjs +9 -7
  64. package/dist-lib/home-paths.cjs +1 -1
  65. package/dist-lib/host/bridge-host.cjs +58072 -16400
  66. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  67. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  68. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  69. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  70. package/dist-lib/host/websocket-proxy.cjs +1 -1
  71. package/dist-lib/index.cjs +2790 -143
  72. package/dist-lib/jump-to-source-babel.cjs +1 -1
  73. package/dist-lib/jump-to-source-native.cjs +1 -1
  74. package/dist-lib/kotlin-host.cjs +1 -1
  75. package/dist-lib/menu.cjs +1 -1
  76. package/dist-lib/menu.mjs +1 -1
  77. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  78. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  79. package/dist-lib/metro-production-bundle.cjs +1 -1
  80. package/dist-lib/metro-production-bundle.mjs +1 -1
  81. package/dist-lib/metro.cjs +2579 -95
  82. package/dist-lib/native-app-identity-project.cjs +243 -53
  83. package/dist-lib/native-app-identity-project.mjs +243 -53
  84. package/dist-lib/native-app-identity.cjs +29 -30
  85. package/dist-lib/native-app-identity.mjs +29 -30
  86. package/dist-lib/native-firebase-metadata.cjs +1 -1
  87. package/dist-lib/native-firebase-metadata.mjs +1 -1
  88. package/dist-lib/plist.cjs +1 -1
  89. package/dist-lib/plist.mjs +1 -1
  90. package/dist-lib/profiles.cjs +1 -1
  91. package/dist-lib/public-brand.cjs +1 -1
  92. package/dist-lib/react-native-host-modules.cjs +2 -2
  93. package/dist-lib/react-native-host-modules.mjs +2 -2
  94. package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
  95. package/dist-lib/sdk.cjs +4 -3
  96. package/dist-lib/sdk.mjs +4 -3
  97. package/dist-lib/skills.cjs +66475 -23097
  98. package/dist-lib/swift-compile.cjs +452 -260
  99. package/dist-lib/swift-compile.mjs +451 -252
  100. package/dist-lib/swift-package.cjs +1 -1
  101. package/dist-lib/swift-package.mjs +1 -1
  102. package/dist-lib/swift-submit.cjs +14 -7
  103. package/dist-lib/swift-submit.mjs +14 -7
  104. package/dist-lib/swift-toolchain.cjs +316 -41
  105. package/dist-lib/swift.cjs +1 -1
  106. package/dist-lib/vite-swift.cjs +8188 -5636
  107. package/dist-lib/vite.cjs +2857 -12
  108. package/dist-lib/worklet-executable.cjs +14 -2
  109. package/dist-lib/worklet-executable.mjs +14 -2
  110. package/package.json +1 -1
  111. package/src/backend-origin.ts +21 -6
  112. package/src/expo-swift-host.ts +5 -2
  113. package/src/expoKotlinContract.ts +4 -7
  114. package/src/expoSwiftContract.ts +4 -6
  115. package/src/host/staged-host-files.ts +9 -0
  116. package/src/native-app-identity-project.ts +1 -4
  117. package/src/native-app-identity.ts +1 -4
  118. package/src/nativeAppIconContract.ts +1 -4
  119. package/src/output-lock-stdio.ts +36 -0
  120. package/src/react-native-bridge-modules.ts +106 -78
  121. package/src/react-native-host-modules.ts +4 -1
  122. package/src/settings-bundle.ts +1 -4
  123. package/src/swift-compile.ts +224 -139
  124. package/src/swift-distribution.lock.json +23 -12
  125. package/src/swift-distribution.ts +103 -4
  126. package/src/swift-host.ts +95 -30
  127. package/src/swift-icu.ts +16 -10
  128. package/src/swift-objc-compile.ts +547 -0
  129. package/src/swift-objc-metadata.ts +204 -0
  130. package/src/swift-objc-sdk.ts +883 -19
  131. package/src/swift-objc-standin.ts +126 -14
  132. package/src/swift-package-manifest.ts +42 -1
  133. package/src/swift-packages.ts +35 -21
  134. package/src/swift-root-lock.ts +153 -0
  135. package/src/swift-standin-emit.ts +40 -7
  136. package/src/swift-standins.ts +404 -138
  137. package/src/swift-submit.ts +18 -7
  138. package/src/swift-toolchain.ts +378 -46
  139. package/src/swiftContract.ts +12 -0
  140. package/src/worker-system-chunks.ts +11 -12
  141. package/src/worklet-executable.ts +19 -1
  142. package/src/xcode-project.ts +280 -33
@@ -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` +
@@ -145,6 +168,7 @@ const RECORD_VALUE_FLAGS = new Set([
145
168
  '--repo',
146
169
  '--scene',
147
170
  '--shell-url',
171
+ '--timeout',
148
172
  ])
149
173
  const RECORD_SUBCOMMANDS = new Set([
150
174
  'start',
@@ -242,6 +266,8 @@ export async function runRecord(args: string[], opts: RecordOptions) {
242
266
 
243
267
  const parsed = parseBridgeCliArgs(args, {
244
268
  port: opts.port,
269
+ // an explicit --timeout wins over the default
270
+ commandTimeoutMs: 60_000,
245
271
  stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'],
246
272
  stripValueFlags: [
247
273
  '--mode',
@@ -356,7 +382,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
356
382
  originOverride,
357
383
  allowGitHubAuth: true,
358
384
  })
359
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
385
+ const bridge = createBridgeFromParsed(parsed)
360
386
  try {
361
387
  await assertStoreBridgesAvailable(bridge)
362
388
  await assertGuestBundleLoaded(bridge, mode)
@@ -376,7 +402,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
376
402
  peachExit(1)
377
403
  }
378
404
  console.log(` recording ${mode} for ${durationSec}s`)
379
- await new Promise((r) => setTimeout(r, durationMs))
405
+ await holdCapture(bridge, durationMs)
380
406
  await evalStoreStop(bridge)
381
407
  const result = await pollStoreUpload(bridge)
382
408
  handleStoreUploadResult(result, openAfter, mode)
@@ -398,7 +424,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
398
424
  )
399
425
  }
400
426
 
401
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
427
+ const bridge = createBridgeFromParsed(parsed)
402
428
  try {
403
429
  await assertRecorderAvailable(bridge)
404
430
  await assertTabVisibleForCapture(bridge)
@@ -434,7 +460,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
434
460
  console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`)
435
461
  peachExit(1)
436
462
  }
437
- await new Promise((r) => setTimeout(r, durationMs))
463
+ await holdCapture(bridge, durationMs)
438
464
  const deadline = Date.now() + Math.max(5_000, durationMs)
439
465
  // frames come back a few at a time: one message holding every base64 png
440
466
  // exceeds the bridge socket's message cap and closes it.
@@ -490,6 +516,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
490
516
  }${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`,
491
517
  'gif encode',
492
518
  durationMs,
519
+ captureStepTimeoutMs(parsed, durationMs, true),
493
520
  )
494
521
  if (!result) {
495
522
  console.error(' gif capture returned no frames')
@@ -523,6 +550,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
523
550
  `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
524
551
  `${format} start`,
525
552
  durationMs,
553
+ captureStepTimeoutMs(parsed, durationMs, false),
526
554
  )
527
555
  if (!startResult.ok) {
528
556
  console.error(` start failed: ${startResult.error ?? 'unknown error'}`)
@@ -543,7 +571,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
543
571
  }
544
572
 
545
573
  console.log(` recording ${format} for ${durationSec}s → ${out}`)
546
- await new Promise((r) => setTimeout(r, durationMs))
574
+ await holdCapture(bridge, durationMs)
547
575
 
548
576
  const stopResult: {
549
577
  ok: boolean
@@ -557,6 +585,7 @@ export async function runRecord(args: string[], opts: RecordOptions) {
557
585
  `window.__sootsimRecorder.stop()`,
558
586
  `${format} encode/flush`,
559
587
  durationMs,
588
+ captureStepTimeoutMs(parsed, durationMs, false),
560
589
  )
561
590
  if (!stopResult.ok) {
562
591
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
@@ -590,11 +619,11 @@ async function probeSim(bridge: WsBridge, code: string): Promise<boolean> {
590
619
  try {
591
620
  return await bridge.send(
592
621
  { type: 'evaluate', code },
593
- { timeoutMs: SIM_PROBE_TIMEOUT_MS },
622
+ { defaultTimeoutMs: SIM_PROBE_TIMEOUT_MS },
594
623
  )
595
624
  } catch (err) {
596
625
  const msg = err instanceof Error ? err.message : String(err)
597
- if (/^command timed out after \d+s$/.test(msg)) {
626
+ if (/^command timed out after \d+(?:\.\d+)?s$/.test(msg)) {
598
627
  console.error(
599
628
  ' sim did not respond — it is connected to the bridge but has not\n' +
600
629
  ' loaded an app (or its page is unresponsive). run `peach list`\n' +
@@ -786,7 +815,7 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
786
815
  ...parsed,
787
816
  simId: existing.simId ?? parsed.simId,
788
817
  simIdSource: existing.simId ? 'saved' : parsed.simIdSource,
789
- commandTimeoutMs: 8_000,
818
+ commandTimeoutMs: parsed.explicitTimeout ? parsed.commandTimeoutMs : 8_000,
790
819
  })
791
820
  try {
792
821
  const sims = await bridge.listSims()
@@ -808,8 +837,12 @@ async function refuseLiveRecording(parsed: ParsedBridgeCliArgs) {
808
837
  }
809
838
 
810
839
  async function recordStart(args: string[], opts: RecordOptions) {
840
+ const mode = parseMode(valueOf(args, '--mode'))
811
841
  const parsed = parseBridgeCliArgs(args, {
812
842
  port: opts.port,
843
+ // an explicit --timeout wins over the per-mode default
844
+ commandTimeoutMs:
845
+ mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
813
846
  stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'],
814
847
  stripValueFlags: [
815
848
  '--mode',
@@ -823,7 +856,6 @@ async function recordStart(args: string[], opts: RecordOptions) {
823
856
  ],
824
857
  })
825
858
  await refuseLiveRecording(parsed)
826
- const mode = parseMode(valueOf(args, '--mode'))
827
859
  const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
828
860
  ? 'shell'
829
861
  : args.includes('--no-shell')
@@ -840,11 +872,19 @@ async function recordStart(args: string[], opts: RecordOptions) {
840
872
  const fps = Number(valueOf(args, '--fps') ?? '30')
841
873
  const repoScope = readRepoScope(args)
842
874
 
843
- const bridge = createBridgeFromParsed({
844
- ...parsed,
845
- commandTimeoutMs:
846
- mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
847
- })
875
+ // the session is recorded before the engine is asked to start, so a start
876
+ // that times out on this side while the engine records stays stoppable. a
877
+ // refusal clears it.
878
+ const session: RecordingState = {
879
+ simId: parsed.simId ?? null,
880
+ mode,
881
+ format,
882
+ fps,
883
+ layers,
884
+ startedAt: new Date().toISOString(),
885
+ }
886
+
887
+ const bridge = createBridgeFromParsed(parsed)
848
888
  try {
849
889
  if (mode === 'live' || mode === 'combined') {
850
890
  // live mode captures events only — --no-shell / --shell-only have no
@@ -869,6 +909,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
869
909
  await assertGuestBundleLoaded(bridge, mode)
870
910
  if (mode === 'combined') await assertTabVisibleForCapture(bridge)
871
911
  await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope)
912
+ writeRecordingState(session)
872
913
  const started = await evalStoreStart(
873
914
  bridge,
874
915
  mode,
@@ -876,6 +917,7 @@ async function recordStart(args: string[], opts: RecordOptions) {
876
917
  mode === 'combined' ? layers : undefined,
877
918
  )
878
919
  if (!started.ok) {
920
+ clearRecordingState()
879
921
  console.error(
880
922
  ` start failed: recording store refused to start (${mode})` +
881
923
  (started.error ? `: ${started.error}` : ''),
@@ -883,8 +925,6 @@ async function recordStart(args: string[], opts: RecordOptions) {
883
925
  peachExit(1)
884
926
  }
885
927
  } else {
886
- await assertRecorderAvailable(bridge)
887
- await assertTabVisibleForCapture(bridge)
888
928
  const lockstep = args.includes('--lockstep')
889
929
  if (lockstep && format !== 'mp4') {
890
930
  console.error(' --lockstep requires --format mp4 (webm is realtime-clocked)')
@@ -898,23 +938,21 @@ async function recordStart(args: string[], opts: RecordOptions) {
898
938
  } = { format, fps }
899
939
  if (layers) startOpts.layers = layers
900
940
  if (lockstep) startOpts.lockstep = true
941
+ writeRecordingState(session)
901
942
  const result: { ok: boolean; error?: string } = await bridge.send({
902
943
  type: 'evaluate',
903
- code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
944
+ code: `(() => {
945
+ if (!window.__sootsimRecorder) return { ok: false, error: 'recorder unavailable in this sim' };
946
+ if (document.hidden) return { ok: false, error: 'the peach tab is hidden; bring it to the foreground before recording' };
947
+ return window.__sootsimRecorder.start(${JSON.stringify(startOpts)});
948
+ })()`,
904
949
  })
905
950
  if (!result.ok) {
951
+ clearRecordingState()
906
952
  console.error(` start failed: ${result.error ?? 'unknown error'}`)
907
953
  peachExit(1)
908
954
  }
909
955
  }
910
- writeRecordingState({
911
- simId: parsed.simId ?? null,
912
- mode,
913
- format,
914
- fps,
915
- layers,
916
- startedAt: new Date().toISOString(),
917
- })
918
956
  if (mode === 'video') {
919
957
  console.log(
920
958
  ` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`peach record stop --output <path>\` when done`,
@@ -936,7 +974,7 @@ async function recordPreludeStart(args: string[], opts: RecordOptions) {
936
974
  stripValueFlags: ['--origin'],
937
975
  })
938
976
  await refuseLiveRecording(parsed)
939
- const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 })
977
+ const bridge = createBridgeFromParsed(parsed)
940
978
  try {
941
979
  await assertStoreBridgesAvailable(bridge)
942
980
  await assertTabVisibleForCapture(bridge)
@@ -987,7 +1025,13 @@ async function recordPreludeStart(args: string[], opts: RecordOptions) {
987
1025
  // - the event recorder alone drives hidden PR-preview preludes before a
988
1026
  // visible live/combined segment exists
989
1027
  // returns null when the engine can't be reached.
990
- type EngineRecordingState = 'idle' | 'recording' | 'processing' | 'prelude'
1028
+ type EngineRecordingState =
1029
+ | 'idle'
1030
+ | 'starting'
1031
+ | 'recording'
1032
+ | 'processing'
1033
+ | 'ready'
1034
+ | 'prelude'
991
1035
 
992
1036
  async function probeEngineRecordingState(
993
1037
  bridge: WsBridge,
@@ -1008,8 +1052,10 @@ async function probeEngineRecordingState(
1008
1052
  })()`,
1009
1053
  })
1010
1054
  if (!snap) return null
1055
+ if (snap.headless === 'starting') return 'starting'
1011
1056
  if (snap.store === 'recording' || snap.headless === 'recording') return 'recording'
1012
- if (snap.store === 'processing') return 'processing'
1057
+ if (snap.store === 'processing' || snap.headless === 'processing') return 'processing'
1058
+ if (snap.headless === 'ready') return 'ready'
1013
1059
  if (snap.eventRecording) return 'prelude'
1014
1060
  if (snap.store === 'idle' || snap.headless === 'idle') return 'idle'
1015
1061
  return null
@@ -1054,26 +1100,36 @@ async function cancelEngineRecording(bridge: WsBridge): Promise<{
1054
1100
 
1055
1101
  async function recordStatus(args: string[], opts: RecordOptions) {
1056
1102
  const state = readRecordingState()
1057
- if (state) {
1058
- noteSimMismatch(parseBridgeCliArgs(args, { port: opts.port }).simId, state)
1059
- if (state.mode === 'video') {
1060
- console.log(
1061
- ` recording ${state.mode} (${state.format} @ ${state.fps}fps) on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1062
- )
1063
- } else {
1064
- console.log(
1065
- ` recording ${state.mode} on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1066
- )
1067
- }
1068
- return
1069
- }
1070
- // no `record start` session — but an atomic run or the rail button may
1071
- // 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 })
1103
+ const parsed = parseBridgeCliArgs(args, {
1104
+ port: opts.port,
1105
+ commandTimeoutMs: 8_000,
1106
+ })
1107
+ if (state) noteSimMismatch(parsed.simId, state)
1108
+ const bridge = createBridgeFromParsed({
1109
+ ...parsed,
1110
+ simId: state?.simId ?? parsed.simId,
1111
+ })
1074
1112
  try {
1075
1113
  const engineState = await probeEngineRecordingState(bridge)
1114
+ if (state) {
1115
+ if (engineState === 'idle') {
1116
+ clearRecordingState()
1117
+ console.log(' no recording in progress')
1118
+ } else if (engineState === 'ready') {
1119
+ console.log(' recording ready to save — run `peach record stop --output <path>`')
1120
+ } else if (engineState === null) {
1121
+ console.log(
1122
+ ' recording state unavailable — the sim did not respond; the tracked session is preserved',
1123
+ )
1124
+ } else {
1125
+ console.log(
1126
+ ` ${engineState} ${state.mode}${state.mode === 'video' ? ` (${state.format} @ ${state.fps}fps)` : ''} on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1127
+ )
1128
+ }
1129
+ return
1130
+ }
1076
1131
  if (
1132
+ engineState === 'starting' ||
1077
1133
  engineState === 'recording' ||
1078
1134
  engineState === 'processing' ||
1079
1135
  engineState === 'prelude'
@@ -1111,16 +1167,18 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1111
1167
  if (!state) {
1112
1168
  // no `record start` session to cancel — but check the engine so we
1113
1169
  // 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,
1170
+ const probeParsed = parseBridgeCliArgs(args, {
1171
+ port: opts.port,
1117
1172
  commandTimeoutMs: 8_000,
1118
1173
  })
1174
+ const probeBridge = createBridgeFromParsed(probeParsed)
1119
1175
  try {
1120
1176
  const engineState = await probeEngineRecordingState(probeBridge)
1121
1177
  if (
1178
+ engineState === 'starting' ||
1122
1179
  engineState === 'recording' ||
1123
1180
  engineState === 'processing' ||
1181
+ engineState === 'ready' ||
1124
1182
  engineState === 'prelude'
1125
1183
  ) {
1126
1184
  // `record cancel` is explicit intent: the caller wants whatever is
@@ -1152,18 +1210,14 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1152
1210
  }
1153
1211
  return
1154
1212
  }
1155
- const parsed = parseBridgeCliArgs(args, { port: opts.port })
1213
+ const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 15_000 })
1156
1214
  noteSimMismatch(parsed.simId, state)
1157
1215
  // the recorder (and its buffered blob) lives in the page of the sim the
1158
1216
  // recording was *started* on — `state.simId` wins over the requested
1159
1217
  // --sim, otherwise cancel would stop an idle recorder on the wrong sim
1160
1218
  // and leave the real recording running (F11-1).
1161
1219
  const simId = state.simId ?? parsed.simId ?? undefined
1162
- const bridge = createBridgeFromParsed({
1163
- ...parsed,
1164
- simId,
1165
- commandTimeoutMs: 15_000,
1166
- })
1220
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1167
1221
  try {
1168
1222
  if (state.mode === 'live' || state.mode === 'combined') {
1169
1223
  await bridge.send({
@@ -1173,7 +1227,11 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1173
1227
  } else {
1174
1228
  await bridge.send({
1175
1229
  type: 'evaluate',
1176
- code: `window.__sootsimRecorder.stop()`,
1230
+ code: `(async () => {
1231
+ const recorder = window.__sootsimRecorder;
1232
+ await recorder.stop();
1233
+ return recorder.forceRelease();
1234
+ })()`,
1177
1235
  })
1178
1236
  }
1179
1237
  } catch {
@@ -1194,6 +1252,9 @@ async function recordStop(args: string[], opts: RecordOptions) {
1194
1252
 
1195
1253
  const parsed = parseBridgeCliArgs(args, {
1196
1254
  port: opts.port,
1255
+ // an explicit --timeout wins over the per-mode default
1256
+ commandTimeoutMs:
1257
+ state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1197
1258
  stripBooleanFlags: ['--open'],
1198
1259
  stripValueFlags: ['--output'],
1199
1260
  })
@@ -1204,15 +1265,25 @@ async function recordStop(args: string[], opts: RecordOptions) {
1204
1265
  const simId = state.simId ?? parsed.simId ?? undefined
1205
1266
  const openAfter = args.includes('--open')
1206
1267
 
1207
- const bridge = createBridgeFromParsed({
1208
- ...parsed,
1209
- simId,
1210
- commandTimeoutMs:
1211
- state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
1212
- })
1268
+ const bridge = createBridgeFromParsed({ ...parsed, simId })
1213
1269
  try {
1214
1270
  if (state.mode === 'live' || state.mode === 'combined') {
1215
- await evalStoreStop(bridge)
1271
+ const stopped = await evalStoreStop(bridge)
1272
+ if (stopped === 'pending') {
1273
+ console.error(
1274
+ ` stop failed: the ${state.mode} start is still in flight and has not begun a take yet.\n` +
1275
+ ' run `peach record stop` again once it begins.',
1276
+ )
1277
+ peachExit(1)
1278
+ }
1279
+ if (!stopped) {
1280
+ clearRecordingState()
1281
+ console.error(
1282
+ ` stop failed: the ${state.mode} start still in flight never began a take, so there is nothing to upload.\n` +
1283
+ ' run `peach record start` again.',
1284
+ )
1285
+ peachExit(1)
1286
+ }
1216
1287
  const result = await pollStoreUpload(bridge)
1217
1288
  clearRecordingState()
1218
1289
  handleStoreUploadResult(result, openAfter, state.mode)
@@ -1235,7 +1306,17 @@ async function recordStop(args: string[], opts: RecordOptions) {
1235
1306
  })
1236
1307
  if (!stopResult.ok) {
1237
1308
  console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
1238
- clearRecordingState()
1309
+ // a start that timed out on the client can still be on its way to the
1310
+ // engine. keep the state so a later stop finds the recording instead of
1311
+ // leaving it running with nothing to stop it.
1312
+ if (stopResult.error === 'not recording') {
1313
+ console.error(
1314
+ ' the start may not have reached the engine yet. retry `peach record stop`,\n' +
1315
+ ' or run `peach record cancel` to give up on it.',
1316
+ )
1317
+ } else {
1318
+ clearRecordingState()
1319
+ }
1239
1320
  peachExit(1)
1240
1321
  }
1241
1322
  if (!stopResult.size) {
@@ -1396,11 +1477,16 @@ async function evalStoreStart(
1396
1477
  return { ok: false, error: error ?? null }
1397
1478
  }
1398
1479
 
1399
- async function evalStoreStop(bridge: WsBridge): Promise<void> {
1400
- await bridge.send({
1480
+ // the engine stop waits for a start still in flight. false means that start
1481
+ // never began a take, so no upload will come and the store's last one belongs
1482
+ // to an earlier take. 'pending' means the start was still pending when the
1483
+ // engine stopped waiting, so it may yet begin one.
1484
+ async function evalStoreStop(bridge: WsBridge): Promise<boolean | 'pending'> {
1485
+ const stopped: boolean | 'pending' | null = await bridge.send({
1401
1486
  type: 'evaluate',
1402
- code: `void window.SootSim.bridges.stopRecording()`,
1487
+ code: `window.SootSim.bridges.stopRecording()`,
1403
1488
  })
1489
+ return stopped === 'pending' ? stopped : stopped !== false
1404
1490
  }
1405
1491
 
1406
1492
  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,
@@ -164,11 +165,21 @@ export async function runSwift(
164
165
  const appProject = resolveAppProject(packagePath)
165
166
  host.configureSwiftStandinBehavior(packagePath, await loadRNXAppConfig(appProject))
166
167
  // collection preflights every input path before any source bytes are read.
167
- host.swiftPackageWatchFiles(packagePath, undefined)
168
+ try {
169
+ host.swiftPackageWatchFiles(packagePath, undefined)
170
+ } catch (error) {
171
+ const { SwiftObjCCompilationError } = await import('../../src/swift-objc-compile')
172
+ if (!(error instanceof SwiftObjCCompilationError)) throw error
173
+ console.error(error.message)
174
+ host.disposeSwiftProject(packagePath)
175
+ return 1
176
+ }
168
177
  const packageName = path.basename(packagePath)
169
178
  const appInfo = host.swiftProjectAppInfo(packagePath)
170
179
  const guestSource = swiftGuestBundle(packageName)
171
- let guest = guestSource
180
+ const withIdentity = (source: string) =>
181
+ appInfo.identity ? insertNativeAppIdentityFooter(source, appInfo.identity) : source
182
+ let guest = withIdentity(guestSource)
172
183
  const controller = new AbortController()
173
184
  const resources: OpenCommandResources = {}
174
185
  let metadata: (SwiftEntryModuleArgs & { hmrPath: string }) | null = null
@@ -192,7 +203,9 @@ export async function runSwift(
192
203
  try {
193
204
  const config = await loadRNXAppConfig(appProject)
194
205
  host.configureSwiftStandinBehavior(packagePath, config)
195
- guest = await produceConfiguredBundle(guestSource, config, new Set())
206
+ guest = withIdentity(
207
+ await produceConfiguredBundle(guestSource, config, new Set()),
208
+ )
196
209
  const next = await host.swiftPackageMetadata(
197
210
  packagePath,
198
211
  packageName,
@@ -348,7 +361,8 @@ export async function runSwift(
348
361
  console.log(` Swift preview host: ${origin}`)
349
362
  await refresh()
350
363
  controller.signal.throwIfAborted()
351
- if (!metadata || compileError) throw new Error(compileError)
364
+ // refresh already printed the error
365
+ if (!metadata || compileError) return 1
352
366
  if (opts.device) openArgs.push('--device', opts.device)
353
367
  if (opts.driver) openArgs.push('--driver', opts.driver)
354
368
  if (opts.headless) openArgs.push('--headless')
@@ -6,6 +6,10 @@
6
6
  import { createHash } from 'node:crypto'
7
7
  import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
8
  import { dirname, extname, resolve } from 'node:path'
9
+ import {
10
+ collectTimelineAssetRefs,
11
+ timelineAssetRefOf,
12
+ } from 'peach-engine/screenshots/timeline-assets'
9
13
  import {
10
14
  isThreeModeSetting,
11
15
  THREE_MODE_SETTING_IDS,
@@ -353,44 +357,6 @@ function mimeTypeForAsset(path: string, contentType: unknown) {
353
357
  // timeline as blob: URLs.
354
358
  const TIMELINE_ASSET_CHUNK_BYTES = 1_048_576
355
359
 
356
- type TimelineAssetRef = {
357
- src: string
358
- recording: boolean
359
- }
360
-
361
- function timelineAssetRefOf(value: Record<string, unknown>): TimelineAssetRef | null {
362
- if (
363
- (value.type === 'recording' ||
364
- value.type === 'screenshot' ||
365
- value.kind === 'screen') &&
366
- typeof value.src === 'string' &&
367
- !/^(?:blob:|data:|https?:)/i.test(value.src)
368
- ) {
369
- return { src: value.src, recording: value.type === 'recording' }
370
- }
371
- return null
372
- }
373
-
374
- function collectTimelineAssetRefs(value: unknown): TimelineAssetRef[] {
375
- const refs: TimelineAssetRef[] = []
376
- const seen = new Set<string>()
377
- const visit = (node: unknown): void => {
378
- if (Array.isArray(node)) {
379
- for (const entry of node) visit(entry)
380
- return
381
- }
382
- if (!isRecord(node)) return
383
- for (const entry of Object.values(node)) visit(entry)
384
- const ref = timelineAssetRefOf(node)
385
- if (ref && !seen.has(ref.src)) {
386
- seen.add(ref.src)
387
- refs.push(ref)
388
- }
389
- }
390
- visit(value)
391
- return refs
392
- }
393
-
394
360
  type DistinctTimelineAsset = {
395
361
  hash: string
396
362
  mime: string