peachsim 0.1.1625 → 0.1.1640

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1625 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
  let __sootsim_import_meta_url = '', __sootsim_import_meta_dirname = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; __sootsim_import_meta_dirname = __dirname; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -185,6 +185,8 @@ function enter(node, scope) {
185
185
  const name = identifierName(id);
186
186
  if (name !== void 0 && isNode(init))
187
187
  declare(target, name, { kind: "value", init, scope });
188
+ else if (name !== void 0 && init === null)
189
+ declare(target, name, { kind: "uninitialized" });
188
190
  else declareOpaque(target, patternNames(id));
189
191
  }
190
192
  return scope;
@@ -254,12 +256,18 @@ function collectInitData(root) {
254
256
  immediate: /* @__PURE__ */ new Map(),
255
257
  writes: [],
256
258
  reassigned: /* @__PURE__ */ new Set(),
259
+ assigned: /* @__PURE__ */ new Map(),
257
260
  free: /* @__PURE__ */ new Map()
258
261
  };
259
262
  visit(root, newScope(void 0, true), collection);
260
263
  for (const { name, scope, value } of collection.writes) {
261
264
  const binding = lookup(scope, name);
262
- if (binding) collection.reassigned.add(binding);
265
+ if (binding?.kind === "uninitialized")
266
+ collection.assigned.set(
267
+ binding,
268
+ collection.assigned.has(binding) || !value ? null : { value, scope }
269
+ );
270
+ else if (binding) collection.reassigned.add(binding);
263
271
  else
264
272
  collection.free.set(
265
273
  name,
@@ -320,6 +328,10 @@ function resolveObject(expression, scope, collection, depth) {
320
328
  return free ? resolveObject(free.value, free.scope, collection, depth + 1) : void 0;
321
329
  }
322
330
  if (collection.reassigned.has(binding)) return void 0;
331
+ if (binding.kind === "uninitialized") {
332
+ const assigned = collection.assigned.get(binding);
333
+ return assigned ? resolveObject(assigned.value, assigned.scope, collection, depth + 1) : void 0;
334
+ }
323
335
  if (binding.kind === "value")
324
336
  return resolveObject(binding.init, binding.scope, collection, depth + 1);
325
337
  if (binding.kind === "param") {
@@ -1,4 +1,4 @@
1
- /*! peach v0.1.1625 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
1
+ /*! peach v0.1.1640 | (c) 2026 Tamagui LLC | Proprietary; see LICENSE */
2
2
 
3
3
  // src/worklet-executable.ts
4
4
  import { parse } from "acorn";
@@ -160,6 +160,8 @@ function enter(node, scope) {
160
160
  const name = identifierName(id);
161
161
  if (name !== void 0 && isNode(init))
162
162
  declare(target, name, { kind: "value", init, scope });
163
+ else if (name !== void 0 && init === null)
164
+ declare(target, name, { kind: "uninitialized" });
163
165
  else declareOpaque(target, patternNames(id));
164
166
  }
165
167
  return scope;
@@ -229,12 +231,18 @@ function collectInitData(root) {
229
231
  immediate: /* @__PURE__ */ new Map(),
230
232
  writes: [],
231
233
  reassigned: /* @__PURE__ */ new Set(),
234
+ assigned: /* @__PURE__ */ new Map(),
232
235
  free: /* @__PURE__ */ new Map()
233
236
  };
234
237
  visit(root, newScope(void 0, true), collection);
235
238
  for (const { name, scope, value } of collection.writes) {
236
239
  const binding = lookup(scope, name);
237
- if (binding) collection.reassigned.add(binding);
240
+ if (binding?.kind === "uninitialized")
241
+ collection.assigned.set(
242
+ binding,
243
+ collection.assigned.has(binding) || !value ? null : { value, scope }
244
+ );
245
+ else if (binding) collection.reassigned.add(binding);
238
246
  else
239
247
  collection.free.set(
240
248
  name,
@@ -295,6 +303,10 @@ function resolveObject(expression, scope, collection, depth) {
295
303
  return free ? resolveObject(free.value, free.scope, collection, depth + 1) : void 0;
296
304
  }
297
305
  if (collection.reassigned.has(binding)) return void 0;
306
+ if (binding.kind === "uninitialized") {
307
+ const assigned = collection.assigned.get(binding);
308
+ return assigned ? resolveObject(assigned.value, assigned.scope, collection, depth + 1) : void 0;
309
+ }
298
310
  if (binding.kind === "value")
299
311
  return resolveObject(binding.init, binding.scope, collection, depth + 1);
300
312
  if (binding.kind === "param") {
@@ -4,18 +4,21 @@ import androidx.compose.ui.graphics.Brush
4
4
  import androidx.compose.ui.graphics.Color
5
5
  import androidx.compose.ui.graphics.ImageBitmap
6
6
  import androidx.compose.ui.graphics.SolidColor
7
- import androidx.compose.ui.graphics.toComposeImageBitmap
7
+ import androidx.compose.ui.graphics.asComposeImageBitmap
8
8
  import androidx.compose.ui.graphics.vector.ImageVector
9
9
  import kotlin.io.encoding.Base64
10
10
  import kotlin.io.encoding.ExperimentalEncodingApi
11
+ import org.jetbrains.skia.Bitmap as SkiaBitmap
11
12
  import org.jetbrains.skia.BlendMode
13
+ import org.jetbrains.skia.Canvas
14
+ import org.jetbrains.skia.Codec
15
+ import org.jetbrains.skia.Data
12
16
  import org.jetbrains.skia.FilterMipmap
13
17
  import org.jetbrains.skia.FilterMode
14
18
  import org.jetbrains.skia.Image
15
19
  import org.jetbrains.skia.MipmapMode
16
20
  import org.jetbrains.skia.Paint
17
21
  import org.jetbrains.skia.Rect
18
- import org.jetbrains.skia.Surface
19
22
 
20
23
  // one file a drawable resource resolves to under a density (ResTable_config's
21
24
  // value, 0 for an unqualified directory) and a platform version: a vector the
@@ -67,28 +70,32 @@ private fun isBetter(a: PeachDrawable, b: PeachDrawable, requested: Int): Boolea
67
70
  // bitmap drawn for a higher density than the display's is scaled down, its
68
71
  // size rounded, through a linear filtered draw with the source blend; one
69
72
  // drawn for a lower density, or nodpi, keeps its pixels, as from API 28 the
70
- // upscale is left to draw time, which BitmapPainter does not do
73
+ // upscale is left to draw time, which BitmapPainter does not do. as
74
+ // ImageDecoder does, the codec decodes into a bitmap and the scale draws into
75
+ // another
71
76
  @OptIn(ExperimentalEncodingApi::class)
72
77
  internal fun peachDecodeBitmap(drawable: PeachDrawable.Bitmap, targetDensity: Int): ImageBitmap {
73
78
  if (drawable.ninePatch)
74
79
  throw UnsupportedOperationException("peach kotlin: the nine-patch drawable ${drawable.path} is not drawn yet")
75
- val image = Image.makeFromEncoded(Base64.decode(drawable.data))
80
+ val decoded = Codec.makeFromData(Data.makeFromBytes(Base64.decode(drawable.data))).readPixels()
76
81
  val density = if (drawable.density == DENSITY_DEFAULT) DENSITY_MEDIUM else drawable.density
77
- if (drawable.density == DENSITY_NONE || density <= targetDensity) return image.toComposeImageBitmap()
82
+ if (drawable.density == DENSITY_NONE || density <= targetDensity) return decoded.asComposeImageBitmap()
78
83
  val scale = targetDensity.toFloat() / density
79
- val width = maxOf((image.width * scale + 0.5f).toInt(), 1)
80
- val height = maxOf((image.height * scale + 0.5f).toInt(), 1)
81
- val surface = Surface.makeRasterN32Premul(width, height)
82
- surface.canvas.scale(width.toFloat() / image.width, height.toFloat() / image.height)
83
- surface.canvas.drawImageRect(
84
- image,
85
- Rect.makeWH(image.width.toFloat(), image.height.toFloat()),
86
- Rect.makeWH(image.width.toFloat(), image.height.toFloat()),
84
+ val width = maxOf((decoded.width * scale + 0.5f).toInt(), 1)
85
+ val height = maxOf((decoded.height * scale + 0.5f).toInt(), 1)
86
+ val scaled = SkiaBitmap().apply { allocN32Pixels(width, height) }
87
+ val canvas = Canvas(scaled)
88
+ canvas.scale(width.toFloat() / decoded.width, height.toFloat() / decoded.height)
89
+ canvas.drawImageRect(
90
+ Image.makeFromBitmap(decoded),
91
+ Rect.makeWH(decoded.width.toFloat(), decoded.height.toFloat()),
92
+ Rect.makeWH(decoded.width.toFloat(), decoded.height.toFloat()),
87
93
  FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE),
88
94
  Paint().apply { blendMode = BlendMode.SRC },
89
95
  false,
90
96
  )
91
- return surface.makeImageSnapshot().toComposeImageBitmap()
97
+ canvas.close()
98
+ return scaled.asComposeImageBitmap()
92
99
  }
93
100
 
94
101
  // ComplexColorCompat.willDraw: a transparent solid color draws nothing
@@ -0,0 +1,14 @@
1
+ package androidx.annotation
2
+
3
+ // androidx.annotation's DrawableRes, which only its android artifact declares
4
+ @MustBeDocumented
5
+ @Retention(AnnotationRetention.BINARY)
6
+ @Target(
7
+ AnnotationTarget.FUNCTION,
8
+ AnnotationTarget.PROPERTY_GETTER,
9
+ AnnotationTarget.PROPERTY_SETTER,
10
+ AnnotationTarget.VALUE_PARAMETER,
11
+ AnnotationTarget.FIELD,
12
+ AnnotationTarget.LOCAL_VARIABLE,
13
+ )
14
+ annotation class DrawableRes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.1625",
3
+ "version": "0.1.1640",
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",
@@ -25,6 +25,7 @@ import {
25
25
  } from '../src/backend-origin.ts'
26
26
  import { applyPeachConfigToUrl } from '../src/config.ts'
27
27
  import { fetchDevManifest } from '../src/dev-bundle-resolution'
28
+ import { parseExpoManifestBody } from '../src/expo-manifest-body'
28
29
  import {
29
30
  KOTLIN_DISCOVERY_PATH,
30
31
  KOTLIN_GUEST_PATH,
@@ -969,7 +970,7 @@ function applyManifest(
969
970
  ): DiscoveredServer {
970
971
  if (!manifestRes) return result
971
972
  try {
972
- const manifest = JSON.parse(manifestRes.body)
973
+ const manifest = parseExpoManifestBody(manifestRes.body)
973
974
  const client = manifest?.extra?.expoClient || manifest?.extra || {}
974
975
  if (client.name) result.projectName = client.name
975
976
  if (typeof client.primaryColor === 'string') result.primaryColor = client.primaryColor
@@ -310,9 +310,14 @@ attributes a missing frame to its hop:
310
310
  - `delivery lag` — vsync postMessage receipt latency above the run's
311
311
  best case; jitter here means the shell worker was descheduled or its
312
312
  queue was busy.
313
- - `paint interval` — the end-to-end cadence the user perceives.
314
- - `idle breaks` — gaps >250ms, excluded from all three: demand-gated
315
- quiesce is legitimate, not starvation.
313
+ - `present interval` — intervals between admitted compositor presentations,
314
+ including native frames, while progress remains pending after the preceding
315
+ presentation (`motionPending` in `compositorCadence.frameSeries`). Settled
316
+ admitted frames end a sequence; deferred frames retain the pending gap.
317
+ `paintIntervals` reports the sample count, so zero samples do not prove
318
+ smoothness. Shell work and JavaScript flushes can park while native frames
319
+ continue presenting.
320
+ - `idle breaks` — host gaps >250ms, excluded from host interval statistics.
316
321
 
317
322
  the compositor `compositorHostFrameClock` keeps its historical name and reports
318
323
  display/timer ticks consumed by the compositor worker's resilient clock during
@@ -320,8 +325,9 @@ the acknowledged profile window. `published` is null because renderer-main no
320
325
  longer publishes compositor frames. sparse consumed ticks with low production
321
326
  time identifies worker scheduling starvation. many consumed ticks with sparse
322
327
  paints can be legitimate dirty-gating; correlate the frame series before
323
- treating it as a regression. shell cadence comes from its worker frame and paint
324
- series.
328
+ treating it as a regression. Host and shell delivery statistics come from the
329
+ shell worker; `cadence.paintInterval*` comes from compositor presentation times
330
+ during motion.
325
331
 
326
332
  compare baseline vs contended captures of the same scripted window; also
327
333
  compare **painted-frame counts**, the crudest and most robust signal.
@@ -42,7 +42,8 @@ debugging or `/peach-test` for writing tests.
42
42
  daemon is down, the dev server port is wrong, or the runtime is missing.
43
43
  walk the recovery checklist before reinstalling.
44
44
  - **keep one sim for your whole session.** the first `peach open <port>`
45
- launches Chrome for Testing with an isolated profile. later opens reuse the
45
+ launches the simulator window: the native desktop app on Apple silicon macOS
46
+ and Linux x64, otherwise Chrome for Testing with an isolated profile. later opens reuse the
46
47
  saved sim and navigate it in place. use `peach do reload` when the target
47
48
  has not changed:
48
49
 
@@ -74,27 +75,23 @@ debugging or `/peach-test` for writing tests.
74
75
  saved sim to reuse. verify the development server and Peach runtime, then
75
76
  retry once after fixing the cause.
76
77
 
77
- ## choose the runtime
78
+ ## the simulator window
78
79
 
79
- on Apple silicon macOS and Linux x64, recommend trying the optional native
80
- runtime for faster simulation. it is beta, so the user may run into more issues
81
- than in the browser. keep the current default unless the user chooses native:
80
+ on Apple silicon macOS and Linux x64, `peach open` shows the app in the native
81
+ desktop app, which is faster than the browser. setup installs it, and on a Mac
82
+ the first `peach open` installs it if it is missing. do not pass a driver flag
83
+ for the user's own viewing.
82
84
 
83
- ```sh
84
- peach desktop install
85
- peach open 8081 --driver desktop
86
- ```
87
-
88
- use the app's actual development server port. the Linux desktop app needs a
89
- desktop session; its `.deb` installer uses `sudo apt-get install`.
90
- `peach desktop install --native` on Linux installs the standalone headless host
91
- instead of the desktop app. Intel Mac and Linux arm64 use the browser.
92
- `peach open 8081 --driver playwright --headed` explicitly opens a browser window.
93
- pass the selected driver when opening the app in the steps below; installing
94
- the desktop app alone does not change the saved window preference.
85
+ the Linux desktop app needs a desktop session; its `.deb` installer uses
86
+ `sudo apt-get install`. `peach desktop install --native` on Linux installs the
87
+ standalone headless host instead of the desktop app. Intel Mac and Linux arm64
88
+ use the browser. `peach open 8081 --driver playwright --headed` explicitly opens
89
+ a browser window. a yellow "GPU disabled" item in the browser simulator's menu
90
+ bar means it is drawing without a GPU and will be slow; move the user to the
91
+ desktop app.
95
92
 
96
- the native runtime renders the simulator. the background service below keeps
97
- the bridge ready for either runtime and is a separate optional choice.
93
+ the background service below keeps the bridge ready for either window and is a
94
+ separate optional choice.
98
95
 
99
96
  ## first move
100
97
 
@@ -98,12 +98,16 @@ export function isBrowserAssetProxyUrl(value: string): boolean {
98
98
  }
99
99
  }
100
100
 
101
+ // the PeachSim app's host; the backend serves every path on it
102
+ export const PEACHSIM_NATIVE_HOST = 'native.peachsim.com'
103
+
101
104
  export function isContrastBackendHost(hostname: string): boolean {
102
105
  const normalized = normalizeHostname(hostname)
103
106
  return (
104
107
  isLoopbackHost(normalized) ||
105
108
  normalized === 'contrast.dev' ||
106
- normalized.endsWith('.contrast.dev')
109
+ normalized.endsWith('.contrast.dev') ||
110
+ normalized === PEACHSIM_NATIVE_HOST
107
111
  )
108
112
  }
109
113
 
@@ -115,15 +119,18 @@ export function isContrastBackendOrigin(origin: string): boolean {
115
119
  }
116
120
  }
117
121
 
118
- export function resolveContrastBackendOrigin(currentOrigin?: string | null): string {
119
- const origin = currentOrigin?.trim()
122
+ // a page's `?backend=` names another backend origin, judged by the same rule
123
+ export function resolveContrastBackendOrigin(
124
+ currentOrigin?: string | null,
125
+ search?: string | null,
126
+ ): string {
127
+ const origin =
128
+ new URLSearchParams(search ?? '').get('backend')?.trim() || currentOrigin?.trim()
120
129
  if (!origin) return DEFAULT_CONTRAST_BACKEND_ORIGIN
121
130
 
122
131
  try {
123
132
  const parsed = new URL(origin)
124
- if (isLoopbackHost(parsed.hostname) || isContrastBackendHost(parsed.hostname)) {
125
- return parsed.origin
126
- }
133
+ if (isContrastBackendHost(parsed.hostname)) return parsed.origin
127
134
  } catch {}
128
135
 
129
136
  return DEFAULT_CONTRAST_BACKEND_ORIGIN
@@ -6,6 +6,17 @@ import { PEACH_SIMULATOR_PATH } from './simulator-route'
6
6
  export const DEFAULT_SOOTSIM_BRIDGE_PORT = 7668
7
7
  export const DEFAULT_SOOTSIM_BRIDGE_URL = `http://localhost:${DEFAULT_SOOTSIM_BRIDGE_PORT}/`
8
8
 
9
+ export function assertPeachPortEnvironment(env: Record<string, unknown>): void {
10
+ for (const [removed, replacement] of [
11
+ ['SOOTSIM_SHELL_PORT', 'PEACH_SHELL_PORT'],
12
+ ['SOOTSIM_BRIDGE_PORT', 'PEACH_BRIDGE_PORT'],
13
+ ] satisfies [string, string][]) {
14
+ if (env[removed] !== undefined) {
15
+ throw new Error(`${removed} has been removed; unset it and use ${replacement}`)
16
+ }
17
+ }
18
+ }
19
+
9
20
  function parsePort(value: unknown): number | null {
10
21
  if (typeof value === 'number') {
11
22
  return Number.isInteger(value) && value > 0 ? value : null
@@ -385,39 +385,29 @@ export const SEMANTIC_READY_POLL_MS = 150
385
385
 
386
386
  export const HEADLESS_TENANT_FETCH_HUNG_MARKER = 'headless tenant fetch hung'
387
387
 
388
- export function readyProbeHasContent(
389
- probe: Pick<SemanticReadyProbe, 'nodes' | 'targets'> &
390
- Partial<
391
- Pick<
392
- SemanticReadyProbe,
393
- 'liveFrameActive' | 'browserSurfaceCount' | 'paintedAppContent'
394
- >
395
- >,
396
- ): boolean {
397
- return (
398
- probe.paintedAppContent === true ||
399
- (probe.browserSurfaceCount ?? 0) > 0 ||
400
- probe.liveFrameActive ||
401
- probe.targets > 0 ||
402
- probe.nodes >= SEMANTIC_READY_CONTENT_NODE_FLOOR
403
- )
388
+ type ReadyContentProbe = Pick<SemanticReadyProbe, 'nodes' | 'targets'> &
389
+ Partial<
390
+ Pick<
391
+ SemanticReadyProbe,
392
+ 'liveFrameActive' | 'browserSurfaceCount' | 'paintedAppContent' | 'flag'
393
+ >
394
+ >
395
+
396
+ // the first content condition a probe satisfies. a probe without the ready
397
+ // flag counts only targeted content, never the node floor. empty when the
398
+ // probe has none.
399
+ export function readyProbeContentBasis(probe: ReadyContentProbe): string {
400
+ if (probe.paintedAppContent === true) return 'paintedAppContent'
401
+ if ((probe.browserSurfaceCount ?? 0) > 0) return 'browserSurface'
402
+ if (probe.liveFrameActive) return 'liveFrame'
403
+ if (probe.targets > 0) return 'targets'
404
+ if (probe.flag === true && probe.nodes >= SEMANTIC_READY_CONTENT_NODE_FLOOR)
405
+ return 'nodeFloor'
406
+ return ''
404
407
  }
405
408
 
406
- export function readyProbeHasTargetContent(
407
- probe: Pick<SemanticReadyProbe, 'targets'> &
408
- Partial<
409
- Pick<
410
- SemanticReadyProbe,
411
- 'liveFrameActive' | 'browserSurfaceCount' | 'paintedAppContent'
412
- >
413
- >,
414
- ): boolean {
415
- return (
416
- probe.paintedAppContent === true ||
417
- (probe.browserSurfaceCount ?? 0) > 0 ||
418
- probe.liveFrameActive ||
419
- probe.targets > 0
420
- )
409
+ export function readyProbeHasContent(probe: ReadyContentProbe): boolean {
410
+ return readyProbeContentBasis({ ...probe, flag: true }) !== ''
421
411
  }
422
412
 
423
413
  // one probe shows a ready app: it has said so with content behind it, or it
@@ -426,9 +416,7 @@ export function readyProbeHasTargetContent(
426
416
  // already let the app settle judges a single probe with this alone.
427
417
  export function readyProbeIsReady(probe: SemanticReadyProbe): boolean {
428
418
  return (
429
- (probe.flag === true
430
- ? readyProbeHasContent(probe)
431
- : readyProbeHasTargetContent(probe)) &&
419
+ readyProbeContentBasis(probe) !== '' &&
432
420
  probe.externalReady !== false &&
433
421
  !probe.externalError &&
434
422
  !probe.loadingText
package/src/config.ts CHANGED
@@ -76,6 +76,8 @@ export interface PeachConfig<Settings extends Record<string, any> = Record<strin
76
76
  runtimeVersion?: string
77
77
  // native host identity. omission preserves the Fabric default.
78
78
  nativeRenderer?: NativeRenderer
79
+ // developer-owned browser token, restricted to the runtime's origin.
80
+ mapKit?: { token: string }
79
81
  modules?: Record<string, ModuleResolution>
80
82
  // native stubs keyed by the package they answer. the cli lowers these, with
81
83
  // installed stub packages, into the wire slots below before anything runs.
@@ -95,6 +97,9 @@ export interface PeachConfig<Settings extends Record<string, any> = Record<strin
95
97
  remap?: Record<string, string>
96
98
  }
97
99
  settings?: Partial<Settings>
100
+ // the properties a native host hands the root component at launch, the
101
+ // initialProperties of RCTRootView / runApplication's initialProps.
102
+ initialProps?: Record<string, unknown>
98
103
  initialState?: {
99
104
  authenticated?: boolean
100
105
  locale?: string
@@ -131,6 +136,11 @@ export function isPeachConfig<Settings extends Record<string, any> = Record<stri
131
136
  (value.nativeRenderer === undefined ||
132
137
  value.nativeRenderer === 'paper' ||
133
138
  value.nativeRenderer === 'fabric') &&
139
+ (value.mapKit === undefined ||
140
+ (isRecord(value.mapKit) &&
141
+ typeof value.mapKit.token === 'string' &&
142
+ value.mapKit.token.length > 0 &&
143
+ value.mapKit.token === value.mapKit.token.trim())) &&
134
144
  (value.runtimeVersion === undefined ||
135
145
  (typeof value.runtimeVersion === 'string' &&
136
146
  value.runtimeVersion === value.runtimeVersion.trim() &&
@@ -144,6 +154,7 @@ export function isPeachConfig<Settings extends Record<string, any> = Record<stri
144
154
  'env',
145
155
  'network',
146
156
  'settings',
157
+ 'initialProps',
147
158
  'initialState',
148
159
  ].every((key) => value[key] === undefined || isRecord(value[key])) &&
149
160
  (value.packages === undefined ||
@@ -232,6 +243,7 @@ export function hasPeachConfig<
232
243
  if (!config) return false
233
244
  return (
234
245
  config.nativeRenderer !== undefined ||
246
+ config.mapKit !== undefined ||
235
247
  (typeof config.runtimeVersion === 'string' && config.runtimeVersion.length > 0) ||
236
248
  hasOwnKeys(config.modules as Record<string, unknown> | undefined) ||
237
249
  hasOwnKeys(config.turboModules as Record<string, unknown> | undefined) ||
@@ -240,6 +252,7 @@ export function hasPeachConfig<
240
252
  hasOwnKeys(config.env as Record<string, unknown> | undefined) ||
241
253
  hasOwnKeys(config.network?.remap as Record<string, unknown> | undefined) ||
242
254
  hasOwnKeys(config.settings as Record<string, unknown> | undefined) ||
255
+ hasOwnKeys(config.initialProps) ||
243
256
  hasOwnKeys(config.initialState as Record<string, unknown> | undefined)
244
257
  )
245
258
  }
@@ -257,6 +270,7 @@ export function mergePeachConfig<
257
270
  return {
258
271
  runtimeVersion: override.runtimeVersion ?? base.runtimeVersion,
259
272
  nativeRenderer: override.nativeRenderer ?? base.nativeRenderer,
273
+ mapKit: override.mapKit ?? base.mapKit,
260
274
  modules: { ...(base.modules || {}), ...(override.modules || {}) },
261
275
  turboModules: { ...(base.turboModules || {}), ...(override.turboModules || {}) },
262
276
  nativeModules: {
@@ -272,6 +286,7 @@ export function mergePeachConfig<
272
286
  ...(base.settings || {}),
273
287
  ...(override.settings || {}),
274
288
  } as Partial<Settings>,
289
+ initialProps: { ...(base.initialProps || {}), ...(override.initialProps || {}) },
275
290
  initialState: { ...(base.initialState || {}), ...(override.initialState || {}) },
276
291
  }
277
292
  }
@@ -229,9 +229,13 @@ export function headlessHostCommand(): string[] | null {
229
229
  if (!fs.existsSync(launcher) || !hostEntrySupportsAutomaticPort(host)) return null
230
230
  return [launcher, '--experimental-vm-modules', host]
231
231
  }
232
- const version = placedHostVersion()
232
+ const pin = desktopHostDelivery().resolvePin()
233
+ if (pin && !/^\d+\.\d+$/.test(pin))
234
+ throw new Error(`invalid native host version '${pin}' (expected <build>.<attempt>)`)
235
+ const version = pin || placedHostVersion()
233
236
  if (!version) return null
234
237
  const dir = desktopHostDir(version)
238
+ if (!fs.existsSync(path.join(dir, desktopHostExecutable()))) return null
235
239
  // a placed host that predates the automatic-port bind upgrades like a missing one
236
240
  if (!hostEntrySupportsAutomaticPort(path.join(dir, 'node/host.mjs'))) return null
237
241
  return [
@@ -3,6 +3,7 @@ import {
3
3
  isLoopbackHost,
4
4
  LOOPBACK_PROBE_HOSTNAMES,
5
5
  } from './backend-origin'
6
+ import { parseExpoManifestBody } from './expo-manifest-body'
6
7
  import {
7
8
  alignLoopbackBundleUrlWithProbe,
8
9
  METRO_BUNDLE_PATH,
@@ -223,7 +224,7 @@ async function probeExpoManifest(
223
224
 
224
225
  let manifest: unknown
225
226
  try {
226
- manifest = JSON.parse(body)
227
+ manifest = parseExpoManifestBody(body)
227
228
  } catch {
228
229
  if (answered.headers.get('content-type')?.includes('json'))
229
230
  throw new Error(`Metro returned invalid manifest JSON at ${normalizedBaseUrl}/`)
@@ -0,0 +1,13 @@
1
+ // an Expo packager serves its manifest as JSON, or as the multipart form body
2
+ // Expo Updates defines (older CLIs always, whatever the request accepts) whose
3
+ // `manifest` part carries the same JSON.
4
+ export function parseExpoManifestBody(body: string) {
5
+ const opening = /^(--[^\r\n]+)\r?\n/.exec(body)
6
+ if (!opening) return JSON.parse(body)
7
+ for (const part of body.split(opening[1])) {
8
+ const headerEnd = part.search(/\r?\n\r?\n/)
9
+ if (headerEnd < 0 || !/\bname="manifest"/.test(part.slice(0, headerEnd))) continue
10
+ return JSON.parse(part.slice(headerEnd).trim())
11
+ }
12
+ throw new Error('Expo manifest response has no manifest part')
13
+ }