peachsim 0.1.1640 → 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 (109) hide show
  1. package/README.md +5 -0
  2. package/cli/app-stubs.ts +38 -13
  3. package/cli/cloud-session.ts +16 -0
  4. package/cli/commands/assert.ts +17 -4
  5. package/cli/commands/box.ts +33 -19
  6. package/cli/commands/doctor.ts +24 -15
  7. package/cli/commands/inspect/actions.ts +3 -2
  8. package/cli/commands/inspect/core.ts +0 -8
  9. package/cli/commands/inspect.ts +0 -14
  10. package/cli/commands/platform.ts +2 -1
  11. package/cli/commands/record.ts +64 -28
  12. package/cli/commands/swift.ts +10 -2
  13. package/cli/commands/three-mode.ts +4 -38
  14. package/cli/commands/upload.ts +8 -8
  15. package/cli/flow-live-status.ts +0 -2
  16. package/cli/main.ts +1 -1
  17. package/cli/ws-bridge.ts +0 -6
  18. package/detox/attempt-events.cjs +41 -5
  19. package/detox/index.ts +155 -109
  20. package/detox/jest-environment.cjs +5 -1
  21. package/dist-lib/agent-daemon-client.cjs +1 -1
  22. package/dist-lib/agent-events.cjs +1 -1
  23. package/dist-lib/attached-projects.cjs +1 -1
  24. package/dist-lib/auth/shared-session.cjs +1 -1
  25. package/dist-lib/backend-origin.cjs +10 -1
  26. package/dist-lib/beta.cjs +1 -1
  27. package/dist-lib/beta.mjs +1 -1
  28. package/dist-lib/bridge-constants.cjs +1 -1
  29. package/dist-lib/bridge-contract-input.cjs +1 -1
  30. package/dist-lib/bridge-contract-input.mjs +1 -1
  31. package/dist-lib/bridge-contract.cjs +1 -1
  32. package/dist-lib/bridge-contract.mjs +1 -1
  33. package/dist-lib/bundle-channel.cjs +2059 -1368
  34. package/dist-lib/capture-contract.cjs +1 -1
  35. package/dist-lib/capture-contract.mjs +1 -1
  36. package/dist-lib/cloud-contract.cjs +1 -1
  37. package/dist-lib/cloud-contract.mjs +1 -1
  38. package/dist-lib/cloud-test-args.cjs +1 -1
  39. package/dist-lib/cloud-test-args.mjs +1 -1
  40. package/dist-lib/cloud.cjs +1 -1
  41. package/dist-lib/cloud.mjs +1 -1
  42. package/dist-lib/config.cjs +1 -1
  43. package/dist-lib/config.mjs +1 -1
  44. package/dist-lib/detox/index.cjs +572 -125
  45. package/dist-lib/detox/jest-environment.cjs +5 -1
  46. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  47. package/dist-lib/expo-swift-bundle.cjs +1837 -1164
  48. package/dist-lib/expo-swift-metadata.cjs +1 -1
  49. package/dist-lib/expo-swift-metadata.mjs +1 -1
  50. package/dist-lib/home-paths.cjs +1 -1
  51. package/dist-lib/host/bridge-host.cjs +2290 -1616
  52. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  53. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  54. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  55. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  56. package/dist-lib/host/websocket-proxy.cjs +1 -1
  57. package/dist-lib/index.cjs +44 -17
  58. package/dist-lib/jump-to-source-babel.cjs +1 -1
  59. package/dist-lib/jump-to-source-native.cjs +1 -1
  60. package/dist-lib/kotlin-host.cjs +1 -1
  61. package/dist-lib/menu.cjs +1 -1
  62. package/dist-lib/menu.mjs +1 -1
  63. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  64. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  65. package/dist-lib/metro-production-bundle.cjs +1 -1
  66. package/dist-lib/metro-production-bundle.mjs +1 -1
  67. package/dist-lib/metro.cjs +33 -13
  68. package/dist-lib/native-app-identity-project.cjs +7 -4
  69. package/dist-lib/native-app-identity-project.mjs +7 -4
  70. package/dist-lib/native-app-identity.cjs +1 -1
  71. package/dist-lib/native-app-identity.mjs +1 -1
  72. package/dist-lib/native-firebase-metadata.cjs +1 -1
  73. package/dist-lib/native-firebase-metadata.mjs +1 -1
  74. package/dist-lib/plist.cjs +1 -1
  75. package/dist-lib/plist.mjs +1 -1
  76. package/dist-lib/profiles.cjs +1 -1
  77. package/dist-lib/public-brand.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.cjs +1 -1
  79. package/dist-lib/react-native-host-modules.mjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  81. package/dist-lib/sdk.cjs +4 -3
  82. package/dist-lib/sdk.mjs +4 -3
  83. package/dist-lib/skills.cjs +2823 -2109
  84. package/dist-lib/swift-compile.cjs +370 -239
  85. package/dist-lib/swift-compile.mjs +368 -230
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +8 -3
  89. package/dist-lib/swift-submit.mjs +8 -3
  90. package/dist-lib/swift-toolchain.cjs +1 -1
  91. package/dist-lib/swift.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1757 -1108
  93. package/dist-lib/vite.cjs +1 -1
  94. package/dist-lib/worklet-executable.cjs +1 -1
  95. package/dist-lib/worklet-executable.mjs +1 -1
  96. package/package.json +1 -1
  97. package/src/backend-origin.ts +8 -0
  98. package/src/react-native-bridge-modules.ts +89 -76
  99. package/src/swift-compile.ts +134 -107
  100. package/src/swift-distribution.lock.json +9 -8
  101. package/src/swift-host.ts +6 -4
  102. package/src/swift-objc-compile.ts +385 -193
  103. package/src/swift-objc-metadata.ts +204 -0
  104. package/src/swift-objc-sdk.ts +195 -2
  105. package/src/swift-package-manifest.ts +6 -3
  106. package/src/swift-root-lock.ts +153 -0
  107. package/src/swift-submit.ts +15 -2
  108. package/src/swiftContract.ts +12 -0
  109. package/src/xcode-project.ts +10 -3
