peachsim 0.1.1594 → 0.1.1620

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 (226) hide show
  1. package/README.md +34 -11
  2. package/cli/app-catalog.ts +7 -5
  3. package/cli/app-config.ts +6 -2
  4. package/cli/app-fonts.ts +74 -35
  5. package/cli/app-stubs.ts +173 -42
  6. package/cli/bin.ts +9 -0
  7. package/cli/bridge-flow-runner.ts +226 -145
  8. package/cli/cloud-client.ts +52 -29
  9. package/cli/commands/agent-indicator-mode.ts +39 -0
  10. package/cli/commands/box.ts +13 -3
  11. package/cli/commands/control.ts +132 -108
  12. package/cli/commands/daemon.ts +99 -24
  13. package/cli/commands/debug.ts +29 -1
  14. package/cli/commands/detect.ts +1 -1
  15. package/cli/commands/device.ts +6 -8
  16. package/cli/commands/diagnose.ts +2 -0
  17. package/cli/commands/doctor.ts +97 -34
  18. package/cli/commands/flow.ts +22 -12
  19. package/cli/commands/inspect/actions.ts +2 -0
  20. package/cli/commands/inspect/core.ts +106 -40
  21. package/cli/commands/inspect/list.ts +9 -3
  22. package/cli/commands/inspect/memory.ts +9 -0
  23. package/cli/commands/inspect/settling.ts +167 -49
  24. package/cli/commands/inspect/shared.ts +53 -12
  25. package/cli/commands/inspect/wait-event.ts +0 -1
  26. package/cli/commands/inspect/wait.ts +2 -2
  27. package/cli/commands/inspect.ts +19 -4
  28. package/cli/commands/kotlin.ts +5 -7
  29. package/cli/commands/maestro-compare.ts +25 -2
  30. package/cli/commands/mode.ts +13 -1
  31. package/cli/commands/perf-record.ts +296 -0
  32. package/cli/commands/perf.ts +6 -1
  33. package/cli/commands/platform.ts +34 -11
  34. package/cli/commands/preview.ts +1 -1
  35. package/cli/commands/record.ts +48 -20
  36. package/cli/commands/runtime.ts +9 -11
  37. package/cli/commands/screenshots.ts +4 -3
  38. package/cli/commands/setup.ts +17 -5
  39. package/cli/commands/skills.ts +16 -43
  40. package/cli/commands/swift.ts +18 -3
  41. package/cli/commands/upgrade.ts +7 -6
  42. package/cli/commands/upload.ts +27 -51
  43. package/cli/commands/window.ts +449 -0
  44. package/cli/desktop-companion.ts +84 -10
  45. package/cli/drivers/desktop.ts +2 -0
  46. package/cli/drivers/playwright-sim-host.ts +12 -1
  47. package/cli/flow-file.ts +1 -0
  48. package/cli/maestro-compare.ts +19 -3
  49. package/cli/main.ts +22 -1
  50. package/cli/material-symbol-fonts.ts +171 -0
  51. package/cli/outbound-endpoints.ts +32 -16
  52. package/cli/preview-capture.ts +2 -2
  53. package/cli/run-registry.ts +5 -2
  54. package/cli/telemetry.ts +6 -3
  55. package/cli/ws-bridge.ts +15 -19
  56. package/detox/attempt-events.cjs +63 -7
  57. package/detox/attempt-events.d.cts +7 -0
  58. package/detox/gestures.ts +115 -2
  59. package/detox/index.ts +313 -411
  60. package/detox/jest-environment.cjs +86 -0
  61. package/detox/jest-preset.cjs +3 -1
  62. package/detox/jest-setup-after-env.cjs +1 -0
  63. package/detox/matchers.ts +3 -6
  64. package/detox/motion-change.cjs +9 -1
  65. package/detox/proof-frame.cjs +83 -9
  66. package/dist-lib/agent-daemon-client.cjs +1 -1
  67. package/dist-lib/agent-events.cjs +1 -1
  68. package/dist-lib/attached-projects.cjs +1 -1
  69. package/dist-lib/auth/shared-session.cjs +15 -5
  70. package/dist-lib/backend-origin.cjs +1 -1
  71. package/dist-lib/beta.cjs +1 -1
  72. package/dist-lib/beta.mjs +1 -1
  73. package/dist-lib/bridge-constants.cjs +1 -1
  74. package/dist-lib/bridge-contract-input.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.mjs +1 -1
  76. package/dist-lib/bridge-contract.cjs +1 -1
  77. package/dist-lib/bridge-contract.mjs +1 -1
  78. package/dist-lib/bundle-channel.cjs +4005 -1362
  79. package/dist-lib/capture-contract.cjs +1 -1
  80. package/dist-lib/capture-contract.mjs +1 -1
  81. package/dist-lib/cloud-contract.cjs +6 -1
  82. package/dist-lib/cloud-contract.mjs +5 -1
  83. package/dist-lib/cloud-test-args.cjs +1 -1
  84. package/dist-lib/cloud-test-args.mjs +1 -1
  85. package/dist-lib/cloud.cjs +2 -2
  86. package/dist-lib/cloud.mjs +2 -2
  87. package/dist-lib/config.cjs +26 -7
  88. package/dist-lib/config.mjs +26 -7
  89. package/dist-lib/detox/index.cjs +1908 -539
  90. package/dist-lib/detox/jest-environment.cjs +86 -0
  91. package/dist-lib/detox/jest-preset.cjs +3 -1
  92. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  93. package/dist-lib/expo-swift-bundle.cjs +3617 -1016
  94. package/dist-lib/expo-swift-metadata.cjs +1 -1
  95. package/dist-lib/expo-swift-metadata.mjs +1 -1
  96. package/dist-lib/home-paths.cjs +1 -1
  97. package/dist-lib/host/bridge-host.cjs +4760 -1825
  98. package/dist-lib/host/fetch-proxy-handler.cjs +59 -9
  99. package/dist-lib/host/fetch-proxy-overrides.cjs +62 -2
  100. package/dist-lib/host/fetch-proxy-overrides.mjs +56 -2
  101. package/dist-lib/host/replacement-module-handler.cjs +8 -8
  102. package/dist-lib/host/websocket-proxy.cjs +22 -6
  103. package/dist-lib/index.cjs +610 -184
  104. package/dist-lib/jump-to-source-babel.cjs +1 -1
  105. package/dist-lib/jump-to-source-native.cjs +33 -4
  106. package/dist-lib/kotlin-host.cjs +262 -121
  107. package/dist-lib/menu.cjs +1 -1
  108. package/dist-lib/menu.mjs +1 -1
  109. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  110. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  111. package/dist-lib/metro-production-bundle.cjs +101 -7
  112. package/dist-lib/metro-production-bundle.mjs +106 -7
  113. package/dist-lib/metro.cjs +315 -87
  114. package/dist-lib/native-app-identity-project.cjs +101 -26
  115. package/dist-lib/native-app-identity-project.mjs +101 -26
  116. package/dist-lib/native-app-identity.cjs +33 -7
  117. package/dist-lib/native-app-identity.mjs +33 -7
  118. package/dist-lib/native-firebase-metadata.cjs +1 -1
  119. package/dist-lib/native-firebase-metadata.mjs +1 -1
  120. package/dist-lib/plist.cjs +1 -1
  121. package/dist-lib/plist.mjs +1 -1
  122. package/dist-lib/profiles.cjs +1 -1
  123. package/dist-lib/public-brand.cjs +1 -1
  124. package/dist-lib/react-native-host-modules.cjs +1 -1
  125. package/dist-lib/react-native-host-modules.mjs +1 -1
  126. package/dist-lib/scripts/dev-server-scanner.cjs +30 -9
  127. package/dist-lib/sdk.cjs +236 -78
  128. package/dist-lib/sdk.mjs +236 -78
  129. package/dist-lib/skills.cjs +6788 -2636
  130. package/dist-lib/swift-compile.cjs +1637 -299
  131. package/dist-lib/swift-compile.mjs +1558 -219
  132. package/dist-lib/swift-package.cjs +25 -3
  133. package/dist-lib/swift-package.mjs +22 -2
  134. package/dist-lib/swift-submit.cjs +1 -1
  135. package/dist-lib/swift-submit.mjs +1 -1
  136. package/dist-lib/swift-toolchain.cjs +1 -1
  137. package/dist-lib/swift.cjs +1 -1
  138. package/dist-lib/vite-swift.cjs +3675 -1051
  139. package/dist-lib/vite.cjs +142 -20
  140. package/dist-lib/worklet-executable.cjs +1 -1
  141. package/dist-lib/worklet-executable.mjs +1 -1
  142. package/install.sh +1 -1
  143. package/kotlin/AppSources/android/view/Window.kt +4 -1
  144. package/kotlin/AppSources/android/window/BackEvent.kt +24 -0
  145. package/kotlin/AppSources/android/window/OnBackAnimationCallback.kt +7 -0
  146. package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +37 -1
  147. package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +5 -0
  148. package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +8 -1
  149. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +14 -0
  150. package/kotlin/CompilerSources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
  151. package/kotlin/CompilerSources/PeachJniIncoming.kt +243 -0
  152. package/kotlin/CompilerSources/PeachJniPlugin.kt +116 -0
  153. package/kotlin/ComposeSources/PeachComposeHost.kt +27 -20
  154. package/kotlin/JniPlatformSources/androidx/annotation/CallSuper.kt +5 -0
  155. package/kotlin/JniPlatformSources/com/facebook/proguard/annotations/DoNotStrip.kt +5 -0
  156. package/kotlin/JniPlatformSources/dalvik/annotation/optimization/FastNative.kt +5 -0
  157. package/kotlin/JniPlatformSources/kotlin/Synchronized.kt +6 -0
  158. package/kotlin/JniPlatformSources/kotlin/jvm/JvmField.kt +5 -0
  159. package/kotlin/JniPlatformSources/kotlin/jvm/JvmStatic.kt +5 -0
  160. package/kotlin/JniSources/com/facebook/jni/HybridData.kt +68 -0
  161. package/kotlin/JniSources/dev/peach/jni/IncomingBindings.kt +157 -0
  162. package/kotlin/JniSources/dev/peach/jni/NativeBindings.kt +79 -0
  163. package/kotlin/JniTransportSources/dev/peach/jni/integration/JniTransport.kt +91 -0
  164. package/kotlin/README.md +18 -0
  165. package/package.json +1 -1
  166. package/scripts/dev-server-scanner.ts +30 -40
  167. package/skills/contrast/SKILL.md +1 -1
  168. package/skills/peach-perf/SKILL.md +25 -0
  169. package/skills/peach-setup/SKILL.md +25 -1
  170. package/src/assetCatalogContract.ts +31 -3
  171. package/src/auth/shared-session.ts +3 -2
  172. package/src/bridge-contract.ts +5 -0
  173. package/src/bundle-channel.mjs +1 -1
  174. package/src/cloud-contract.ts +11 -0
  175. package/src/cloud.ts +2 -2
  176. package/src/config.ts +16 -8
  177. package/src/expo-kotlin-host.ts +7 -2
  178. package/src/guest-bundle.ts +15 -8
  179. package/src/host/agent-host.ts +2 -4
  180. package/src/host/bridge-host.ts +52 -54
  181. package/src/host/fetch-proxy-handler.ts +94 -31
  182. package/src/host/fetch-proxy-overrides.ts +91 -0
  183. package/src/host/replacement-module-handler.ts +11 -10
  184. package/src/host/server-scan-handler.ts +1 -1
  185. package/src/host/staged-host-files.ts +92 -0
  186. package/src/host/websocket-proxy.ts +27 -6
  187. package/src/installed-package-versions.ts +1 -0
  188. package/src/jump-to-source-native.ts +3 -3
  189. package/src/kotlin-host.ts +143 -26
  190. package/src/kotlin-module.ts +52 -0
  191. package/src/metro-layout-parser.ts +78 -0
  192. package/src/metro-production-bundle.ts +59 -10
  193. package/src/native-app-identity-project.ts +67 -3
  194. package/src/native-app-identity.ts +48 -0
  195. package/src/native-firebase-config.ts +3 -2
  196. package/src/native-seam-manifest.ts +0 -44
  197. package/src/nativeAppIconContract.ts +11 -6
  198. package/src/nativeViewSourceContract.ts +49 -0
  199. package/src/nativeViewWebContract.ts +41 -0
  200. package/src/nitro-source.ts +3 -7
  201. package/src/one-widget-metadata.ts +23 -3
  202. package/src/oneWidgetContract.ts +22 -0
  203. package/src/process-group.ts +306 -0
  204. package/src/process-liveness.ts +24 -0
  205. package/src/process-output.ts +21 -2
  206. package/src/serverDiscoveryContract.ts +41 -0
  207. package/src/swift-compile.ts +1063 -265
  208. package/src/swift-design-time.ts +552 -0
  209. package/src/swift-distribution.lock.json +12 -11
  210. package/src/swift-host.ts +133 -25
  211. package/src/swift-objc-sdk.ts +726 -0
  212. package/src/swift-objc-standin.ts +441 -0
  213. package/src/swift-package.ts +25 -0
  214. package/src/swift-packages.ts +25 -10
  215. package/src/swift-standin-config.ts +94 -0
  216. package/src/swift-standin-emit.ts +5 -3
  217. package/src/swift-standins.ts +225 -81
  218. package/src/swiftContract.ts +4 -0
  219. package/src/target-matcher.ts +67 -0
  220. package/src/visit-parsed-program.ts +21 -0
  221. package/src/vite-plugin-one.ts +58 -16
  222. package/src/vite-plugin-swift.ts +1 -0
  223. package/src/vite-plugin.ts +105 -41
  224. package/src/worker-system-chunks.ts +86 -9
  225. package/src/worklets-babel.ts +85 -13
  226. package/src/xcode-project.ts +40 -15
