peachsim 0.1.1406 → 0.1.1465

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 (150) hide show
  1. package/LICENSE +8 -4
  2. package/README.md +22 -2
  3. package/cli/app-fonts.ts +50 -44
  4. package/cli/bin.ts +5 -4
  5. package/cli/bridge-diagnostics.ts +5 -0
  6. package/cli/bridge-flow-runner.ts +21 -13
  7. package/cli/cloud-client.ts +4 -3
  8. package/cli/commands/control.ts +155 -123
  9. package/cli/commands/daemon-mac-app.ts +3 -3
  10. package/cli/commands/daemon.ts +2 -2
  11. package/cli/commands/do-chain.ts +9 -2
  12. package/cli/commands/electron.ts +4 -1
  13. package/cli/commands/inspect/core.ts +1 -1
  14. package/cli/commands/inspect/settle.ts +19 -7
  15. package/cli/commands/inspect/settling.ts +92 -56
  16. package/cli/commands/inspect/wait-idle.ts +21 -9
  17. package/cli/commands/inspect/wait-ready.ts +22 -8
  18. package/cli/commands/inspect.ts +101 -134
  19. package/cli/commands/setup.ts +26 -16
  20. package/cli/commands/upgrade.ts +53 -9
  21. package/cli/desktop-companion.ts +1 -1
  22. package/cli/drivers/playwright-sim-host.ts +32 -13
  23. package/cli/drivers/playwright.ts +3 -3
  24. package/cli/main.ts +14 -5
  25. package/cli/outbound-endpoints.ts +1 -9
  26. package/detox/index.ts +6 -1
  27. package/dist-lib/agent-daemon-client.cjs +1 -1
  28. package/dist-lib/agent-events.cjs +1 -1
  29. package/dist-lib/attached-projects.cjs +1 -1
  30. package/dist-lib/auth/shared-session.cjs +1 -1
  31. package/dist-lib/backend-origin.cjs +1 -1
  32. package/dist-lib/beta.cjs +1 -1
  33. package/dist-lib/beta.mjs +1 -1
  34. package/dist-lib/bridge-constants.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.cjs +1 -1
  36. package/dist-lib/bridge-contract-input.mjs +1 -1
  37. package/dist-lib/bridge-contract.cjs +3 -3
  38. package/dist-lib/bridge-contract.mjs +3 -3
  39. package/dist-lib/bundle-channel.cjs +322 -274
  40. package/dist-lib/capture-contract.cjs +1 -1
  41. package/dist-lib/capture-contract.mjs +1 -1
  42. package/dist-lib/cloud-contract.cjs +1 -1
  43. package/dist-lib/cloud-contract.mjs +1 -1
  44. package/dist-lib/cloud-test-args.cjs +1 -1
  45. package/dist-lib/cloud-test-args.mjs +1 -1
  46. package/dist-lib/cloud.cjs +1 -1
  47. package/dist-lib/cloud.mjs +1 -1
  48. package/dist-lib/config.cjs +1 -1
  49. package/dist-lib/config.mjs +1 -1
  50. package/dist-lib/detox/index.cjs +512 -122
  51. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  52. package/dist-lib/expo-swift-bundle.cjs +69 -33
  53. package/dist-lib/expo-swift-metadata.cjs +1 -1
  54. package/dist-lib/expo-swift-metadata.mjs +1 -1
  55. package/dist-lib/home-paths.cjs +1 -1
  56. package/dist-lib/host/bridge-host.cjs +3650 -3528
  57. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  59. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  60. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  61. package/dist-lib/host/websocket-proxy.cjs +1 -1
  62. package/dist-lib/index.cjs +327 -123
  63. package/dist-lib/jump-to-source-babel.cjs +1 -1
  64. package/dist-lib/jump-to-source-native.cjs +1 -1
  65. package/dist-lib/kotlin-host.cjs +10 -5
  66. package/dist-lib/menu.cjs +1 -1
  67. package/dist-lib/menu.mjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  69. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  70. package/dist-lib/metro-production-bundle.cjs +1 -1
  71. package/dist-lib/metro-production-bundle.mjs +1 -1
  72. package/dist-lib/metro.cjs +49 -47
  73. package/dist-lib/native-app-identity-project.cjs +1 -1
  74. package/dist-lib/native-app-identity-project.mjs +1 -1
  75. package/dist-lib/native-app-identity.cjs +1 -1
  76. package/dist-lib/native-app-identity.mjs +1 -1
  77. package/dist-lib/native-firebase-metadata.cjs +1 -1
  78. package/dist-lib/native-firebase-metadata.mjs +1 -1
  79. package/dist-lib/plist.cjs +1 -1
  80. package/dist-lib/plist.mjs +1 -1
  81. package/dist-lib/profiles.cjs +1 -1
  82. package/dist-lib/public-brand.cjs +1 -1
  83. package/dist-lib/react-native-host-modules.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.mjs +1 -1
  85. package/dist-lib/scripts/dev-server-scanner.cjs +23 -17
  86. package/dist-lib/sdk.cjs +99 -61
  87. package/dist-lib/sdk.mjs +97 -61
  88. package/dist-lib/skills.cjs +3347 -3051
  89. package/dist-lib/swift-compile.cjs +26 -12
  90. package/dist-lib/swift-compile.mjs +26 -12
  91. package/dist-lib/swift-package.cjs +1 -1
  92. package/dist-lib/swift-package.mjs +1 -1
  93. package/dist-lib/swift-submit.cjs +2 -2
  94. package/dist-lib/swift-submit.mjs +2 -2
  95. package/dist-lib/swift-toolchain.cjs +1 -1
  96. package/dist-lib/swift.cjs +1 -1
  97. package/dist-lib/vite-swift.cjs +66 -31
  98. package/dist-lib/vite.cjs +1 -1
  99. package/dist-lib/worklet-executable.cjs +5 -1
  100. package/dist-lib/worklet-executable.mjs +5 -1
  101. package/kotlin/AppSources/android/view/Window.kt +2 -0
  102. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +1 -1
  103. package/kotlin/ComposeSources/PeachComposeHost.kt +35 -9
  104. package/kotlin/ExpoComposeSources/dev/peach/expo/PeachExpoCompose.kt +2 -2
  105. package/kotlin/ExpoSources/dev/peach/expo/PeachExpoRuntime.kt +9 -6
  106. package/kotlin/ExpoSources/dev/peach/expo/PeachExpoTypes.kt +4 -4
  107. package/kotlin/ExpoSources/expo/modules/kotlin/modules/Module.kt +2 -2
  108. package/kotlin/ExpoSources/expo/modules/kotlin/viewevent/ViewEvent.kt +22 -8
  109. package/kotlin/ExpoSources/expo/modules/kotlin/views/ExpoView.kt +0 -3
  110. package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewDefinitionBuilder.kt +10 -9
  111. package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewProps.kt +6 -5
  112. package/kotlin/fixtures/import-drawing/Drawing.d.kt.ts +7 -0
  113. package/kotlin/fixtures/import-drawing/Drawing.kt +101 -0
  114. package/kotlin/fixtures/import-drawing/ImportDrawing.native.tsx +18 -0
  115. package/kotlin/fixtures/import-drawing/README.md +34 -0
  116. package/kotlin/fixtures/import-drawing/app/_layout.tsx +4 -0
  117. package/kotlin/fixtures/import-drawing/app/index+spa.tsx +1 -0
  118. package/kotlin/fixtures/import-drawing/package.json +23 -0
  119. package/kotlin/fixtures/import-drawing/tsconfig.json +20 -0
  120. package/kotlin/fixtures/import-drawing/vite.config.ts +20 -0
  121. package/kotlin/fixtures/import-scroll/ImportNested.native.tsx +10 -0
  122. package/kotlin/fixtures/import-scroll/Nested.d.kt.ts +4 -0
  123. package/kotlin/fixtures/import-scroll/README.md +15 -0
  124. package/kotlin/fixtures/import-scroll/app/nested+spa.tsx +1 -0
  125. package/kotlin/fixtures/import-values/ImportValues.native.tsx +26 -0
  126. package/kotlin/fixtures/import-values/README.md +6 -1
  127. package/kotlin/fixtures/import-values/Values.d.kt.ts +4 -0
  128. package/kotlin/fixtures/import-values/Values.kt +18 -0
  129. package/kotlin/tools/expo-types.main.kts +17 -5
  130. package/package.json +2 -1
  131. package/scripts/dev-server-scanner.ts +33 -23
  132. package/src/assetCatalogContract.ts +7 -0
  133. package/src/bridge-contract.ts +21 -3
  134. package/src/connect.ts +18 -5
  135. package/src/desktop-host-delivery.ts +33 -3
  136. package/src/expo-swift-host.ts +6 -3
  137. package/src/host/bridge-host.ts +2 -0
  138. package/src/host/fetch-proxy-transport.ts +8 -0
  139. package/src/kotlin-host.ts +11 -5
  140. package/src/native-firebase-config.ts +95 -54
  141. package/src/process-output.ts +16 -0
  142. package/src/sdk.ts +6 -1
  143. package/src/sim-client.ts +6 -1
  144. package/src/swift-compile.ts +36 -15
  145. package/src/swift-distribution.lock.json +8 -8
  146. package/src/swift-standin-emit.ts +10 -1
  147. package/src/swift-standins.ts +21 -7
  148. package/src/swift-submit.ts +6 -1
  149. package/src/worklet-executable.ts +7 -2
  150. /package/kotlin/fixtures/{kotlin-app-variants → import-scroll}/Nested.kt +0 -0