package/README.md CHANGED
@@ -656,6 +656,11 @@ peach screenshot --with-frame --output /tmp/peach-framed.png
656
656
  peach record --duration 5 --output /tmp/peach-anim.mp4
657
657
  ```
658
658
 
659
+ `peach record status` queries the recorder on the tracked simulator. A video
660
+ whose stop reply timed out remains available: status reports processing or
661
+ ready to save, and another `peach record stop --output <path>` joins the same
662
+ finalization and saves its bytes. `peach record cancel` discards them.
663
+
659
664
  `peach screenshot --with-frame` composes the real Peach shell device chrome
660
665
  around the raw screen bitmap (reusing the shell bezel/button geometry, excluding
661
666
  the Electron top bar, rail gutter, and other window chrome). Flows can request
package/cli/app-stubs.ts CHANGED
@@ -381,23 +381,39 @@ export function packageNativeRequests(dir: string): NativeRequest[] {
381
381
  )
382
382
  }
383
383
 
384
- // the native names each installed dependency's own JavaScript asks for, keyed
385
- // `kind:name`. links to source outside node_modules are the app's own packages
386
- // and are scanned as app code instead.
384
+ // resolve the installed runtime dependency graph, including transitive native
385
+ // packages. workspace source is scanned as app code instead. a transitive
386
+ // package is scanned only when it ships native code: the graph runs to
387
+ // thousands of packages, and the rest name nothing a direct dependency does not.
387
388
  function dependencyNativeOwners(
388
389
  appDir: string,
389
390
  manifest: ProjectManifest | null,
390
391
  ): Map<string, string> {
391
392
  const owners = new Map<string, string>()
392
- for (const name of Object.keys({
393
+ const pending = Object.keys({
393
394
  ...manifest?.devDependencies,
394
395
  ...manifest?.dependencies,
395
- })) {
396
- const dir = installedPackageDir(appDir, name)
397
- if (!dir || !dir.split('/').includes('node_modules')) continue
398
- for (const request of packageNativeRequests(dir))
399
- if (!owners.has(`${request.kind}:${request.name}`))
400
- owners.set(`${request.kind}:${request.name}`, name)
396
+ }).map((name) => ({ from: appDir, name }))
397
+ const visited = new Set<string>()
398
+ for (let index = 0; index < pending.length; index++) {
399
+ const { from, name } = pending[index]
400
+ const dir = installedPackageDir(from, name)
401
+ if (!dir || visited.has(dir)) continue
402
+ visited.add(dir)
403
+ const dependency = readProjectManifest(dir)
404
+ const optional = readPackageJson(dir)?.optionalDependencies
405
+ if (
406
+ dir.split('/').includes('node_modules') &&
407
+ (from === appDir || hasNativeCode(dir, 'ios'))
408
+ )
409
+ for (const request of packageNativeRequests(dir))
410
+ if (!owners.has(`${request.kind}:${request.name}`))
411
+ owners.set(`${request.kind}:${request.name}`, dependency?.name ?? name)
412
+ for (const child of Object.keys({
413
+ ...dependency?.dependencies,
414
+ ...(isRecord(optional) ? optional : {}),
415
+ }))
416
+ pending.push({ from: dir, name: child })
401
417
  }
402
418
  return owners
403
419
  }
@@ -595,7 +611,12 @@ export function scanLocalNative(
595
611
  dir,
596
612
  scanRepository(dir).directories.flatMap((sub) =>
597
613
  listDir(sub)
598
- .filter((name) => NATIVE_SOURCE_FILE.test(name))
614
+ .filter(
615
+ (name) =>
616
+ NATIVE_SOURCE_FILE.test(name) &&
617
+ (!platform ||
618
+ (platform === 'ios' ? name.endsWith('.swift') : name.endsWith('.kt'))),
619
+ )
599
620
  .map((name) => join(sub, name)),
600
621
  ),
601
622
  ]),
@@ -633,8 +654,12 @@ export function scanLocalNative(
633
654
  path: `${relative(appDir, file)}:${line}`,
634
655
  expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
635
656
  swift: moduleDir
636
- ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
637
- path.endsWith('.swift'),
657
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some(
658
+ (path) =>
659
+ path.endsWith('.swift') &&
660
+ [...(readSmallFile(path) ?? '').matchAll(/\bName\(\s*"(\w+)"\s*\)/g)].some(
661
+ (match) => match[1] === name,
662
+ ),
638
663
  )
639
664
  : false,
640
665
  answered: answered[kind].has(name),
@@ -60,6 +60,7 @@ export interface CloudBoxSession {
60
60
  platform?: RemotePlatform
61
61
  device?: string
62
62
  artifact?: CloudSessionArtifactIdentity
63
+ appFontsWireSha256?: string
63
64
  }
64
65
 
65
66
  export function normalizeCloudOrigin(source: string): string {
@@ -128,7 +129,13 @@ export function createCloudBoxSession(input: {
128
129
  platform?: RemotePlatform
129
130
  device?: string
130
131
  artifact?: CloudSessionArtifactIdentity
132
+ appFontsWireSha256?: string
131
133
  }): CloudBoxSession {
134
+ if (
135
+ input.appFontsWireSha256 !== undefined &&
136
+ !/^[a-f0-9]{64}$/.test(input.appFontsWireSha256)
137
+ )
138
+ throw new Error('cloud box session has an invalid app-font wire digest')
132
139
  const session: CloudBoxSession = {
133
140
  version: CLOUD_SESSION_VERSION,
134
141
  service: 'box',
@@ -138,6 +145,9 @@ export function createCloudBoxSession(input: {
138
145
  ...(input.platform ? { platform: input.platform } : {}),
139
146
  ...(input.device?.trim() ? { device: input.device.trim() } : {}),
140
147
  ...(input.artifact ? { artifact: input.artifact } : {}),
148
+ ...(input.appFontsWireSha256 !== undefined
149
+ ? { appFontsWireSha256: input.appFontsWireSha256 }
150
+ : {}),
141
151
  }
142
152
  if (!session.boxId || !session.boxToken) {
143
153
  throw new Error('cloud box session requires a box, an origin, and a box token')
@@ -198,6 +208,11 @@ export function parseCloudSession(encoded: string): CloudSession {
198
208
  try {
199
209
  if (parsed.service === 'box') {
200
210
  if (!boxId || !boxToken) throw new Error('unreadable cloud box session')
211
+ if (
212
+ parsed.appFontsWireSha256 !== undefined &&
213
+ typeof parsed.appFontsWireSha256 !== 'string'
214
+ )
215
+ throw new Error('unreadable app-font wire digest')
201
216
  return createCloudBoxSession({
202
217
  boxId,
203
218
  boxToken,
@@ -205,6 +220,7 @@ export function parseCloudSession(encoded: string): CloudSession {
205
220
  platform,
206
221
  device,
207
222
  artifact,
223
+ appFontsWireSha256: parsed.appFontsWireSha256,
208
224
  })
209
225
  }
210
226
  const simId = parsed.simId
@@ -162,11 +162,14 @@ interface VerbResult {
162
162
  reason?: string
163
163
  }
164
164
 
165
- async function runVerbOnce(verbArgs: string[]): Promise<VerbResult> {
165
+ async function runVerbOnce(
166
+ verbArgs: string[],
167
+ globalArgs: string[],
168
+ ): Promise<VerbResult> {
166
169
  // re-invoke self through the real CLI entry, so every code path the user's
167
170
  // verb would take, assert takes too.
168
171
  const { executable, prefixArgs } = rnxSelfInvocation()
169
- const args = [...prefixArgs, ...verbArgs, '--json']
172
+ const args = [...prefixArgs, ...globalArgs, ...verbArgs, '--json']
170
173
 
171
174
  return new Promise<VerbResult>((resolve) => {
172
175
  const proc = spawn(executable, args, {
@@ -402,7 +405,10 @@ function runJq(expr: string, payload: unknown): Promise<string> {
402
405
  })
403
406
  }
404
407
 
405
- export async function runAssert(rawArgs: string[]): Promise<void> {
408
+ export async function runAssert(
409
+ rawArgs: string[],
410
+ target: { port?: number; sim?: string } = {},
411
+ ): Promise<void> {
406
412
  if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs.length === 0) {
407
413
  printHelp()
408
414
  peachExit(rawArgs.length === 0 ? 2 : 0)
@@ -430,6 +436,13 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
430
436
  const effectivePredicates =
431
437
  predicates.length > 0 ? predicates : [{ kind: '--exists', args: [] }]
432
438
 
439
+ // the child is a fresh CLI process: without the caller's explicit port and
440
+ // sim it falls back to the saved target.
441
+ const globalArgs = [
442
+ ...(target.port != null ? ['--port', String(target.port)] : []),
443
+ ...(target.sim ? ['--sim', target.sim] : []),
444
+ ]
445
+
433
446
  const deadline = withinMs > 0 ? Date.now() + withinMs : 0
434
447
  let attempts = 0
435
448
  let lastFailReason = ''
@@ -437,7 +450,7 @@ export async function runAssert(rawArgs: string[]): Promise<void> {
437
450
 
438
451
  while (true) {
439
452
  attempts++
440
- const verbResult = await runVerbOnce(verbArgs)
453
+ const verbResult = await runVerbOnce(verbArgs, globalArgs)
441
454
  if (!verbResult.ok) {
442
455
  lastVerbSkipReason = verbResult.reason || 'verb failed'
443
456
  if (Date.now() < deadline) {
@@ -10,6 +10,7 @@
10
10
  // name on the command line, else the one `peach box use` remembered. see
11
11
  // boxTarget below.
12
12
 
13
+ import { createHash } from 'node:crypto'
13
14
  import fs from 'node:fs'
14
15
  import path from 'node:path'
15
16
  import { createInterface } from 'node:readline'
@@ -536,7 +537,13 @@ export async function createPrebuiltBundleBox(options: {
536
537
  const nativeRenderer = options.nativeProjectDir
537
538
  ? readNativeProjectRenderer(options.nativeProjectDir, platform)
538
539
  : undefined
539
- return cloudBoxClient(options).create({
540
+ const appFonts = options.nativeProjectDir
541
+ ? readNativeLinkedAppFontsWire({
542
+ projectDir: options.nativeProjectDir,
543
+ platform,
544
+ })
545
+ : ''
546
+ const box = await cloudBoxClient(options).create({
540
547
  name: options.name,
541
548
  stack: prebuiltBundleBoxTemplate.stack,
542
549
  template: BOX_TEMPLATES.resolve({ name: prebuiltBundleBoxTemplate.name }).provenance,
@@ -544,16 +551,17 @@ export async function createPrebuiltBundleBox(options: {
544
551
  options.bundle,
545
552
  options.device,
546
553
  options.initialUrl ?? null,
547
- options.nativeProjectDir
548
- ? readNativeLinkedAppFontsWire({
549
- projectDir: options.nativeProjectDir,
550
- platform,
551
- })
552
- : '',
554
+ appFonts,
553
555
  nativeRenderer,
554
556
  ),
555
557
  ...(options.secrets ? { secrets: options.secrets } : {}),
556
558
  })
559
+ return {
560
+ box,
561
+ appFontsWireSha256: box.existing
562
+ ? undefined
563
+ : createHash('sha256').update(appFonts).digest('hex'),
564
+ }
557
565
  }
558
566
 
559
567
  // a plain .env file: `NAME=value` lines, an optional `export `, optional
@@ -698,7 +706,7 @@ async function runCloudBoxCreate(
698
706
  }
699
707
  let session: BoxShellSession | null = null
700
708
  try {
701
- const box =
709
+ const created =
702
710
  'bundle' in source
703
711
  ? await createPrebuiltBundleBox({
704
712
  ...connection,
@@ -708,21 +716,27 @@ async function runCloudBoxCreate(
708
716
  name,
709
717
  ...(secrets ? { secrets } : {}),
710
718
  })
711
- : await cloudBoxClient(connection).create({
712
- name,
713
- stack: template.stack,
714
- template: template.provenance,
715
- source: source.checkout,
716
- ...(secrets ? { secrets } : {}),
717
- onProgress:
718
- opts.verbose && !json
719
- ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
720
- : undefined,
721
- })
719
+ : {
720
+ box: await cloudBoxClient(connection).create({
721
+ name,
722
+ stack: template.stack,
723
+ template: template.provenance,
724
+ source: source.checkout,
725
+ ...(secrets ? { secrets } : {}),
726
+ onProgress:
727
+ opts.verbose && !json
728
+ ? (seeded, total) => console.log(` ...seeded ${seeded}/${total} files`)
729
+ : undefined,
730
+ }),
731
+ }
732
+ const { box } = created
722
733
  if (json) {
723
734
  console.log(
724
735
  JSON.stringify({
725
736
  ...box.description,
737
+ ...('appFontsWireSha256' in created
738
+ ? { appFontsWireSha256: created.appFontsWireSha256 }
739
+ : {}),
726
740
  ...(box.accessToken ? { accessToken: box.accessToken } : {}),
727
741
  }),
728
742
  )
@@ -373,9 +373,8 @@ function printVersions(findings: DoctorFindings): void {
373
373
  }
374
374
  }
375
375
 
376
- // one line per package, worst first, with the published implementation
377
- // percent. packages that run from the bundle with no native code sit last on
378
- // one line: there is nothing to check in them.
376
+ // packages appear worst first with the published implementation percent and
377
+ // complete capability details. javascript-only packages sit last on one line.
379
378
  function printPackages(
380
379
  packages: readonly PackageResult[],
381
380
  verbose: boolean,
@@ -411,10 +410,26 @@ function printPackages(
411
410
  const columns = process.stdout.columns || 120
412
411
  for (const row of rows) {
413
412
  const percent = percentLabel(row.item)
414
- const detail = fit(row.detail, Math.max(40, columns - nameWidth - versionWidth - 13))
413
+ const detail = row.detail
415
414
  console.log(
416
415
  ` ${row.glyph} ${row.item.name.padEnd(nameWidth)}${muted(row.item.version.padEnd(versionWidth))}${percent.padStart(5)}${detail ? ` ${row.tone(detail)}` : ''}`,
417
416
  )
417
+ if (!answering.has(row.item.name)) {
418
+ for (const [label, text] of [
419
+ ['working', row.item.rnx.working],
420
+ ['missing', row.item.rnx.missing],
421
+ ]) {
422
+ if (!text) continue
423
+ let line = `${label}:`
424
+ for (const word of text.split(/\s+/)) {
425
+ if (line.length + word.length + 1 > columns - 6) {
426
+ console.log(` ${muted(line)}`)
427
+ line = word
428
+ } else line += ` ${word}`
429
+ }
430
+ console.log(` ${muted(line)}`)
431
+ }
432
+ }
418
433
  if (verbose && row.item.note) console.log(` ${muted(row.item.note)}`)
419
434
  }
420
435
  if (javascriptOnly.length > 0) {
@@ -517,22 +532,12 @@ function packageRow(
517
532
  return {
518
533
  rank: RANK.partial,
519
534
  glyph: warning('◐'),
520
- detail: pkg.rnx.missing ? `missing: ${firstClause(pkg.rnx.missing)}` : '',
535
+ detail: '',
521
536
  tone: muted,
522
537
  }
523
538
  return { rank: RANK.supported, glyph: success('✓'), detail: '', tone: muted }
524
539
  }
525
540
 
526
- function firstClause(text: string): string {
527
- const end = text.search(/[.;,](\s|$)/)
528
- return end > 0 ? text.slice(0, end) : text
529
- }
530
-
531
- // a detail ends at the terminal edge rather than wrapping under the columns
532
- function fit(text: string, width: number): string {
533
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
534
- }
535
-
536
541
  function printNativeGuidance(
537
542
  unknownNative: readonly UnknownNativePackage[],
538
543
  local: readonly LocalNative[],
@@ -577,6 +582,10 @@ function printNativeGuidance(
577
582
  }
578
583
  if (open.size) {
579
584
  console.log(`\n stub them in peach.config.ts, by package:\n`)
585
+ console.log(
586
+ muted(` import { defineConfig } from '${rnxPublicBrand.packageName}/config'`),
587
+ )
588
+ console.log()
580
589
  console.log(muted(` export default defineConfig({`))
581
590
  console.log(muted(` packages: {`))
582
591
  for (const [owner, requestsByName] of open) {
@@ -380,9 +380,10 @@ async function resolveTextTapTarget(
380
380
  query: string,
381
381
  options: TapTextOptions = {},
382
382
  ) {
383
+ const needle = options.exact ? query : query.trim().toLowerCase()
384
+ if (!needle) return { matched: 0, total: 0 }
383
385
  const { tree } = await inspectTree(bridge, 50)
384
386
  if (!Array.isArray(tree)) return { matched: 0, total: 0 }
385
- const needle = options.exact ? query : query.toLowerCase()
386
387
  let candidates = collectTextCandidates(tree).filter(
387
388
  ({ node, ancestorTestIDs, role }) => {
388
389
  const text = nodeText(node)
@@ -411,7 +412,7 @@ async function resolveTextTapTarget(
411
412
  if (actionable.length > 0) candidates = actionable
412
413
  if (!options.exact) {
413
414
  const whole = candidates.filter(
414
- ({ node }) => nodeText(node).trim().toLowerCase() === needle.trim(),
415
+ ({ node }) => nodeText(node).trim().toLowerCase() === needle,
415
416
  )
416
417
  if (whole.length > 0) candidates = whole
417
418
  }
@@ -556,12 +556,6 @@ export interface LayoutElement {
556
556
  ellipsisActive?: boolean
557
557
  }
558
558
 
559
- export interface LayoutResult {
560
- preview: 'web' | 'native'
561
- count: number
562
- elements: LayoutElement[]
563
- }
564
-
565
559
  // in-page extractor for the native preview. mirrors the visibility + device
566
560
  // sizing the a11y / wait-ready kernels use so "what's on screen" agrees across
567
561
  // the inspect surface. returns LayoutElement[] (or [] when the bridge is cold).
@@ -2013,8 +2007,6 @@ export const DEBUG_CHANNELS = [
2013
2007
  'yoga',
2014
2008
  ] as const
2015
2009
 
2016
- export type DebugChannel = (typeof DEBUG_CHANNELS)[number]
2017
-
2018
2010
  // `debug status` — which debug channels are currently enabled + their counts.
2019
2011
  export async function inspectDebugStatus(bridge: InspectBridge): Promise<unknown> {
2020
2012
  return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.status()' })
@@ -971,20 +971,6 @@ function keyCodeToVisualKey(code: string) {
971
971
  return null
972
972
  }
973
973
 
974
- function getInspectCommandName(subcommand: string, directSubcommand: string | null) {
975
- if (directSubcommand === subcommand) return `peach ${subcommand}`
976
- const group = getCliInspectVerbMeta(subcommand)?.group
977
- return group ? `peach ${group} ${subcommand}` : `peach ${subcommand}`
978
- }
979
-
980
- function getInspectUsage(
981
- subcommand: string,
982
- tail: string,
983
- directSubcommand: string | null,
984
- ) {
985
- return ` usage: ${getInspectCommandName(subcommand, directSubcommand)}${tail ? ` ${tail}` : ''}`
986
- }
987
-
988
974
  function normalizeRecordedText(value: unknown) {
989
975
  if (typeof value !== 'string') return null
990
976
  const text = value.replace(/\s+/g, ' ').trim()
@@ -528,7 +528,7 @@ async function runRemoteBox(
528
528
  } else if (existingSession) {
529
529
  await closeCloudSession(existingSession)
530
530
  }
531
- const created = await box.createPrebuiltBundleBox({
531
+ const { box: created, appFontsWireSha256 } = await box.createPrebuiltBundleBox({
532
532
  bundle: new Uint8Array(artifact.bytes),
533
533
  nativeProjectDir: input.nativeProjectDir,
534
534
  device: selectedDevice,
@@ -546,6 +546,7 @@ async function runRemoteBox(
546
546
  apiOrigin: endpoint,
547
547
  platform,
548
548
  device: selectedDevice,
549
+ appFontsWireSha256,
549
550
  artifact: {
550
551
  sha256: artifactSha256,
551
552
  bytes: artifact.bytes.length,
@@ -168,6 +168,7 @@ const RECORD_VALUE_FLAGS = new Set([
168
168
  '--repo',
169
169
  '--scene',
170
170
  '--shell-url',
171
+ '--timeout',
171
172
  ])
172
173
  const RECORD_SUBCOMMANDS = new Set([
173
174
  'start',
@@ -924,8 +925,6 @@ async function recordStart(args: string[], opts: RecordOptions) {
924
925
  peachExit(1)
925
926
  }
926
927
  } else {
927
- await assertRecorderAvailable(bridge)
928
- await assertTabVisibleForCapture(bridge)
929
928
  const lockstep = args.includes('--lockstep')
930
929
  if (lockstep && format !== 'mp4') {
931
930
  console.error(' --lockstep requires --format mp4 (webm is realtime-clocked)')
@@ -942,7 +941,11 @@ async function recordStart(args: string[], opts: RecordOptions) {
942
941
  writeRecordingState(session)
943
942
  const result: { ok: boolean; error?: string } = await bridge.send({
944
943
  type: 'evaluate',
945
- 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
+ })()`,
946
949
  })
947
950
  if (!result.ok) {
948
951
  clearRecordingState()
@@ -1022,7 +1025,13 @@ async function recordPreludeStart(args: string[], opts: RecordOptions) {
1022
1025
  // - the event recorder alone drives hidden PR-preview preludes before a
1023
1026
  // visible live/combined segment exists
1024
1027
  // returns null when the engine can't be reached.
1025
- type EngineRecordingState = 'idle' | 'recording' | 'processing' | 'prelude'
1028
+ type EngineRecordingState =
1029
+ | 'idle'
1030
+ | 'starting'
1031
+ | 'recording'
1032
+ | 'processing'
1033
+ | 'ready'
1034
+ | 'prelude'
1026
1035
 
1027
1036
  async function probeEngineRecordingState(
1028
1037
  bridge: WsBridge,
@@ -1043,8 +1052,10 @@ async function probeEngineRecordingState(
1043
1052
  })()`,
1044
1053
  })
1045
1054
  if (!snap) return null
1055
+ if (snap.headless === 'starting') return 'starting'
1046
1056
  if (snap.store === 'recording' || snap.headless === 'recording') return 'recording'
1047
- if (snap.store === 'processing') return 'processing'
1057
+ if (snap.store === 'processing' || snap.headless === 'processing') return 'processing'
1058
+ if (snap.headless === 'ready') return 'ready'
1048
1059
  if (snap.eventRecording) return 'prelude'
1049
1060
  if (snap.store === 'idle' || snap.headless === 'idle') return 'idle'
1050
1061
  return null
@@ -1089,26 +1100,36 @@ async function cancelEngineRecording(bridge: WsBridge): Promise<{
1089
1100
 
1090
1101
  async function recordStatus(args: string[], opts: RecordOptions) {
1091
1102
  const state = readRecordingState()
1092
- if (state) {
1093
- noteSimMismatch(parseBridgeCliArgs(args, { port: opts.port }).simId, state)
1094
- if (state.mode === 'video') {
1095
- console.log(
1096
- ` recording ${state.mode} (${state.format} @ ${state.fps}fps) on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1097
- )
1098
- } else {
1099
- console.log(
1100
- ` recording ${state.mode} on sim ${state.simId ?? '?'} since ${state.startedAt}`,
1101
- )
1102
- }
1103
- return
1104
- }
1105
- // no `record start` session — but an atomic run or the rail button may
1106
- // still be recording. ask the engine before claiming nothing is happening.
1107
- const parsed = parseBridgeCliArgs(args, { port: opts.port, commandTimeoutMs: 8_000 })
1108
- const bridge = createBridgeFromParsed(parsed)
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
+ })
1109
1112
  try {
1110
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
+ }
1111
1131
  if (
1132
+ engineState === 'starting' ||
1112
1133
  engineState === 'recording' ||
1113
1134
  engineState === 'processing' ||
1114
1135
  engineState === 'prelude'
@@ -1154,8 +1175,10 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1154
1175
  try {
1155
1176
  const engineState = await probeEngineRecordingState(probeBridge)
1156
1177
  if (
1178
+ engineState === 'starting' ||
1157
1179
  engineState === 'recording' ||
1158
1180
  engineState === 'processing' ||
1181
+ engineState === 'ready' ||
1159
1182
  engineState === 'prelude'
1160
1183
  ) {
1161
1184
  // `record cancel` is explicit intent: the caller wants whatever is
@@ -1204,7 +1227,11 @@ async function recordCancel(args: string[], opts: RecordOptions) {
1204
1227
  } else {
1205
1228
  await bridge.send({
1206
1229
  type: 'evaluate',
1207
- code: `window.__sootsimRecorder.stop()`,
1230
+ code: `(async () => {
1231
+ const recorder = window.__sootsimRecorder;
1232
+ await recorder.stop();
1233
+ return recorder.forceRelease();
1234
+ })()`,
1208
1235
  })
1209
1236
  }
1210
1237
  } catch {
@@ -1241,7 +1268,15 @@ async function recordStop(args: string[], opts: RecordOptions) {
1241
1268
  const bridge = createBridgeFromParsed({ ...parsed, simId })
1242
1269
  try {
1243
1270
  if (state.mode === 'live' || state.mode === 'combined') {
1244
- if (!(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) {
1245
1280
  clearRecordingState()
1246
1281
  console.error(
1247
1282
  ` stop failed: the ${state.mode} start still in flight never began a take, so there is nothing to upload.\n` +
@@ -1444,13 +1479,14 @@ async function evalStoreStart(
1444
1479
 
1445
1480
  // the engine stop waits for a start still in flight. false means that start
1446
1481
  // 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({
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({
1450
1486
  type: 'evaluate',
1451
1487
  code: `window.SootSim.bridges.stopRecording()`,
1452
1488
  })
1453
- return stopped !== false
1489
+ return stopped === 'pending' ? stopped : stopped !== false
1454
1490
  }
1455
1491
 
1456
1492
  interface StoreUploadResult {
@@ -165,7 +165,15 @@ export async function runSwift(
165
165
  const appProject = resolveAppProject(packagePath)
166
166
  host.configureSwiftStandinBehavior(packagePath, await loadRNXAppConfig(appProject))
167
167
  // collection preflights every input path before any source bytes are read.
168
- 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
+ }
169
177
  const packageName = path.basename(packagePath)
170
178
  const appInfo = host.swiftProjectAppInfo(packagePath)
171
179
  const guestSource = swiftGuestBundle(packageName)
@@ -354,7 +362,7 @@ export async function runSwift(
354
362
  await refresh()
355
363
  controller.signal.throwIfAborted()
356
364
  // refresh already printed the error
357
- if (!metadata || compileError) throw new Error('Swift compilation failed, see above')
365
+ if (!metadata || compileError) return 1
358
366
  if (opts.device) openArgs.push('--device', opts.device)
359
367
  if (opts.driver) openArgs.push('--driver', opts.driver)
360
368
  if (opts.headless) openArgs.push('--headless')