package/README.md CHANGED
@@ -12,7 +12,8 @@ drivers, and library exports; its only command, `npx peachsim`, runs that same
12
12
  installer from the copy of `install.sh` it ships.
13
13
 
14
14
  The executable updates itself with `peach upgrade`, which then restarts a running
15
- background daemon onto the new executable and runtime. Successful interactive human
15
+ background daemon onto the new executable. A new runtime needs no restart: the
16
+ next new sim opens on it while open sims keep theirs. Successful interactive human
16
17
  commands refresh a cached stable CLI version in a detached process at most once
17
18
  every 20 hours. A newer version produces one short notice on stderr, once per
18
19
  version and no more than weekly. CI, agents, JSON output, pipes, and
@@ -162,6 +163,11 @@ a local foreground bridge when no daemon is reachable, then opens a `WsBridge`
162
163
  `cli/commands/inspect/*` and are re-exported as the programmatic `peachsim/sdk`;
163
164
  the Maestro runner is `cli/bridge-flow-runner.ts`; the Detox compat driver
164
165
  is in `detox/`. Test and screenshot paths additionally use `cli/drivers/*`.
166
+ Opening a simulator and waiting for reconnection share one app-tree readiness
167
+ poller. Its nominal wait budget includes WebSocket handshakes, target lookup,
168
+ evaluation and poll pauses; unanswered commands do not restart the deadline.
169
+ A timeout reports the last node count, missing test hook or bridge error and
170
+ elapsed time. Registering a simulator alone does not satisfy readiness.
165
171
  Browser sims launch in an isolated Playwright Chrome for Testing profile.