@@ -1,11 +1,29 @@
1
1
  package dev.contrast.kotlinimports
2
2
 
3
3
  import dev.onejs.one.source.OneModule
4
+ import kotlinx.coroutines.delay
4
5
  import org.json.JSONArray
5
6
  import org.json.JSONException
6
7
  import org.json.JSONObject
7
8
 
8
9
  object Values : OneModule {
10
+ private var processState = "initial"
11
+
12
+ fun current(): String = processState
13
+
14
+ suspend fun delayed(value: String): String {
15
+ delay(30)
16
+ processState = value
17
+ return processState
18
+ }
19
+
20
+ fun throwing(): String = throw IllegalStateException("sync failure")
21
+
22
+ suspend fun delayedThrow(): String {
23
+ delay(30)
24
+ throw IllegalArgumentException("suspend failure")
25
+ }
26
+
9
27
  fun nested(values: Map<String, List<Int>>): Map<String, List<Int>> = values
10
28
 
11
29
  fun nullable(value: String?): String? = value
@@ -76,13 +76,25 @@ for (path in args) {
76
76
  type.typeArgumentsAsTypes.singleOrNull()?.text ?: ""
77
77
  }
78
78
  if (composeView != null && !klass.hasModifier(KtTokens.ABSTRACT_KEYWORD)) {
79
- views += "{\"name\":${quote(name)},\"hidden\":$isHidden,\"props\":${quote(composeView)}}"
79
+ views += "{\"name\":${quote(name)},\"hidden\":$isHidden,\"props\":${quote(composeView)},\"appContext\":true}"
80
80
  return
81
81
  }
82
- if (!klass.hasModifier(KtTokens.ABSTRACT_KEYWORD) && klass.superTypeListEntries.any {
83
- (it.typeReference?.typeElement as? KtUserType)?.referencedName == "ExpoView"
84
- }) {
85
- views += "{\"name\":${quote(name)},\"hidden\":$isHidden,\"props\":\"\"}"
82
+ val isAndroidView = !klass.hasModifier(KtTokens.ABSTRACT_KEYWORD) && klass.superTypeListEntries.any {
83
+ val ref = it.typeReference?.text?.substringAfterLast('.')
84
+ ref == "ExpoView" || ref == "View" || ref == "FrameLayout" || ref == "LinearLayout" || ref == "ViewGroup"
85
+ }
86
+ if (isAndroidView) {
87
+ // native selects exact public constructor signatures, preferring Context/AppContext.
88
+ val constructors = listOfNotNull(klass.primaryConstructor) + klass.secondaryConstructors
89
+ val signatures = constructors.filter { !hidden(it) }.map { ctor ->
90
+ ctor.valueParameters.map { it.typeReference?.text?.substringAfterLast('.') }
91
+ }
92
+ val takesAppContext = when {
93
+ listOf("Context", "AppContext") in signatures -> true
94
+ listOf("Context") in signatures -> false
95
+ else -> return
96
+ }
97
+ views += "{\"name\":${quote(name)},\"hidden\":$isHidden,\"props\":\"\",\"appContext\":$takesAppContext}"
86
98
  return
87
99
  }
88
100
  if (klass.superTypeListEntries.any { it.typeReference?.text?.substringAfterLast('.') == "ComposeProps" }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.1406",
3
+ "version": "0.1.1465",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for peach.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -370,6 +370,7 @@
370
370
  "pngjs": "catalog:",
371
371
  "rollup": "4.62.2",
372
372
  "systemjs": "6.15.1",
373
+ "tsconfck": "3.1.6",
373
374
  "typescript": "7.1.0-dev.20261003.1",
374
375
  "vite": "8.3.3",
375
376
  "yaml": "^2.8.3"
@@ -861,38 +861,48 @@ export async function probePort(
861
861
  // going through scanDevServers. wait for the optional in-repo registry here
862
862
  // too, or a cold CLI process can build the known app's bundle url before its
863
863
  // runtime config is available.
864
- await loadOptionalDemoApps()
864
+ const demoApps = await loadOptionalDemoApps()
865
865
  const probeHostname = await tcpPing(port)
866
866
  if (!probeHostname) return null
867
867
 
868
+ // the owner lists a scanned server's project, and its cwd picks an in-repo
869
+ // demo app's runtime config. a direct probe on a published install has
870
+ // neither use, so it skips the lookups, which shell out to lsof on macOS.
871
+ // when they run, they overlap the http probes, which cost about as much.
868
872
  const suppliedPid = listeningProcess?.pid
869
- const ownerPid =
870
- suppliedPid && suppliedPid > 0 ? suppliedPid : await resolveListeningPid(port)
871
- const ownerCwd = ownerPid ? await resolveProcessCwd(ownerPid) : null
872
- const owner: Pick<DiscoveredServer, 'cwd' | 'pid'> = {
873
- ...(ownerPid ? { pid: ownerPid } : {}),
874
- ...(ownerCwd ? { cwd: ownerCwd } : {}),
875
- }
873
+ const ownerPromise = (async (): Promise<Pick<DiscoveredServer, 'cwd' | 'pid'>> => {
874
+ if (!listeningProcess && demoApps.length === 0) return {}
875
+ const ownerPid =
876
+ suppliedPid && suppliedPid > 0 ? suppliedPid : await resolveListeningPid(port)
877
+ const ownerCwd = ownerPid ? await resolveProcessCwd(ownerPid) : null
878
+ return {
879
+ ...(ownerPid ? { pid: ownerPid } : {}),
880
+ ...(ownerCwd ? { cwd: ownerCwd } : {}),
881
+ }
882
+ })()
876
883
 
877
884
  const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true`
878
885
 
879
886
  // wave 1 — safe parallel probes. none of these mutate metro state.
880
- const [sootsimRes, statusRes, manifestRes, expoRes, swiftRes, oneScanRes] =
887
+ const [[sootsimRes, statusRes, manifestRes, expoRes, swiftRes, oneScanRes], owner] =
881
888
  await Promise.all([
882
- knownNonPatched.has(port)
883
- ? Promise.resolve(null)
884
- : httpGet(probeHostname, port, '/__peachsim/'),
885
- httpGet(probeHostname, port, '/status'),
886
- knownOne.has(port)
887
- ? Promise.resolve(null)
888
- : httpGet(probeHostname, port, '/', 'GET', MANIFEST_TIMEOUT_MS, {
889
- 'expo-platform': 'ios',
890
- }),
891
- knownNonExpo.has(port)
892
- ? Promise.resolve(null)
893
- : httpGet(probeHostname, port, '/_expo/status'),
894
- httpGet(probeHostname, port, SWIFT_DISCOVERY_PATH),
895
- httpGet(probeHostname, port, '/__server-scan', 'GET', MANIFEST_TIMEOUT_MS),
889
+ Promise.all([
890
+ knownNonPatched.has(port)
891
+ ? Promise.resolve(null)
892
+ : httpGet(probeHostname, port, '/__peachsim/'),
893
+ httpGet(probeHostname, port, '/status'),
894
+ knownOne.has(port)
895
+ ? Promise.resolve(null)
896
+ : httpGet(probeHostname, port, '/', 'GET', MANIFEST_TIMEOUT_MS, {
897
+ 'expo-platform': 'ios',
898
+ }),
899
+ knownNonExpo.has(port)
900
+ ? Promise.resolve(null)
901
+ : httpGet(probeHostname, port, '/_expo/status'),
902
+ httpGet(probeHostname, port, SWIFT_DISCOVERY_PATH),
903
+ httpGet(probeHostname, port, '/__server-scan', 'GET', MANIFEST_TIMEOUT_MS),
904
+ ]),
905
+ ownerPromise,
896
906
  ])
897
907
 
898
908
  // the One plugin advertises its exact native entry, including replacement
@@ -47,6 +47,13 @@ export type SwiftImageResources = Record<string, Record<string, SwiftImageVarian
47
47
  // an app catalog color resolves by appearance or names a system color.
48
48
  export type AppCatalogColor = { light: string; dark: string } | { system: string }
49
49
 
50
+ // the premultiplied sRGB rendition SwiftUI passes to a shader image argument.
51
+ export interface SfSymbolShaderImage {
52
+ readonly width: number
53
+ readonly height: number
54
+ readonly pixels: Uint8Array
55
+ }
56
+
50
57
  // UIImage's ordered monochrome layers and unrounded content bearings.
51
58
  export interface SfSymbolImageDrawing {
52
59
  referencePointSize: number
@@ -351,8 +351,12 @@ export interface SemanticReadyProbe {
351
351
  flag: unknown
352
352
  at: number
353
353
  nodes: number
354
+ // ms the counted tree had held its shape when the count was read; 0 when
355
+ // the reader cannot tell
356
+ treeQuietMs: number
354
357
  targets: number
355
358
  liveFrameActive: boolean
359
+ paintedAppContent: boolean
356
360
  browserSurfaceCount: number
357
361
  liveFrameChannels: number
358
362
  liveFramePublishes: number
@@ -378,9 +382,15 @@ export const HEADLESS_TENANT_FETCH_HUNG_MARKER = 'headless tenant fetch hung'
378
382
 
379
383
  export function readyProbeHasContent(
380
384
  probe: Pick<SemanticReadyProbe, 'nodes' | 'targets'> &
381
- Partial<Pick<SemanticReadyProbe, 'liveFrameActive' | 'browserSurfaceCount'>>,
385
+ Partial<
386
+ Pick<
387
+ SemanticReadyProbe,
388
+ 'liveFrameActive' | 'browserSurfaceCount' | 'paintedAppContent'
389
+ >
390
+ >,
382
391
  ): boolean {
383
392
  return (
393
+ probe.paintedAppContent === true ||
384
394
  (probe.browserSurfaceCount ?? 0) > 0 ||
385
395
  probe.liveFrameActive ||
386
396
  probe.targets > 0 ||
@@ -390,10 +400,18 @@ export function readyProbeHasContent(
390
400
 
391
401
  export function readyProbeHasTargetContent(
392
402
  probe: Pick<SemanticReadyProbe, 'targets'> &
393
- Partial<Pick<SemanticReadyProbe, 'liveFrameActive' | 'browserSurfaceCount'>>,
403
+ Partial<
404
+ Pick<
405
+ SemanticReadyProbe,
406
+ 'liveFrameActive' | 'browserSurfaceCount' | 'paintedAppContent'
407
+ >
408
+ >,
394
409
  ): boolean {
395
410
  return (
396
- (probe.browserSurfaceCount ?? 0) > 0 || probe.liveFrameActive || probe.targets > 0
411
+ probe.paintedAppContent === true ||
412
+ (probe.browserSurfaceCount ?? 0) > 0 ||
413
+ probe.liveFrameActive ||
414
+ probe.targets > 0
397
415
  )
398
416
  }
399
417
 
package/src/connect.ts CHANGED
@@ -9,7 +9,13 @@ import {
9
9
  resolveCloudSessionForParsed,
10
10
  } from '../cli/cloud-client'
11
11
  import { readCurrentSimId } from '../cli/current-sim'
12
- import { createBridge, resolveBridgeWorld, type BridgeSimInfo } from '../cli/ws-bridge'
12
+ import {
13
+ createBridge,
14
+ createBridgeFromParsed,
15
+ resolveBridgeWorld,
16
+ type BridgeSimInfo,
17
+ type ParsedBridgeCliArgs,
18
+ } from '../cli/ws-bridge'
13
19
  import { SimClient, SimConnectError } from './sim-client'
14
20
 
15
21
  export interface ConnectOptions {
@@ -72,15 +78,22 @@ export async function connect(options: ConnectOptions = {}): Promise<SimClient>
72
78
  })
73
79
  }
74
80
 
75
- const port = options.port ?? resolveBridgeWorld().bridgePort
76
81
  const commandTimeoutMs = options.timeoutMs ?? 15_000
82
+ const parsed = {
83
+ ...target,
84
+ wsPort: options.port ?? resolveBridgeWorld().bridgePort,
85
+ explicitPort: options.port !== undefined,
86
+ commandTimeoutMs,
87
+ positional: [],
88
+ } satisfies ParsedBridgeCliArgs
77
89
 
78
90
  // discovery socket: list sims and pick the target. closed before the pinned
79
91
  // client socket is opened so a failed connect never leaks a connection.
80
- const discovery = createBridge(port, { commandTimeoutMs, cliLabel: 'sdk' })
92
+ const discovery = createBridgeFromParsed(parsed)
81
93
  let picked: BridgeSimInfo
82
94
  try {
83
- picked = pickSim(await discovery.listSims(), options.sim, port)
95
+ const sims = await discovery.listSims()
96
+ picked = pickSim(sims, options.sim, parsed.wsPort)
84
97
  } catch (error) {
85
98
  discovery.close()
86
99
  if (error instanceof SimConnectError) throw error
@@ -91,7 +104,7 @@ export async function connect(options: ConnectOptions = {}): Promise<SimClient>
91
104
 
92
105
  // pinned socket: every command targets exactly the picked sim, immune to the
93
106
  // CLI's saved-pin re-resolution.
94
- const bridge = createBridge(port, {
107
+ const bridge = createBridge(parsed.wsPort, {
95
108
  commandTimeoutMs,
96
109
  cliLabel: 'sdk',
97
110
  simId: picked.id,
@@ -1,6 +1,6 @@
1
1
  // peach's binding of the shared runtime-delivery machinery for the native
2
2
  // desktop host. the host releases on its own (packages/peach-desktop's Native
3
- // Desktop Release workflow) and the CLI downloads and verifies each version
3
+ // Desktop Release workflow) and explicit installs download and verify each version
4
4
  // into ~/.peach/desktop/versions/<version>/, exactly as `peach runtime install` manages
5
5
  // the engine runtime. on macOS the active version is at /Applications/Peach.app,
6
6
  // where Spotlight finds it and the CLI, the fleet installer and the app's own
@@ -9,6 +9,7 @@
9
9
  // its active version under ~/.peach and bundles node and software vulkan.
10
10
 
11
11
  import { spawnSync } from 'node:child_process'
12
+ import { createHash } from 'node:crypto'
12
13
  import fs from 'node:fs'
13
14
  import os from 'node:os'
14
15
  import path from 'node:path'
@@ -185,6 +186,32 @@ export function activeDesktopHostApp(): string | null {
185
186
  return process.platform === 'darwin' && placedHostVersion() ? MAC_HOST_APP : null
186
187
  }
187
188
 
189
+ /** protocol 2 binds port zero before publishing the host origin and evaluates
190
+ * in its realms over node ipc for the process that started it. the receipt
191
+ * travels inside the checksummed archive and signed mac bundle. bind its
192
+ * declaration to the entry bytes so a receipt cannot bless another source. */
193
+ export function hostEntrySupportsAutomaticPort(hostEntry: string): boolean {
194
+ try {
195
+ const contract: unknown = JSON.parse(
196
+ fs.readFileSync(
197
+ path.join(path.dirname(hostEntry), 'headless-contract.json'),
198
+ 'utf8',
199
+ ),
200
+ )
201
+ return (
202
+ typeof contract === 'object' &&
203
+ contract !== null &&
204
+ 'protocol' in contract &&
205
+ contract.protocol === 2 &&
206
+ 'entrySha256' in contract &&
207
+ contract.entrySha256 ===
208
+ createHash('sha256').update(fs.readFileSync(hostEntry)).digest('hex')
209
+ )
210
+ } catch {
211
+ return false
212
+ }
213
+ }
214
+
188
215
  /** the installed standalone host command; the driver appends its connection arguments. */
189
216
  export function headlessHostCommand(): string[] | null {
190
217
  const platform = desktopHostPlatform()
@@ -196,13 +223,16 @@ export function headlessHostCommand(): string[] | null {
196
223
  if (!app) return null
197
224
  const launcher = path.join(app, 'Contents/Frameworks/node')
198
225
  const host = path.join(app, 'Contents/Resources/node/host.mjs')
199
- // releases before the bundled launcher have no headless host
200
- if (!fs.existsSync(launcher) || !fs.existsSync(host)) return null
226
+ // releases before the bundled launcher have no headless host, and releases
227
+ // before the automatic-port bind cannot serve `--port 0`; use the browser
228
+ if (!fs.existsSync(launcher) || !hostEntrySupportsAutomaticPort(host)) return null
201
229
  return [launcher, '--experimental-vm-modules', host]
202
230
  }
203
231
  const version = placedHostVersion()
204
232
  if (!version) return null
205
233
  const dir = desktopHostDir(version)
234
+ // a placed host that predates the automatic-port bind upgrades like a missing one
235
+ if (!hostEntrySupportsAutomaticPort(path.join(dir, 'node/host.mjs'))) return null
206
236
  return [
207
237
  path.join(dir, 'bin/node'),
208
238
  '--experimental-vm-modules',
@@ -353,14 +353,17 @@ export async function compileExpoSwiftModules(
353
353
  !(error instanceof SwiftMissingModulesError)
354
354
  )
355
355
  throw error
356
+ // a source error or a module peach does not provide holds until the
357
+ // stamp changes; retrying either relinks every namespace and rewrites
358
+ // the linked package, so no reload would ever reuse its artifact.
359
+ const missing = error instanceof SwiftMissingModulesError
356
360
  if (
357
- sourceErrors.length &&
358
- sourceErrors.length === errors.length &&
361
+ ((sourceErrors.length && sourceErrors.length === errors.length) || missing) &&
359
362
  sourceStamp(packagePath, module.root) === stamps.get(unit)
360
363
  ) {
361
364
  failedCompilations.set(packagePath, {
362
365
  stamp: stamps.get(unit)!,
363
- diagnostic: sourceErrors.join('\n'),
366
+ diagnostic: missing ? diagnostic : sourceErrors.join('\n'),
364
367
  })
365
368
  }
366
369
  failures.set(
@@ -16,6 +16,7 @@ import {
16
16
  SOOTSIM_BRIDGE_SIM_CLOSE_REASON,
17
17
  } from '../bridge-constants.ts'
18
18
  import { getCliVersion } from '../cli-version.ts'
19
+ import { activeDesktopHostApp } from '../desktop-host-delivery.ts'
19
20
  import { handleExpoSwiftRequest } from '../expo-swift-host.ts'
20
21
  import {
21
22
  activeRuntimeDir as getActiveRuntimeDir,
@@ -361,6 +362,7 @@ export function injectSharedConfigIntoHtml(
361
362
  `<script>window.__sootsimSharedConfig=${payload};` +
362
363
  bridgePortTag +
363
364
  contrastOriginTag +
365
+ `window.__peachDesktopHostInstalled=${activeDesktopHostApp() !== null};` +
364
366
  `window.__sootsimCliVersion=${JSON.stringify(getCliVersion())};</script>`
365
367
  const html = data.toString('utf8')
366
368
  if (html.includes('<head>')) return html.replace('<head>', `<head>${tag}`)
@@ -82,10 +82,18 @@ async function createIdentity(): Promise<Omit<FetchTransportIdentity, 'port'>> {
82
82
  export async function startFetchProxyTransport(identityPath: string) {
83
83
  const identity = readIdentity(identityPath) ?? { ...(await createIdentity()), port: 0 }
84
84
  const sessions = new Set<ServerHttp2Session>()
85
+ // every open stream holds its queued response data against maxSessionMemory
86
+ // (megabytes), a few hundred kilobytes once a body outgrows the peer's
87
+ // window. past that cap bun answers new streams with GOAWAY
88
+ // ENHANCE_YOUR_CALM, failing every request on the session, or leaves them
89
+ // unanswered. the default 10 MB covers about 40 such streams, while the
90
+ // native host's undici adopts the advertised 256 as its own limit, so the cap
91
+ // covers a megabyte for each stream it admits.
85
92
  const server = createSecureServer(
86
93
  {
87
94
  cert: identity.cert,
88
95
  key: identity.key,
96
+ maxSessionMemory: 256,
89
97
  settings: { maxConcurrentStreams: 256 },
90
98
  },
91
99
  (req, res) => {
@@ -343,7 +343,7 @@ interface ExpoTypesFile {
343
343
  hiddenParameter: boolean
344
344
  }[]
345
345
  // Expo views; compose views also name their props class
346
- views: { name: string; hidden: boolean; props: string }[]
346
+ views: { name: string; hidden: boolean; props: string; appContext: boolean }[]
347
347
  // ComposeProps classes with their public member properties
348
348
  props: { name: string; hidden: boolean; states: string[] }[]
349
349
  }
@@ -433,10 +433,16 @@ function readExpoTypes(output: string): ExpoTypesFile[] {
433
433
  !isRecord(view) ||
434
434
  typeof view.name !== 'string' ||
435
435
  typeof view.hidden !== 'boolean' ||
436
- typeof view.props !== 'string'
436
+ typeof view.props !== 'string' ||
437
+ typeof view.appContext !== 'boolean'
437
438
  )
438
439
  throw new Error('expo-types printed an invalid view')
439
- return { name: view.name, hidden: view.hidden, props: view.props }
440
+ return {
441
+ name: view.name,
442
+ hidden: view.hidden,
443
+ props: view.props,
444
+ appContext: view.appContext,
445
+ }
440
446
  }),
441
447
  props: row.props.map((entry: unknown) => {
442
448
  if (
@@ -463,7 +469,7 @@ function scanExpoTypes(
463
469
  sources: string[],
464
470
  ): Promise<ExpoTypesFile[]> {
465
471
  const candidates = sources.filter((file) =>
466
- /\bRecord\b|\benum\s+class\b|\bExpoComposeView\b|\bExpoView\b|\bComposeProps\b/.test(
472
+ /\bRecord\b|\benum\s+class\b|\bExpoComposeView\b|\bExpoView\b|\bComposeProps\b|:\s*(?:[A-Za-z0-9_.]+\.)?(?:View|FrameLayout|LinearLayout|ViewGroup)\b/.test(
467
473
  fs.readFileSync(file, 'utf8'),
468
474
  ),
469
475
  )
@@ -560,7 +566,7 @@ function expoTypeSources(files: ExpoTypesFile[]): {
560
566
  .filter((view) => !view.hidden)
561
567
  .map(
562
568
  (view) =>
563
- ` dev.peach.expo.PeachExpoTypes.view(${view.name}::class, ${view.props ? `${view.props}::class` : 'null'}) { context, appContext -> ${view.name}(context, appContext) }`,
569
+ ` dev.peach.expo.PeachExpoTypes.view(${view.name}::class, ${view.props ? `${view.props}::class` : 'null'}) { context, appContext -> ${view.name}(${view.appContext ? 'context, appContext' : 'context'}) }`,
564
570
  ),
565
571
  ...file.props
566
572
  .filter((entry) => !entry.hidden)
@@ -1,6 +1,6 @@
1
1
  // read native default initialization inputs before the guest Firebase SDK starts.
2
2
  // expose only FirebaseOptions, never a partner-specific extra configuration.
3
- import { spawnSync } from 'node:child_process'
3
+ import { spawn, spawnSync } from 'node:child_process'
4
4
  import fs from 'node:fs'
5
5
  import os from 'node:os'
6
6
  import path from 'node:path'
@@ -8,13 +8,18 @@ import { isRecord } from './is-record'
8
8
  import { parsePlistSource } from './plist'
9
9
  import { listXcodeAppTargets, readXcodeAppTarget } from './xcode-project'
10
10
 
11
- export function readNativeFirebaseConfig(
12
- projectRoot: string,
13
- platforms: readonly string[] = ['ios', 'android'],
14
- iosTarget?: string,
15
- ) {
16
- const result: Record<string, unknown> = {}
17
- let config: Record<string, unknown> = {}
11
+ // expo's synchronous ESM/TypeScript loader requires Node's module evaluator.
12
+ // a private result file keeps authored console output off the configuration wire.
13
+ const EXPO_CONFIG_SCRIPT = `const { createRequire } = require('node:module');
14
+ const { writeFileSync } = require('node:fs');
15
+ const { join } = require('node:path');
16
+ const root = process.argv[1];
17
+ const { getConfig } = createRequire(join(root, 'package.json'))('@expo/config');
18
+ const result = JSON.stringify(getConfig(root, { skipSDKVersionRequirement: true }).exp);
19
+ if (Buffer.byteLength(result) > 1024 * 1024) throw new Error('Expo native configuration exceeds 1 MiB.');
20
+ writeFileSync(process.argv[2], result, { mode: 0o600 });`
21
+
22
+ function hasExpoConfig(projectRoot: string) {
18
23
  const appJsonPath = path.join(projectRoot, 'app.json')
19
24
  const appJson: unknown = fs.existsSync(appJsonPath)
20
25
  ? JSON.parse(fs.readFileSync(appJsonPath, 'utf8'))
@@ -25,53 +30,89 @@ export function readNativeFirebaseConfig(
25
30
  [appJson.ios, appJson.android].some(
26
31
  (value) => isRecord(value) && typeof value.googleServicesFile === 'string',
27
32
  ))
28
- const dynamicExpo = [
29
- 'app.config.js',
30
- 'app.config.ts',
31
- 'app.config.cjs',
32
- 'app.config.mjs',
33
- ].some((name) => fs.existsSync(path.join(projectRoot, name)))
34
- if (staticExpo || dynamicExpo) {
35
- // expo's synchronous ESM/TypeScript loader requires Node's module evaluator.
36
- // a private result file keeps authored console output off the configuration wire.
37
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'peach-expo-config-'))
38
- const resultPath = path.join(directory, 'config.json')
39
- try {
40
- const child = spawnSync(
41
- 'node',
42
- [
43
- '--input-type=commonjs',
44
- '-e',
45
- `const { createRequire } = require('node:module');
46
- const { writeFileSync } = require('node:fs');
47
- const { join } = require('node:path');
48
- const root = process.argv[1];
49
- const { getConfig } = createRequire(join(root, 'package.json'))('@expo/config');
50
- const result = JSON.stringify(getConfig(root, { skipSDKVersionRequirement: true }).exp);
51
- if (Buffer.byteLength(result) > 1024 * 1024) throw new Error('Expo native configuration exceeds 1 MiB.');
52
- writeFileSync(process.argv[2], result, { mode: 0o600 });`,
53
- path.resolve(projectRoot),
54
- resultPath,
55
- ],
56
- {
57
- cwd: projectRoot,
58
- encoding: 'utf8',
59
- stdio: ['ignore', 'ignore', 'pipe'],
60
- timeout: 30_000,
61
- maxBuffer: 1024 * 1024,
62
- },
63
- )
64
- if (child.error) throw child.error
65
- if (child.status !== 0)
66
- throw new Error(child.stderr || 'Expo native configuration evaluation failed.')
67
- const resolved: unknown = JSON.parse(fs.readFileSync(resultPath, 'utf8'))
68
- if (!isRecord(resolved))
69
- throw new Error('Expo native configuration is unavailable.')
70
- config = resolved
71
- } finally {
72
- fs.rmSync(directory, { recursive: true, force: true })
73
- }
33
+ return (
34
+ staticExpo ||
35
+ ['app.config.js', 'app.config.ts', 'app.config.cjs', 'app.config.mjs'].some((name) =>
36
+ fs.existsSync(path.join(projectRoot, name)),
37
+ )
38
+ )
39
+ }
40
+
41
+ function expoConfigArgs(projectRoot: string, resultPath: string) {
42
+ return [
43
+ '--input-type=commonjs',
44
+ '-e',
45
+ EXPO_CONFIG_SCRIPT,
46
+ path.resolve(projectRoot),
47
+ resultPath,
48
+ ]
49
+ }
50
+
51
+ function readExpoConfigResult(resultPath: string, status: number | null, stderr: string) {
52
+ if (status !== 0)
53
+ throw new Error(stderr || 'Expo native configuration evaluation failed.')
54
+ const resolved: unknown = JSON.parse(fs.readFileSync(resultPath, 'utf8'))
55
+ if (!isRecord(resolved)) throw new Error('Expo native configuration is unavailable.')
56
+ return resolved
57
+ }
58
+
59
+ const resultDirectory = () => fs.mkdtempSync(path.join(os.tmpdir(), 'peach-expo-config-'))
60
+
61
+ function readExpoConfig(projectRoot: string): Record<string, unknown> {
62
+ if (!hasExpoConfig(projectRoot)) return {}
63
+ const directory = resultDirectory()
64
+ const resultPath = path.join(directory, 'config.json')
65
+ try {
66
+ const child = spawnSync('node', expoConfigArgs(projectRoot, resultPath), {
67
+ cwd: projectRoot,
68
+ encoding: 'utf8',
69
+ stdio: ['ignore', 'ignore', 'pipe'],
70
+ timeout: 30_000,
71
+ maxBuffer: 1024 * 1024,
72
+ })
73
+ if (child.error) throw child.error
74
+ return readExpoConfigResult(resultPath, child.status, child.stderr)
75
+ } finally {
76
+ fs.rmSync(directory, { recursive: true, force: true })
77
+ }
78
+ }
79
+
80
+ // the same evaluation without blocking, so a caller can overlap it with other
81
+ // startup work and hand the result to readNativeFirebaseConfig.
82
+ export async function readExpoConfigAsync(
83
+ projectRoot: string,
84
+ ): Promise<Record<string, unknown>> {
85
+ if (!hasExpoConfig(projectRoot)) return {}
86
+ const directory = resultDirectory()
87
+ const resultPath = path.join(directory, 'config.json')
88
+ try {
89
+ const child = spawn('node', expoConfigArgs(projectRoot, resultPath), {
90
+ cwd: projectRoot,
91
+ stdio: ['ignore', 'ignore', 'pipe'],
92
+ timeout: 30_000,
93
+ })
94
+ let stderr = ''
95
+ child.stderr.setEncoding('utf8')
96
+ child.stderr.on('data', (chunk: string) => {
97
+ stderr += chunk
98
+ })
99
+ const status = await new Promise<number | null>((resolve, reject) => {
100
+ child.once('error', reject)
101
+ child.once('close', resolve)
102
+ })
103
+ return readExpoConfigResult(resultPath, status, stderr)
104
+ } finally {
105
+ fs.rmSync(directory, { recursive: true, force: true })
74
106
  }
107
+ }
108
+
109
+ export function readNativeFirebaseConfig(
110
+ projectRoot: string,
111
+ platforms: readonly string[] = ['ios', 'android'],
112
+ iosTarget?: string,
113
+ config: Record<string, unknown> = readExpoConfig(projectRoot),
114
+ ) {
115
+ const result: Record<string, unknown> = {}
75
116
  const ios = config.ios
76
117
  if (
77
118
  platforms.includes('ios') &&
@@ -0,0 +1,16 @@
1
+ // wait for both pipes before forcing an exit with live handles.
2
+ // console writers must use these streams rather than a separate runtime sink.
3
+ export async function exitWithFlushedOutput(code: number): Promise<never> {
4
+ await Promise.all(
5
+ [process.stdout, process.stderr].map(
6
+ (stream) =>
7
+ new Promise<void>((resolve, reject) => {
8
+ stream.end((error?: Error | null) => {
9
+ if (error) reject(error)
10
+ else resolve()
11
+ })
12
+ }),
13
+ ),
14
+ )
15
+ process.exit(code)
16
+ }
package/src/sdk.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  export * from './sim-client'
9
9
  export * from '../cli/commands/inspect/core'
10
10
  export * from '../cli/commands/inspect/actions'
11
- export { waitForSootsimIdle, waitReadyAndSettle } from '../cli/commands/inspect/settling'
11
+ export {
12
+ IdleObservationError,
13
+ RuntimeReadinessUnsupportedError,
14
+ waitForSootsimIdle,
15
+ waitReadyAndSettle,
16
+ } from '../cli/commands/inspect/settling'
12
17
  export type {
13
18
  SettledWaitReady,
14
19
  WaitForSootsimIdleOptions,