166
172
  An explicit `--base-url` keeps that shell's origin and uses its assets without
167
173
  installing an active runtime. A bridge started for that shell skips automatic
@@ -203,10 +209,12 @@ localhost. The `AgentHost` extension owns one FIFO reader per agent session and
203
209
  fans agent events out to every subscriber.
204
210
 
205
211
  `peach open` auto-installs a published runtime only when the runtime directory
206
- is empty. If runtime files exist without a selected runtime containing
207
- `index.html`, it refuses to open. Finish copying the runtime, then select it
208
- with `peach runtime use <version>`. Write the active marker after staging is
209
- complete when preparing an isolated home.
212
+ is empty and no live bridge already owns the runtime. When a live bridge is
213
+ performing its first download, `open` waits for that bridge's bootstrap state
214
+ to finish, then uses its installed runtime. Without a live bridge, runtime
215
+ files without a selected runtime containing `index.html` still cause a refusal.
216
+ Finish copying the runtime, then select it with `peach runtime use <version>`.
217
+ Write the active marker after staging is complete when preparing an isolated home.
210
218
 
211
219
  During source development, each Vite bridge owns one
212
220
  `~/.peach/dev-bridges/<port>.json` heartbeat record. CLI defaults select the
@@ -215,12 +223,13 @@ bridge, so concurrent worktrees cannot replace or delete one another's address.
215
223
  Each Vite shell also publishes its exact bridge at `/__sootsim/bridge`, so a
216
224
  launcher that reuses a shell from another isolated `PEACH_HOME` stays in that
217
225
  shell's bridge world without reading the other process's lockfile directory.
218
- An explicit `--port` also participates in `--sim` ownership discovery even
219
- without a local heartbeat record. The bridge must report that simulator as
220
- open; a port that names another simulator's world is rejected.
221
- SDK `connect({ sim })` and batched `do --then` actions use that same ownership
222
- discovery. An inferred default port does not override the named sim's world;
223
- an explicit SDK port or CLI `--port` retains the ownership check.
226
+ An explicit CLI `--port` or SDK port connects directly to that bridge without
227
+ scanning other worlds or requiring a local heartbeat record. That bridge handles
228
+ the named sim and reports an error if it is absent.
229
+ Without an explicit port, `--sim`, SDK `connect({ sim })`, and batched `do --then`
230
+ actions discover the sim's owning world. Each world probe has one two-second
231
+ wall deadline covering both connection and response. An inferred default port
232
+ does not override the named sim's world.
224
233
 
225
234
  **The sim** is a browser/Electron *page* launched by a CLI driver. Inside that
226
235
  page run three workers: the **shell worker** owns device and native state, the
@@ -443,6 +452,7 @@ peach debug enable animated,layout
443
452
  peach debug trace shell on 240
444
453
  peach do tap-text "..." # interact
445
454
  peach do tap email --then type user@example.com --then tap submit
455
+ peach shell window --x 120 --y 140 --scale fit # place/scale desktop sim window
446
456
  peach debug snapshot before
447
457
  peach debug snapshot after
448
458
  peach debug diff before after
@@ -551,6 +561,16 @@ peach detox # auto-discovers e2e/ tests and launches a shell
551
561
  peach detox init # scaffold a config + sample test
552
562
  ```
553
563
 
564
+ `device.launchApp()` reuses a running guest by default. With a URL,
565
+ `device.launchApp({ url })` and `device.openURL({ url })` deliver a Linking
566
+ URL event to the running guest through the shell's deep-link bridge. Warm `launchApp` brings the guest forward; `openURL` retains its
567
+ foreground or background state. Both retain its browser session and app state.
568
+ `sourceApp` does not add fields to React Native's `{ url }` event. A
569
+ stopped guest rejects URL delivery. Explicit `newInstance: true`, deletion,
570
+ and video recording create a new browser context; cold launches still use the
571
+ supplied shell URL. When synchronization is enabled, URL delivery waits for
572
+ existing work before dispatch and for navigation and painting afterward.
573
+
554
574
  See `../../docs/migrating-from-detox.md` for details.
555
575
 
556
576
  ## Coming from Maestro
@@ -604,6 +624,9 @@ the framed export inline without changing Maestro syntax:
604
624
  native content, a populated node tree, or a live-frame channel whose publish
605
625
  count advances across probes. Canvas-only GL, WebGPU, and video surfaces can
606
626
  therefore prove that they are painting without weakening the boot-card guard.
627
+ A confirmed guest launch error ends readiness immediately, including waits longer
628
+ than one bridge request. Loading and suppressed entry errors keep their existing
629
+ readiness rules.
607
630
 
608
631
  ```yaml
609
632
  - takeScreenshot: hero
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import {
4
4
  enumerateBundleColors,
5
5
  enumerateBundleImages,
6
+ enumerateBundleSymbols,
6
7
  } from '../../sootsim-swift/src/bundleAssets'
7
8
  import type { AppCatalog } from 'peach-engine/engine/app-catalog-wire'
8
9
 
@@ -36,7 +37,7 @@ function catalogFiles(dir: string, found: string[]) {
36
37
  }
37
38
  }
38
39
 
39
- // the app's asset-catalog colors and images and its declared accent, read off
40
+ // the app's asset-catalog colors, images, symbols and declared accent, read off
40
41
  // the ios project a prebuild or a bare app keeps on disk: the app target is
41
42
  // the folder holding the AppDelegate, the catalogs are its .xcassets, and the
42
43
  // accent is what its Info.plist or project build settings name. metro serves
@@ -65,9 +66,9 @@ export function discoverAppCatalog(
65
66
  const relative = files.map((file) =>
66
67
  path.relative(targetDir, file).split(path.sep).join(path.posix.sep),
67
68
  )
68
- const readContents = (candidate: string) =>
69
- candidate.endsWith('Contents.json') ? readText(path.join(targetDir, candidate)) : null
69
+ const readContents = (candidate: string) => readText(path.join(targetDir, candidate))
70
70
  const colors = enumerateBundleColors(relative, readContents)
71
+ const symbols = enumerateBundleSymbols(relative, readContents, () => '.')
71
72
  const served = path.relative(appDir, targetDir).split(path.sep).join(path.posix.sep)
72
73
  const images: AppCatalog['images'] = {}
73
74
  for (const { name, file, scale } of enumerateBundleImages(relative, readContents)) {
@@ -85,8 +86,9 @@ export function discoverAppCatalog(
85
86
  if (
86
87
  accentName === null &&
87
88
  Object.keys(colors).length === 0 &&
88
- Object.keys(images).length === 0
89
+ Object.keys(images).length === 0 &&
90
+ Object.keys(symbols).length === 0
89
91
  )
90
92
  return null
91
- return { colors, images, accentName }
93
+ return { colors, images, symbols, accentName }
92
94
  }
package/cli/app-config.ts CHANGED
@@ -4,7 +4,7 @@ import * as peachConfigModule from '../src/config'
4
4
  import { isPeachConfig, type PeachConfig } from '../src/config'
5
5
  import { rnxPublicBrand } from '../src/public-brand'
6
6
  import { resolveAppProject, type ResolvedAppProject } from './app-project'
7
- import { resolveAppStubs, type AppStubs } from './app-stubs'
7
+ import { resolveAppStubs, wireSlotMigration, type AppStubs } from './app-stubs'
8
8
 
9
9
  // `{ file }` entries are relative to the config file; the engine loads them by
10
10
  // absolute path from the runtime's replacement-module endpoint.
@@ -86,8 +86,12 @@ async function loadConfigFile(
86
86
  }
87
87
  const wire = WIRE_SLOTS.filter((slot) => loaded[slot] !== undefined)
88
88
  if (wire.length > 0) {
89
+ const migration = wireSlotMigration(project.appDir, {
90
+ modules: { ...loaded.turboModules, ...loaded.nativeModules },
91
+ views: { ...loaded.nativeViews },
92
+ })
89
93
  throw new Error(
90
- `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer: packages: { 'package-name': { modules: { Name: './stub.ts' }, views: { Name: './View.tsx' } } }`,
94
+ `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. replace them with:\n${migration}`,
91
95
  )
92
96
  }
93
97
  return resolveConfigReplacementFiles(loaded, project.configPath)
package/cli/app-fonts.ts CHANGED
@@ -7,14 +7,20 @@ import {
7
7
  expoFontPathsFromPlugins,
8
8
  nativeLinkedFontSpecsFromPaths,
9
9
  metroFontSpecsFromPaths,
10
+ appFontAssetUrlsFromWire,
10
11
  type ConfigPluginFontSpec,
11
12
  type FontPlatform,
12
13
  } from 'peach-engine/engine/config-plugin-fonts'
13
14
  import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
14
15
  import { loopbackOriginOf } from '../src/backend-origin'
15
16
  import { MANIFEST_PROBE_TIMEOUT_MS } from '../src/dev-bundle-resolution'
17
+ import { stageHostFile } from '../src/host/staged-host-files'
16
18
  import { parsePlistSource } from '../src/plist.ts'
17
19
  import { resolveAppProject } from './app-project'
20
+ import {
21
+ materialSymbolFontPaths,
22
+ materialSymbolFontAssets,
23
+ } from './material-symbol-fonts'
18
24
 
19
25
  const require = createRequire(import.meta.url)
20
26
 
@@ -33,14 +39,20 @@ export function discoverNativeLinkedAppFonts(opts: {
33
39
  bundleUrl: string
34
40
  projectDir: string
35
41
  platform?: string
42
+ nativeAssetOrigin: string
36
43
  }): DiscoveredAppFontSpec[] {
37
44
  const metroOrigin = loopbackOriginOf(opts.bundleUrl)
38
45
  if (!metroOrigin) return []
39
- return metroFontSpecsFromPaths(
40
- projectRelativeFontPaths(opts.projectDir, discoverNativeLinkedAppFontPaths(opts)),
46
+ const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
47
+ const specs = metroFontSpecsFromPaths(
48
+ projectRelativeFontPaths(opts.projectDir, paths),
41
49
  metroOrigin,
42
50
  nativeFontPlatform(opts.platform),
43
51
  )
52
+ attachMaterialSymbolMetadata(specs, materialSymbols, (asset) =>
53
+ stageHostFile(asset, opts.nativeAssetOrigin),
54
+ )
55
+ return specs
44
56
  }
45
57
 
46
58
  function nativeFontPlatform(platform: string | undefined): FontPlatform {
@@ -49,10 +61,40 @@ function nativeFontPlatform(platform: string | undefined): FontPlatform {
49
61
  : 'ios'
50
62
  }
51
63
 
52
- function projectRelativeFontPaths(projectDir: string, fontPaths: string[]): string[] {
53
- return fontPaths.map((fontPath) =>
54
- path.relative(path.resolve(projectDir), fontPath).split(path.sep).join('/'),
64
+ function attachMaterialSymbolMetadata(
65
+ specs: ConfigPluginFontSpec[],
66
+ files: ReturnType<typeof materialSymbolFontAssets>,
67
+ assetUrl: (filePath: string) => string,
68
+ ): void {
69
+ const first = specs.find(
70
+ (spec) =>
71
+ spec.family !== undefined &&
72
+ /^MaterialSymbols(?:Outlined|Rounded|Sharp)_/.test(spec.family),
55
73
  )
74
+ if (first && files)
75
+ first.materialSymbols = {
76
+ hash: files.hash,
77
+ codepointsUrl: assetUrl(files.codepointsPath),
78
+ mirroredUrl: assetUrl(files.mirroredPath),
79
+ }
80
+ }
81
+
82
+ function projectRelativeFontPaths(projectDir: string, fontPaths: string[]): string[] {
83
+ // metro resolves an asset path lexically against its project root and reads
84
+ // through symlinks, so a font declared inside the project keeps its declared
85
+ // path even when node_modules is a symlink to a shared install. a font that
86
+ // node resolved outside the project (a hoisted workspace or pnpm store) is
87
+ // named through its real location, in the namespace of the real project root.
88
+ const projectRoot = path.resolve(projectDir)
89
+ const realProjectRoot = fs.realpathSync(projectDir)
90
+ return fontPaths.map((fontPath) => {
91
+ const lexical = path.relative(projectRoot, fontPath)
92
+ const relative =
93
+ lexical.startsWith('..') || path.isAbsolute(lexical)
94
+ ? path.relative(realProjectRoot, fs.realpathSync(fontPath))
95
+ : lexical
96
+ return relative.split(path.sep).join('/')
97
+ })
56
98
  }
57
99
 
58
100
  function isFile(filePath: string): boolean {
@@ -64,13 +106,13 @@ function isFile(filePath: string): boolean {
64
106
  }
65
107
 
66
108
  // absolute paths of every existing font file the native app links.
67
- function discoverNativeLinkedAppFontPaths(opts: {
109
+ function discoverNativeLinkedAppFontAssets(opts: {
68
110
  projectDir: string
69
111
  platform?: string
70
- }): string[] {
112
+ }): { paths: string[]; materialSymbols?: ReturnType<typeof materialSymbolFontAssets> } {
71
113
  const project = resolveAppProject(opts.projectDir)
72
114
  const selectedAppDir = project?.appDir ?? null
73
- if (!selectedAppDir) return []
115
+ if (!selectedAppDir) return { paths: [] }
74
116
 
75
117
  const platform = nativeFontPlatform(opts.platform)
76
118
 
@@ -105,6 +147,9 @@ function discoverNativeLinkedAppFontPaths(opts: {
105
147
  .slice(0, 256)
106
148
  .map((fontPath) => path.resolve(selectedAppDir, fontPath))
107
149
  .filter(isFile)
150
+ const materialFonts =
151
+ platform === 'android' ? materialSymbolFontPaths(selectedAppDir) : []
152
+ fontPaths.push(...materialFonts)
108
153
  // the walk is bounded by WORK, not by a clock: at most 64 readdirs, at most 64
109
154
  // queued dirs, 4 levels deep, 256 paths. those ceilings are what keep a stray
110
155
  // node_modules from being crawled, and no machine can blow through them. a
@@ -271,7 +316,10 @@ function discoverNativeLinkedAppFontPaths(opts: {
271
316
  } catch {}
272
317
  }
273
318
 
274
- return [...new Set(fontPaths)]
319
+ return {
320
+ paths: [...new Set(fontPaths)],
321
+ materialSymbols: materialSymbolFontAssets(materialFonts),
322
+ }
275
323
  }
276
324
 
277
325
  // prebuilt guests cannot discover files linked into their native binary.
@@ -283,16 +331,23 @@ export function readNativeLinkedAppFontsWire(opts: {
283
331
  if (!resolveAppProject(opts.projectDir)) {
284
332
  throw new Error(`native font project is unavailable: ${opts.projectDir}`)
285
333
  }
286
- return encodeAppFontsWire(
287
- nativeLinkedFontSpecsFromPaths(
288
- projectRelativeFontPaths(opts.projectDir, discoverNativeLinkedAppFontPaths(opts)),
289
- nativeFontPlatform(opts.platform),
290
- (fontPath) => {
291
- const bytes = fs.readFileSync(path.resolve(opts.projectDir, fontPath))
292
- return `data:${fontContentType(fontPath)};base64,${bytes.toString('base64')}`
293
- },
294
- ),
334
+ const projectDir = fs.realpathSync(opts.projectDir)
335
+ const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
336
+ const specs = nativeLinkedFontSpecsFromPaths(
337
+ projectRelativeFontPaths(projectDir, paths),
338
+ nativeFontPlatform(opts.platform),
339
+ (fontPath) => {
340
+ const bytes = fs.readFileSync(path.resolve(projectDir, fontPath))
341
+ return `data:${fontContentType(fontPath)};base64,${bytes.toString('base64')}`
342
+ },
343
+ )
344
+ attachMaterialSymbolMetadata(
345
+ specs,
346
+ materialSymbols,
347
+ (filePath) =>
348
+ `data:application/octet-stream;base64,${fs.readFileSync(filePath).toString('base64')}`,
295
349
  )
350
+ return encodeAppFontsWire(specs)
296
351
  }
297
352
 
298
353
  // resolve a metro dev server's project root. Expo's middleware advertises it
@@ -357,22 +412,6 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
357
412
  return pid === null ? null : resolveProcessCwd(pid)
358
413
  }
359
414
 
360
- export function parseAppFontUrls(wire: string): string[] {
361
- const urls: string[] = []
362
- for (const entry of wire.split(',')) {
363
- const trimmed = entry.trim()
364
- if (!trimmed) continue
365
- const sep = trimmed.indexOf('::')
366
- const urlPart = sep === -1 ? trimmed : trimmed.slice(0, sep)
367
- try {
368
- urls.push(decodeURIComponent(urlPart))
369
- } catch {
370
- urls.push(urlPart)
371
- }
372
- }
373
- return urls.filter(Boolean)
374
- }
375
-
376
415
  // content type for a config-plugin font url, by extension. config fonts only
377
416
  // ever ship as ttf/otf/woff(2); fall back to octet-stream for anything else
378
417
  // so R2 still serves bytes the engine can register.
@@ -409,7 +448,7 @@ export async function fetchAppFontFiles(
409
448
  ): Promise<FetchedAppFont[]> {
410
449
  const out: FetchedAppFont[] = []
411
450
  const seen = new Set<string>()
412
- for (const url of parseAppFontUrls(wire)) {
451
+ for (const url of appFontAssetUrlsFromWire(wire)) {
413
452
  const urlhash = createHash('sha256').update(url).digest('hex')
414
453
  if (seen.has(urlhash)) continue
415
454
  seen.add(urlhash)
package/cli/app-stubs.ts CHANGED
@@ -6,15 +6,19 @@
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
7
7
  import { dirname, join, relative, resolve } from 'node:path'
8
8
  import { resolveCompatVersionRange } from '@peach/compat/version-range'
9
+ import { parseSync, type Expression } from 'oxc-parser'
10
+ import { transform } from 'sucrase'
9
11
  import {
10
12
  isPackageStubs,
11
13
  type NativeModuleResolution,
12
14
  type NativeStub,
13
15
  type NativeViewResolution,
16
+ type NativeViewSource,
14
17
  type PackageStubs,
15
18
  type PeachConfig,
16
19
  } from '../src/config'
17
20
  import { isRecord } from '../src/is-record'
21
+ import { visitParsedProgram } from '../src/visit-parsed-program'
18
22
  import { readProjectManifest, scanRepository } from './commands/detect'
19
23
 
20
24
  // who answers a package, in rising precedence
@@ -28,6 +32,7 @@ export interface AppliedStubs {
28
32
  providerVersion: string | null
29
33
  modules: string[]
30
34
  views: string[]
35
+ viewSources?: Record<string, NativeViewSource>
31
36
  }
32
37
 
33
38
  // a published stub whose `versions` range excludes the installed target
@@ -141,7 +146,8 @@ export function resolveAppStubs(
141
146
  for (const [name, stub] of Object.entries(modules))
142
147
  nativeModules[name] = lowerStub(stub, source.baseDir)
143
148
  for (const [name, stub] of Object.entries(views))
144
- nativeViews[name] = lowerStub(stub, source.baseDir)
149
+ nativeViews[name] =
150
+ typeof stub === 'string' ? lowerStub(stub, source.baseDir) : stub
145
151
  applied.push({
146
152
  target: source.target,
147
153
  origin: source.origin,
@@ -149,6 +155,16 @@ export function resolveAppStubs(
149
155
  providerVersion: source.providerVersion,
150
156
  modules: Object.keys(modules),
151
157
  views: Object.keys(views),
158
+ ...(Object.values(views).some((value) => typeof value !== 'string')
159
+ ? {
160
+ viewSources: Object.fromEntries(
161
+ Object.entries(views).filter(
162
+ (entry): entry is [string, NativeViewSource] =>
163
+ typeof entry[1] !== 'string',
164
+ ),
165
+ ),
166
+ }
167
+ : {}),
152
168
  })
153
169
  }
154
170
  const stubs = { applied, skipped }
@@ -162,7 +178,10 @@ export function resolveAppStubs(
162
178
  function expandStubs(
163
179
  appDir: string,
164
180
  source: StubSource,
165
- ): { modules: Record<string, NativeStub>; views: Record<string, NativeStub> } {
181
+ ): {
182
+ modules: Record<string, NativeStub>
183
+ views: Record<string, NativeStub | NativeViewSource>
184
+ } {
166
185
  if (source.stubs !== 'noop')
167
186
  return { modules: source.stubs.modules ?? {}, views: source.stubs.views ?? {} }
168
187
  const dir = installedPackageDir(appDir, source.target)
@@ -212,34 +231,85 @@ export interface NativeRequest {
212
231
  }
213
232
 
214
233
  export const SOURCE_FILE = /\.[cm]?[jt]sx?$/
215
- export const NATIVE_LOOKUP = /requireNative|TurboModuleRegistry|codegenNativeComponent/
216
- // a requireNativeView(module, view) name is `module_view`, the key the engine
217
- // registers expo views under.
218
- export const LOOKUPS: { kind: NativeRequest['kind']; pattern: RegExp }[] = [
219
- {
220
- kind: 'view',
221
- pattern: /\brequireNativeComponent\s*(?:<[^;]{0,200}?>)?\(\s*['"]([\w$.-]+)['"]/g,
222
- },
223
- {
224
- kind: 'view',
225
- pattern:
226
- /\brequireNativeView(?:Manager)?\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]\s*(?:,\s*['"](\w+)['"])?/g,
227
- },
228
- {
229
- kind: 'view',
230
- pattern: /\bcodegenNativeComponent\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]/g,
231
- },
232
- {
233
- kind: 'module',
234
- pattern:
235
- /\brequire(?:Optional)?NativeModule\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]/g,
236
- },
237
- {
238
- kind: 'module',
239
- pattern:
240
- /\bTurboModuleRegistry\.get(?:Enforcing)?\s*(?:<[^;]{0,200}?>)?\(\s*['"](\w+)['"]/g,
241
- },
242
- ]
234
+ const NATIVE_LOOKUP =
235
+ /requireNative|requireOptionalNative|TurboModuleRegistry|codegenNativeComponent|NativeModules/
236
+
237
+ function nativeLookupName(node: Expression): string | null {
238
+ if (node.type === 'Identifier') return node.name
239
+ if (
240
+ node.type === 'MemberExpression' &&
241
+ !node.computed &&
242
+ node.property.type === 'Identifier'
243
+ )
244
+ return node.property.name
245
+ return null
246
+ }
247
+
248
+ // sucrase removes flow and typescript types while retaining source lines. oxc
249
+ // then visits executable syntax, including expressions inside template literals,
250
+ // without interpreting comments, fixture strings or type queries as requests.
251
+ export function sourceNativeRequests(
252
+ file: string,
253
+ text: string,
254
+ includeLegacy = false,
255
+ ): (NativeRequest & { line: number })[] {
256
+ if (!NATIVE_LOOKUP.test(text)) return []
257
+ let code: string
258
+ try {
259
+ code = transform(text, {
260
+ transforms: [/\.[cm]?tsx?$/.test(file) ? 'typescript' : 'flow', 'jsx'],
261
+ disableESTransforms: true,
262
+ }).code
263
+ } catch {
264
+ return []
265
+ }
266
+ const { program, errors } = parseSync(file, code, { lang: 'jsx' })
267
+ if (errors.length) return []
268
+ const found: (NativeRequest & { line: number })[] = []
269
+ const add = (kind: NativeRequest['kind'], name: string, at: number) => {
270
+ found.push({ kind, name, line: code.slice(0, at).split('\n').length })
271
+ }
272
+ visitParsedProgram(program, {
273
+ CallExpression(node) {
274
+ const first = node.arguments[0]
275
+ if (first?.type !== 'Literal' || typeof first.value !== 'string') return
276
+ const callee = node.callee
277
+ const lookup = nativeLookupName(callee)
278
+ if (
279
+ callee.type === 'MemberExpression' &&
280
+ !callee.computed &&
281
+ nativeLookupName(callee.object) === 'TurboModuleRegistry' &&
282
+ (lookup === 'get' || lookup === 'getEnforcing')
283
+ ) {
284
+ add('module', first.value, node.start)
285
+ } else if (lookup) {
286
+ if (['requireNativeModule', 'requireOptionalNativeModule'].includes(lookup))
287
+ add('module', first.value, node.start)
288
+ else if (['requireNativeComponent', 'codegenNativeComponent'].includes(lookup))
289
+ add('view', first.value, node.start)
290
+ else if (['requireNativeView', 'requireNativeViewManager'].includes(lookup)) {
291
+ const second = node.arguments[1]
292
+ // expo registers a named view under the module_view key.
293
+ const name =
294
+ second?.type === 'Literal' && typeof second.value === 'string'
295
+ ? `${first.value}_${second.value}`
296
+ : first.value
297
+ add('view', name, node.start)
298
+ }
299
+ }
300
+ },
301
+ MemberExpression(node) {
302
+ if (
303
+ includeLegacy &&
304
+ !node.computed &&
305
+ nativeLookupName(node.object) === 'NativeModules' &&
306
+ node.property.type === 'Identifier'
307
+ )
308
+ add('module', node.property.name, node.start)
309
+ },
310
+ })
311
+ return found
312
+ }
243
313
 
244
314
  // the native names a package's own JavaScript asks for, outside its native
245
315
  // sources (top-level ios and android; src/ios is JavaScript), tests and examples
@@ -251,7 +321,6 @@ const PACKAGE_SKIP_DIRS = new Set([
251
321
  '__mocks__',
252
322
  'docs',
253
323
  ])
254
- const LEGACY_NATIVE_MODULE = /\bNativeModules\s*\.\s*(\w+)/g
255
324
 
256
325
  export function packageNativeRequests(dir: string): NativeRequest[] {
257
326
  const found = new Map<string, NativeRequest>()
@@ -267,17 +336,11 @@ export function packageNativeRequests(dir: string): NativeRequest[] {
267
336
  for (const name of listDir(sub)) {
268
337
  if (!SOURCE_FILE.test(name) || name.endsWith('.d.ts') || files >= 2_000) continue
269
338
  files++
270
- const text = readSmallFile(join(sub, name))
271
- if (!text || !(NATIVE_LOOKUP.test(text) || text.includes('NativeModules'))) continue
272
- for (const { kind, pattern } of [
273
- ...LOOKUPS,
274
- { kind: 'module' as const, pattern: LEGACY_NATIVE_MODULE },
275
- ]) {
276
- for (const match of text.matchAll(pattern)) {
277
- const request = match[2] ? `${match[1]}_${match[2]}` : match[1]
278
- if (request) found.set(`${kind}:${request}`, { kind, name: request })
279
- }
280
- }
339
+ const file = join(sub, name)
340
+ const text = readSmallFile(file)
341
+ if (!text) continue
342
+ for (const { kind, name } of sourceNativeRequests(file, text, true))
343
+ found.set(`${kind}:${name}`, { kind, name })
281
344
  }
282
345
  }
283
346
  return [...found.values()].sort(
@@ -285,6 +348,74 @@ export function packageNativeRequests(dir: string): NativeRequest[] {
285
348
  )
286
349
  }
287
350
 
351
+ // the `packages` block that replaces a config's old top-level wire slots: each
352
+ // name keyed by the direct dependency whose JavaScript asks for it. `packages`
353
+ // takes only 'noop' or a file path, so any other old value is named for a hand
354
+ // move instead of guessed.
355
+ export function wireSlotMigration(
356
+ appDir: string,
357
+ wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
358
+ ): string {
359
+ const owners = new Map<string, string>()
360
+ const manifest = readProjectManifest(appDir)
361
+ for (const name of Object.keys({
362
+ ...manifest?.devDependencies,
363
+ ...manifest?.dependencies,
364
+ })) {
365
+ const dir = installedPackageDir(appDir, name)
366
+ if (!dir) continue
367
+ for (const request of packageNativeRequests(dir))
368
+ if (!owners.has(`${request.kind}:${request.name}`))
369
+ owners.set(`${request.kind}:${request.name}`, name)
370
+ }
371
+ // the app's own native code answers under its package name
372
+ if (typeof manifest?.name === 'string')
373
+ for (const request of packageNativeRequests(appDir))
374
+ if (!owners.has(`${request.kind}:${request.name}`))
375
+ owners.set(`${request.kind}:${request.name}`, manifest.name)
376
+ const unknownOwner = '<package that asks for it>'
377
+ const blocks = new Map<string, { modules: string[]; views: string[] }>()
378
+ const manual: string[] = []
379
+ for (const [kind, entries] of [
380
+ ['module', wire.modules],
381
+ ['view', wire.views],
382
+ ] as const) {
383
+ for (const [name, value] of Object.entries(entries)) {
384
+ const stub =
385
+ value === 'noop'
386
+ ? JSON.stringify(value)
387
+ : isRecord(value) &&
388
+ typeof value.file === 'string' &&
389
+ Object.keys(value).length === 1
390
+ ? JSON.stringify(value.file)
391
+ : null
392
+ if (!stub) {
393
+ manual.push(
394
+ `${name}: packages takes 'noop' or a file path; move this ${kind} into a file that default-exports it and name that file`,
395
+ )
396
+ continue
397
+ }
398
+ const owner = owners.get(`${kind}:${name}`) ?? unknownOwner
399
+ const block = blocks.get(owner) ?? { modules: [], views: [] }
400
+ block[kind === 'module' ? 'modules' : 'views'].push(
401
+ `[${JSON.stringify(name)}]: ${stub}`,
402
+ )
403
+ blocks.set(owner, block)
404
+ }
405
+ }
406
+ const lines = [...blocks].map(([owner, { modules, views }]) => {
407
+ const parts = [
408
+ modules.length ? `modules: { ${modules.join(', ')} }` : '',
409
+ views.length ? `views: { ${views.join(', ')} }` : '',
410
+ ].filter(Boolean)
411
+ return ` [${JSON.stringify(owner)}]: { ${parts.join(', ')} },`
412
+ })
413
+ return [
414
+ ...(lines.length ? [' packages: {', ...lines, ' },'] : []),
415
+ ...manual.map((line) => ` // ${line}`),
416
+ ].join('\n')
417
+ }
418
+
288
419
  export function listDir(dir: string): string[] {
289
420
  try {
290
421
  return readdirSync(dir)
package/cli/bin.ts CHANGED
@@ -13,6 +13,15 @@
13
13
  import { exitWithFlushedOutput } from '../src/process-output'
14
14
  import { RNX_FORKED_SCRIPT_ENV, RNX_INTERNAL_COMMAND } from './internal-child'
15
15
 
16
+ // a consumer such as head may close its pipe before the command finishes.
17
+ // exit directly: reporting this through stderr can fail on the same pipe.
18
+ for (const stream of [process.stdout, process.stderr]) {
19
+ stream.on('error', (error: NodeJS.ErrnoException) => {
20
+ if (error.code === 'EPIPE') process.exit(1)
21
+ throw error
22
+ })
23
+ }
24
+
16
25
  if (process.argv[2] === '__shell-init') {
17
26
  const { renderRnxShellInit } = await import('./shell-init')
18
27
  try {