peachsim 0.1.1481 → 0.1.1594

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 (206) hide show
  1. package/README.md +26 -6
  2. package/cli/app-config.ts +43 -8
  3. package/cli/app-fonts.ts +13 -17
  4. package/cli/app-stubs.ts +302 -0
  5. package/cli/bin.ts +6 -2
  6. package/cli/bridge-flow-runner.ts +195 -40
  7. package/cli/cloud-test-tunnel.ts +2 -0
  8. package/cli/command-registry.ts +2 -1
  9. package/cli/commands/box/bundle-plane.ts +4 -0
  10. package/cli/commands/box.ts +3 -20
  11. package/cli/commands/control.ts +89 -18
  12. package/cli/commands/debug.ts +2 -2
  13. package/cli/commands/do-chain.ts +2 -2
  14. package/cli/commands/doctor.ts +967 -0
  15. package/cli/commands/flow.ts +434 -403
  16. package/cli/commands/inspect/core.ts +45 -19
  17. package/cli/commands/inspect/env.ts +10 -0
  18. package/cli/commands/inspect/list.ts +16 -5
  19. package/cli/commands/inspect/settling.ts +64 -29
  20. package/cli/commands/inspect.ts +32 -12
  21. package/cli/commands/install-desktop.ts +3 -2
  22. package/cli/commands/kotlin.ts +77 -16
  23. package/cli/commands/login.ts +1 -1
  24. package/cli/commands/maestro-compare.ts +16 -3
  25. package/cli/commands/maestro-generate.ts +11 -7
  26. package/cli/commands/maestro.ts +43 -15
  27. package/cli/commands/no-bridge-hint.ts +26 -3
  28. package/cli/commands/platform.ts +16 -6
  29. package/cli/commands/record.ts +9 -1
  30. package/cli/commands/report-issue.ts +2 -1
  31. package/cli/commands/serve.ts +30 -2
  32. package/cli/commands/setup.ts +15 -2
  33. package/cli/commands/swift.ts +4 -0
  34. package/cli/commands/upload.ts +91 -18
  35. package/cli/current-sim.ts +1 -2
  36. package/cli/device-option.ts +8 -0
  37. package/cli/drivers/desktop.ts +1 -1
  38. package/cli/drivers/playwright-sim-host.ts +30 -11
  39. package/cli/drivers/playwright.ts +1 -4
  40. package/cli/flow-export.ts +2 -2
  41. package/cli/flow-file.ts +18 -18
  42. package/cli/internal-child.ts +7 -6
  43. package/cli/maestro-compare.ts +9 -3
  44. package/cli/maestro-js.ts +2 -1
  45. package/cli/main.ts +3 -3
  46. package/cli/outbound-endpoints.ts +105 -41
  47. package/cli/parse-args.ts +7 -0
  48. package/cli/preview-capture.ts +12 -0
  49. package/cli/privacy.ts +1 -1
  50. package/cli/runtime-compat.ts +16 -0
  51. package/cli/ui.ts +5 -0
  52. package/cli/ws-bridge.ts +107 -44
  53. package/detox/attempt-events.cjs +706 -0
  54. package/detox/attempt-events.d.cts +99 -0
  55. package/detox/index.ts +492 -58
  56. package/detox/motion-change.cjs +251 -0
  57. package/detox/motion-change.d.cts +73 -0
  58. package/dist-lib/agent-daemon-client.cjs +1 -1
  59. package/dist-lib/agent-events.cjs +1 -1
  60. package/dist-lib/attached-projects.cjs +1 -1
  61. package/dist-lib/auth/shared-session.cjs +1 -1
  62. package/dist-lib/backend-origin.cjs +1 -1
  63. package/dist-lib/beta.cjs +1 -1
  64. package/dist-lib/beta.mjs +1 -1
  65. package/dist-lib/bridge-constants.cjs +3 -1
  66. package/dist-lib/bridge-contract-input.cjs +1 -1
  67. package/dist-lib/bridge-contract-input.mjs +1 -1
  68. package/dist-lib/bridge-contract.cjs +1 -1
  69. package/dist-lib/bridge-contract.mjs +1 -1
  70. package/dist-lib/bundle-channel.cjs +2018 -481
  71. package/dist-lib/capture-contract.cjs +1 -1
  72. package/dist-lib/capture-contract.mjs +1 -1
  73. package/dist-lib/cloud-contract.cjs +1 -1
  74. package/dist-lib/cloud-contract.mjs +1 -1
  75. package/dist-lib/cloud-test-args.cjs +1 -1
  76. package/dist-lib/cloud-test-args.mjs +1 -1
  77. package/dist-lib/cloud.cjs +1 -1
  78. package/dist-lib/cloud.mjs +1 -1
  79. package/dist-lib/config.cjs +14 -2
  80. package/dist-lib/config.mjs +13 -2
  81. package/dist-lib/detox/index.cjs +1623 -113
  82. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  83. package/dist-lib/expo-swift-bundle.cjs +1937 -461
  84. package/dist-lib/expo-swift-metadata.cjs +21 -13
  85. package/dist-lib/expo-swift-metadata.mjs +21 -13
  86. package/dist-lib/home-paths.cjs +1 -1
  87. package/dist-lib/host/bridge-host.cjs +17679 -15689
  88. package/dist-lib/host/fetch-proxy-handler.cjs +122 -97
  89. package/dist-lib/host/fetch-proxy-overrides.cjs +23 -3
  90. package/dist-lib/host/fetch-proxy-overrides.mjs +19 -2
  91. package/dist-lib/host/replacement-module-handler.cjs +19 -18
  92. package/dist-lib/host/websocket-proxy.cjs +228 -7
  93. package/dist-lib/index.cjs +5540 -4009
  94. package/dist-lib/jump-to-source-babel.cjs +73 -35
  95. package/dist-lib/jump-to-source-native.cjs +86 -7
  96. package/dist-lib/kotlin-host.cjs +3 -1
  97. package/dist-lib/menu.cjs +1 -1
  98. package/dist-lib/menu.mjs +1 -1
  99. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  100. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  101. package/dist-lib/metro-production-bundle.cjs +51 -97
  102. package/dist-lib/metro-production-bundle.mjs +41 -97
  103. package/dist-lib/metro.cjs +4032 -364
  104. package/dist-lib/native-app-identity-project.cjs +232 -118
  105. package/dist-lib/native-app-identity-project.mjs +231 -118
  106. package/dist-lib/native-app-identity.cjs +304 -33
  107. package/dist-lib/native-app-identity.mjs +301 -32
  108. package/dist-lib/native-firebase-metadata.cjs +1 -1
  109. package/dist-lib/native-firebase-metadata.mjs +1 -1
  110. package/dist-lib/plist.cjs +1 -1
  111. package/dist-lib/plist.mjs +1 -1
  112. package/dist-lib/profiles.cjs +1 -1
  113. package/dist-lib/public-brand.cjs +1 -1
  114. package/dist-lib/react-native-host-modules.cjs +11 -1
  115. package/dist-lib/react-native-host-modules.mjs +11 -1
  116. package/dist-lib/scripts/dev-server-scanner.cjs +98 -34
  117. package/dist-lib/sdk.cjs +98 -48
  118. package/dist-lib/sdk.mjs +98 -48
  119. package/dist-lib/skills.cjs +13104 -9997
  120. package/dist-lib/swift-compile.cjs +1851 -437
  121. package/dist-lib/swift-compile.mjs +1860 -445
  122. package/dist-lib/swift-package.cjs +1 -1
  123. package/dist-lib/swift-package.mjs +1 -1
  124. package/dist-lib/swift-submit.cjs +2 -2
  125. package/dist-lib/swift-submit.mjs +2 -2
  126. package/dist-lib/swift-toolchain.cjs +1 -1
  127. package/dist-lib/swift.cjs +1 -1
  128. package/dist-lib/vite-swift.cjs +1938 -462
  129. package/dist-lib/vite.cjs +121 -52
  130. package/dist-lib/worklet-executable.cjs +18 -4
  131. package/dist-lib/worklet-executable.mjs +18 -4
  132. package/install.sh +7 -2
  133. package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +2 -0
  134. package/kotlin/AndroidViewSources/android/graphics/Paint.kt +18 -0
  135. package/kotlin/AndroidViewSources/dev/peach/app/JavaFormat.kt +16 -8
  136. package/kotlin/ComposeSources/PeachComposeHost.kt +31 -12
  137. package/kotlin/ComposeSources/PeachMagnifier.kt +185 -0
  138. package/kotlin/ExpoComposeSources/PeachAndroidView.kt +16 -0
  139. package/kotlin/Sources/java/util/IllegalFormatCodePointException.kt +10 -0
  140. package/kotlin/Sources/java/util/IllegalFormatException.kt +5 -0
  141. package/kotlin/fixtures/counter/CounterApp.native.tsx +30 -3
  142. package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +1 -0
  143. package/kotlin/fixtures/counter/CounterScreen.kt +2 -1
  144. package/kotlin/fixtures/counter/package.json +15 -2
  145. package/kotlin/fixtures/counter/vite.config.ts +11 -1
  146. package/kotlin/fork/NOTICE +1 -0
  147. package/kotlin/fork/README.md +5 -4
  148. package/kotlin/fork/compose-worker-globals.patch +629 -0
  149. package/kotlin/fork/fork.json +1 -1
  150. package/kotlin/toolchain.json +3 -3
  151. package/package.json +1 -1
  152. package/scripts/dev-server-scanner.ts +137 -42
  153. package/skills/contrast/SKILL.md +3 -3
  154. package/skills/peach-debug/SKILL.md +1 -1
  155. package/skills/peach-setup/SKILL.md +22 -12
  156. package/skills/peach-test/SKILL.md +2 -3
  157. package/src/agent-identity.ts +18 -0
  158. package/src/bridge-constants.ts +1 -1
  159. package/src/bundle-channel.mjs +37 -6
  160. package/src/bundleGlobalFooter.ts +70 -20
  161. package/src/config.ts +38 -0
  162. package/src/configured-bundle-producer.ts +4 -1
  163. package/src/desktop-host-delivery.ts +4 -3
  164. package/src/host/bridge-host.ts +218 -75
  165. package/src/host/fetch-proxy-handler.ts +145 -117
  166. package/src/host/fetch-proxy-overrides.ts +27 -0
  167. package/src/host/fetch-proxy-transport.ts +77 -63
  168. package/src/host/lease-owner.test.ts +18 -1
  169. package/src/host/lease-owner.ts +4 -5
  170. package/src/host/loopback-ports.ts +34 -0
  171. package/src/host/replacement-module-handler.ts +26 -18
  172. package/src/host/server-scan-handler.ts +87 -0
  173. package/src/host/websocket-proxy.ts +251 -6
  174. package/src/installed-package-versions.ts +65 -0
  175. package/src/jump-to-source-babel.ts +97 -89
  176. package/src/jump-to-source-native.ts +107 -9
  177. package/src/kotlin-host.ts +1 -1
  178. package/src/kotlinDiscoveryContract.ts +27 -0
  179. package/src/metro-plugin.ts +129 -39
  180. package/src/metro-production-bundle.ts +89 -137
  181. package/src/native-app-identity-project.ts +38 -2
  182. package/src/native-app-identity.ts +142 -11
  183. package/src/native-firebase-config.ts +82 -80
  184. package/src/nativeAppIconContract.ts +132 -0
  185. package/src/nitro-bundle.ts +43 -0
  186. package/src/nitro-compile.ts +107 -0
  187. package/src/nitro-source.ts +314 -0
  188. package/src/nitroContract.ts +51 -0
  189. package/src/oneWidgetContract.ts +14 -3
  190. package/src/packageVersionsContract.ts +52 -0
  191. package/src/react-devtools-frontend-react.ts +45 -0
  192. package/src/react-native-host-modules.ts +10 -0
  193. package/src/skills/builtin/maestro-test.ts +2 -4
  194. package/src/swift-compile.ts +2084 -331
  195. package/src/swift-distribution-contract.ts +11 -0
  196. package/src/swift-distribution.lock.json +45 -11
  197. package/src/swift-host.ts +13 -7
  198. package/src/swift-icu.ts +10 -2
  199. package/src/swift-submit.ts +2 -1
  200. package/src/vite-plugin-one.ts +9 -3
  201. package/src/vite-plugin.ts +63 -29
  202. package/src/worker-system-chunks.ts +3 -1
  203. package/src/worklet-executable.ts +21 -3
  204. package/src/xcode-project.ts +47 -125
  205. package/src/xcode-target-members.ts +107 -0
  206. package/cli/commands/compat.ts +0 -395
package/README.md CHANGED
@@ -185,6 +185,12 @@ config dictionary is empty and the app can select its own public defaults.
185
185
  Preserve failures from those endpoints rather than remapping them to a successful
186
186
  local response. The same flag is available on `peach flow`.
187
187
 
188
+ A project with several Xcode application targets names the one to run, as an
189
+ Xcode scheme does: `peach open <app-port> --ios-target <name>` (also on
190
+ `peach flow` and `peach maestro test`) reads that target's version, build,
191
+ bundle id and display name. Without it, such an app served by a plain Metro
192
+ reads the runtime defaults.
193
+
188
194
  **The daemon is one process** — `SootSimBridgeHost` (`src/host/bridge-host.ts`).
189
195
  `peach serve` runs it in the foreground; the background daemon is the *same
190
196
  process* wrapped with autostart and the `~/.peach/daemon.json` lockfile. It is
@@ -196,6 +202,12 @@ guest-app fetch/WebSocket traffic so the cross-origin tenant worker can reach
196
202
  localhost. The `AgentHost` extension owns one FIFO reader per agent session and
197
203
  fans agent events out to every subscriber.
198
204
 
205
+ `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.
210
+
199
211
  During source development, each Vite bridge owns one
200
212
  `~/.peach/dev-bridges/<port>.json` heartbeat record. CLI defaults select the
201
213
  current `PORT_OFFSET`, then the current checkout, then the canonical `:7668`
@@ -267,6 +279,7 @@ therefore runs before assertions in each flow.
267
279
  curl -fsSL https://peachsim.com/install.sh | sh # or: npx peachsim
268
280
  peach # open ConnectRN and choose a running app in the simulator
269
281
  peach open /settings # dispatch a React Native deep link into that app
282
+ peach open /settings --no-describe --quiet # keep open options with deep links
270
283
  ```
271
284
 
272
285
  Start Metro, Expo, One, or another supported React Native development server
@@ -389,6 +402,8 @@ outside `node_modules`, `dist`, and `build`. Keep it before
389
402
  `react-native-worklets/plugin` in a custom Babel stack that lists both plugins.
390
403
  Apps using `babel-preset-expo` omit the explicit Worklets plugin because the
391
404
  preset configures it automatically.
405
+ Runtime Fragment aliases are excluded before element creation. Component tag
406
+ expressions are evaluated once, and ordinary components retain their locations.
392
407
 
393
408
  For One's Rolldown native bundler, use the native transform in `vite.config.ts`:
394
409
 
@@ -421,7 +436,7 @@ peach open 8081 # load a running metro/expo dev server
421
436
  peach open /settings # route via React Native Linking
422
437
  peach describe # dump UI tree
423
438
  peach get errors 5 # recent runtime failures
424
- peach compat # scan native package compatibility
439
+ peach doctor # check what of this app Peach runs
425
440
  peach support report "…" # preview an opt-in compatibility report
426
441
  peach find --testid cta # inspect one node deeply
427
442
  peach debug enable animated,layout
@@ -499,6 +514,11 @@ options and enables two integrations:
499
514
  The URL helpers request ordinary Metro graphs: `dev=false&minify=true` for
500
515
  production and `dev=true&minify=false` for development.
501
516
 
517
+ Metro registration readers retain the original factory bytes and module IDs.
518
+ Empty dependencies may be an empty array, an omitted production argument, or a
519
+ null development argument before the module name. Identity validation and
520
+ fingerprinting treat each as zero dependencies and reject malformed arguments.
521
+
502
522
  ```js
503
523
  const {
504
524
  toPeachProductionBundleUrl,
@@ -695,11 +715,11 @@ serves its selected version from a version-specific localhost origin, so a
695
715
  background activation cannot mix an already loaded shell with chunks from the
696
716
  next runtime. Without config, each new open selects the active stable version.
697
717
  When `peach.config.ts` sets `runtimeVersion`, `peach open` installs that
698
- version without changing the machine default. The same file replaces an app's own
699
- native modules (`nativeModules`, `turboModules`) and native views
700
- (`nativeViews`, keyed by the `requireNativeComponent` name or an Expo view's
701
- `Module_View` name) with project-local files; the configuration guide in the
702
- docs lists every slot. App config, native-linked fonts,
718
+ version without changing the machine default. The same file answers native code Peach does
719
+ not know under `packages`, keyed by the package and then its native module
720
+ and view names, and an installed `peach-stub-<package>` package answers in the
721
+ same shape with no config line; the configuration guide in the docs lists
722
+ every slot. App config, native-linked fonts,
703
723
  and splash assets resolve from the local directory named by `--project-root`
704
724
  when supplied, otherwise the project root advertised by the selected development
705
725
  server. Hosted recorders pass their checked-out repository explicitly because
package/cli/app-config.ts CHANGED
@@ -4,6 +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
8
 
8
9
  // `{ file }` entries are relative to the config file; the engine loads them by
9
10
  // absolute path from the runtime's replacement-module endpoint.
@@ -33,16 +34,42 @@ function resolveConfigReplacementFiles(
33
34
  return {
34
35
  ...config,
35
36
  modules: resolveFileEntries(config.modules, configDir),
36
- turboModules: resolveFileEntries(config.turboModules, configDir),
37
- nativeModules: resolveFileEntries(config.nativeModules, configDir),
38
- nativeViews: resolveFileEntries(config.nativeViews, configDir),
39
37
  }
40
38
  }
41
39
 
40
+ // the engine's wire slots; an app names native stubs by package instead
41
+ const WIRE_SLOTS = ['nativeModules', 'turboModules', 'nativeViews'] as const
42
+
43
+ // the app's config with every native stub lowered into the engine's wire slots:
44
+ // its own `packages` and the installed stub packages, applied with or without
45
+ // a peach.config.ts.
42
46
  export async function loadRNXAppConfig(
43
47
  project: ResolvedAppProject | null,
44
48
  ): Promise<PeachConfig | undefined> {
45
- if (!project?.configPath) return undefined
49
+ return (await loadRNXAppStubs(project)).config
50
+ }
51
+
52
+ export async function loadRNXAppStubs(
53
+ project: ResolvedAppProject | null,
54
+ ): Promise<{ config: PeachConfig | undefined; stubs: AppStubs }> {
55
+ if (!project) return { config: undefined, stubs: { applied: [], skipped: [] } }
56
+ const config = await loadConfigFile(project)
57
+ try {
58
+ return resolveAppStubs(
59
+ project.appDir,
60
+ config,
61
+ project.configPath ? path.dirname(project.configPath) : project.appDir,
62
+ )
63
+ } catch (error) {
64
+ const message = error instanceof Error ? error.message : String(error)
65
+ throw new Error(`could not load the native stubs of ${project.appDir}: ${message}`)
66
+ }
67
+ }
68
+
69
+ async function loadConfigFile(
70
+ project: ResolvedAppProject,
71
+ ): Promise<PeachConfig | undefined> {
72
+ if (!project.configPath) return undefined
46
73
  try {
47
74
  const jiti = createJiti(import.meta.url, {
48
75
  interopDefault: true,
@@ -57,6 +84,12 @@ export async function loadRNXAppConfig(
57
84
  if (!isPeachConfig(loaded)) {
58
85
  throw new Error('the default export must be a peach config object')
59
86
  }
87
+ const wire = WIRE_SLOTS.filter((slot) => loaded[slot] !== undefined)
88
+ if (wire.length > 0) {
89
+ 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' } } }`,
91
+ )
92
+ }
60
93
  return resolveConfigReplacementFiles(loaded, project.configPath)
61
94
  } catch (error) {
62
95
  const message = error instanceof Error ? error.message : String(error)
@@ -64,9 +97,11 @@ export async function loadRNXAppConfig(
64
97
  }
65
98
  }
66
99
 
67
- export async function resolveRNXAppConfig(
68
- startDir: string = process.cwd(),
69
- ): Promise<{ project: ResolvedAppProject | null; config: PeachConfig | undefined }> {
100
+ export async function resolveRNXAppConfig(startDir: string = process.cwd()): Promise<{
101
+ project: ResolvedAppProject | null
102
+ config: PeachConfig | undefined
103
+ stubs: AppStubs
104
+ }> {
70
105
  const project = resolveAppProject(startDir)
71
- return { project, config: await loadRNXAppConfig(project) }
106
+ return { project, ...(await loadRNXAppStubs(project)) }
72
107
  }
package/cli/app-fonts.ts CHANGED
@@ -3,10 +3,12 @@ import fs from 'node:fs'
3
3
  import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import {
6
+ encodeAppFontsWire,
6
7
  expoFontPathsFromPlugins,
7
8
  nativeLinkedFontSpecsFromPaths,
8
9
  metroFontSpecsFromPaths,
9
10
  type ConfigPluginFontSpec,
11
+ type FontPlatform,
10
12
  } from 'peach-engine/engine/config-plugin-fonts'
11
13
  import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
12
14
  import { loopbackOriginOf } from '../src/backend-origin'
@@ -34,15 +36,17 @@ export function discoverNativeLinkedAppFonts(opts: {
34
36
  }): DiscoveredAppFontSpec[] {
35
37
  const metroOrigin = loopbackOriginOf(opts.bundleUrl)
36
38
  if (!metroOrigin) return []
37
- const specs = metroFontSpecsFromPaths(
39
+ return metroFontSpecsFromPaths(
38
40
  projectRelativeFontPaths(opts.projectDir, discoverNativeLinkedAppFontPaths(opts)),
39
41
  metroOrigin,
42
+ nativeFontPlatform(opts.platform),
40
43
  )
41
- // a family linked from more than one file keeps the first declaration.
42
- const byFamily = new Map<string, DiscoveredAppFontSpec>()
43
- for (const spec of specs)
44
- if (!byFamily.has(spec.family)) byFamily.set(spec.family, spec)
45
- return [...byFamily.values()]
44
+ }
45
+
46
+ function nativeFontPlatform(platform: string | undefined): FontPlatform {
47
+ return (platform || process.env.CONTRAST_PLATFORM || 'ios').toLowerCase() === 'android'
48
+ ? 'android'
49
+ : 'ios'
46
50
  }
47
51
 
48
52
  function projectRelativeFontPaths(projectDir: string, fontPaths: string[]): string[] {
@@ -68,7 +72,7 @@ function discoverNativeLinkedAppFontPaths(opts: {
68
72
  const selectedAppDir = project?.appDir ?? null
69
73
  if (!selectedAppDir) return []
70
74
 
71
- const platform = (opts.platform || process.env.CONTRAST_PLATFORM || 'ios').toLowerCase()
75
+ const platform = nativeFontPlatform(opts.platform)
72
76
 
73
77
  let getConfig: ExpoConfigGetter | null = null
74
78
  // resolve from the exact app root advertised by the development server.
@@ -97,10 +101,7 @@ function discoverNativeLinkedAppFontPaths(opts: {
97
101
  }
98
102
 
99
103
  // a declared path with no file behind it links nothing natively.
100
- const fontPaths = expoFontPathsFromPlugins(
101
- plugins,
102
- platform === 'android' ? 'android' : 'ios',
103
- )
104
+ const fontPaths = expoFontPathsFromPlugins(plugins, platform)
104
105
  .slice(0, 256)
105
106
  .map((fontPath) => path.resolve(selectedAppDir, fontPath))
106
107
  .filter(isFile)
@@ -285,6 +286,7 @@ export function readNativeLinkedAppFontsWire(opts: {
285
286
  return encodeAppFontsWire(
286
287
  nativeLinkedFontSpecsFromPaths(
287
288
  projectRelativeFontPaths(opts.projectDir, discoverNativeLinkedAppFontPaths(opts)),
289
+ nativeFontPlatform(opts.platform),
288
290
  (fontPath) => {
289
291
  const bytes = fs.readFileSync(path.resolve(opts.projectDir, fontPath))
290
292
  return `data:${fontContentType(fontPath)};base64,${bytes.toString('base64')}`
@@ -355,12 +357,6 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
355
357
  return pid === null ? null : resolveProcessCwd(pid)
356
358
  }
357
359
 
358
- export function encodeAppFontsWire(specs: DiscoveredAppFontSpec[]): string {
359
- return specs
360
- .map((spec) => `${encodeURIComponent(spec.url)}::${encodeURIComponent(spec.family)}`)
361
- .join(',')
362
- }
363
-
364
360
  export function parseAppFontUrls(wire: string): string[] {
365
361
  const urls: string[] = []
366
362
  for (const entry of wire.split(',')) {
@@ -0,0 +1,302 @@
1
+ // native stubs keyed by the package they answer (specs/peach-app-stubs.md):
2
+ // the app's peach.config.ts `packages`, installed `peach-stub-<package>`
3
+ // packages, and a library's own `peach` field, lowered into the engine's
4
+ // nativeModules / nativeViews wire slots.
5
+
6
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
7
+ import { dirname, join, relative, resolve } from 'node:path'
8
+ import { resolveCompatVersionRange } from '@peach/compat/version-range'
9
+ import {
10
+ isPackageStubs,
11
+ type NativeModuleResolution,
12
+ type NativeStub,
13
+ type NativeViewResolution,
14
+ type PackageStubs,
15
+ type PeachConfig,
16
+ } from '../src/config'
17
+ import { isRecord } from '../src/is-record'
18
+ import { readProjectManifest, scanRepository } from './commands/detect'
19
+
20
+ // who answers a package, in rising precedence
21
+ export type StubOrigin = 'library' | 'stub-package' | 'config'
22
+
23
+ export interface AppliedStubs {
24
+ target: string
25
+ origin: StubOrigin
26
+ // the package declaring the stubs; null for peach.config.ts
27
+ provider: string | null
28
+ providerVersion: string | null
29
+ modules: string[]
30
+ views: string[]
31
+ }
32
+
33
+ // a published stub whose `versions` range excludes the installed target
34
+ export interface SkippedStubs {
35
+ target: string
36
+ provider: string
37
+ providerVersion: string | null
38
+ versions: string
39
+ installed: string
40
+ }
41
+
42
+ export interface AppStubs {
43
+ applied: AppliedStubs[]
44
+ skipped: SkippedStubs[]
45
+ }
46
+
47
+ interface StubSource {
48
+ target: string
49
+ origin: StubOrigin
50
+ provider: string | null
51
+ providerVersion: string | null
52
+ stubs: PackageStubs
53
+ // where relative stub paths resolve from
54
+ baseDir: string
55
+ }
56
+
57
+ // `peach-stub-<package>` or `@scope/peach-stub-<package>`; a scoped target uses
58
+ // the DefinitelyTyped encoding, `@acme/scanner` as `peach-stub-acme__scanner`.
59
+ export function stubPackageTarget(name: string): string | null {
60
+ const match = /^(?:@[^/]+\/)?peach-stub-(.+)$/.exec(name)
61
+ if (!match?.[1]) return null
62
+ const split = match[1].indexOf('__')
63
+ return split > 0
64
+ ? `@${match[1].slice(0, split)}/${match[1].slice(split + 2)}`
65
+ : match[1]
66
+ }
67
+
68
+ export function resolveAppStubs(
69
+ appDir: string,
70
+ config: PeachConfig | undefined,
71
+ configDir: string,
72
+ ): { config: PeachConfig | undefined; stubs: AppStubs } {
73
+ const sources: StubSource[] = []
74
+ const skipped: SkippedStubs[] = []
75
+ const manifest = readProjectManifest(appDir)
76
+ for (const name of Object.keys({
77
+ ...manifest?.devDependencies,
78
+ ...manifest?.dependencies,
79
+ })) {
80
+ const dir = installedPackageDir(appDir, name)
81
+ const installed = dir ? readPackageJson(dir) : null
82
+ if (!dir || !installed || installed.peach === undefined) continue
83
+ const stubTarget = stubPackageTarget(name)
84
+ const target = stubTarget ?? name
85
+ const field = installed.peach
86
+ const versions = isRecord(field) ? field.versions : undefined
87
+ const stubs = isRecord(field) ? { modules: field.modules, views: field.views } : field
88
+ if (
89
+ !isPackageStubs(stubs) ||
90
+ (versions !== undefined && typeof versions !== 'string')
91
+ )
92
+ throw new Error(
93
+ `${name}'s package.json "peach" field must be "noop" or { versions?, modules?, views? } whose values are "noop" or file paths`,
94
+ )
95
+ const providerVersion =
96
+ typeof installed.version === 'string' ? installed.version : null
97
+ const targetDir = stubTarget ? installedPackageDir(appDir, target) : dir
98
+ const targetVersion = targetDir ? readPackageJson(targetDir)?.version : undefined
99
+ // a stub for a package the app does not install answers nothing
100
+ if (typeof targetVersion !== 'string') continue
101
+ if (
102
+ versions !== undefined &&
103
+ resolveCompatVersionRange([versions], targetVersion).status !== 'matched'
104
+ ) {
105
+ skipped.push({
106
+ target,
107
+ provider: name,
108
+ providerVersion,
109
+ versions,
110
+ installed: targetVersion,
111
+ })
112
+ continue
113
+ }
114
+ sources.push({
115
+ target,
116
+ origin: stubTarget ? 'stub-package' : 'library',
117
+ provider: name,
118
+ providerVersion,
119
+ stubs,
120
+ baseDir: dir,
121
+ })
122
+ }
123
+ for (const [target, stubs] of Object.entries(config?.packages ?? {})) {
124
+ sources.push({
125
+ target,
126
+ origin: 'config',
127
+ provider: null,
128
+ providerVersion: null,
129
+ stubs,
130
+ baseDir: configDir,
131
+ })
132
+ }
133
+
134
+ const ORDER: Record<StubOrigin, number> = { library: 0, 'stub-package': 1, config: 2 }
135
+ sources.sort((a, b) => ORDER[a.origin] - ORDER[b.origin])
136
+ const nativeModules: Record<string, NativeModuleResolution> = {}
137
+ const nativeViews: Record<string, NativeViewResolution> = {}
138
+ const applied: AppliedStubs[] = []
139
+ for (const source of sources) {
140
+ const { modules, views } = expandStubs(appDir, source)
141
+ for (const [name, stub] of Object.entries(modules))
142
+ nativeModules[name] = lowerStub(stub, source.baseDir)
143
+ for (const [name, stub] of Object.entries(views))
144
+ nativeViews[name] = lowerStub(stub, source.baseDir)
145
+ applied.push({
146
+ target: source.target,
147
+ origin: source.origin,
148
+ provider: source.provider,
149
+ providerVersion: source.providerVersion,
150
+ modules: Object.keys(modules),
151
+ views: Object.keys(views),
152
+ })
153
+ }
154
+ const stubs = { applied, skipped }
155
+ if (!config && applied.length === 0) return { config, stubs }
156
+ const lowered: PeachConfig = { ...config, nativeModules, nativeViews }
157
+ delete lowered.packages
158
+ return { config: lowered, stubs }
159
+ }
160
+
161
+ // a whole-package 'noop' answers every native name the installed package asks for
162
+ function expandStubs(
163
+ appDir: string,
164
+ source: StubSource,
165
+ ): { modules: Record<string, NativeStub>; views: Record<string, NativeStub> } {
166
+ if (source.stubs !== 'noop')
167
+ return { modules: source.stubs.modules ?? {}, views: source.stubs.views ?? {} }
168
+ const dir = installedPackageDir(appDir, source.target)
169
+ if (!dir)
170
+ throw new Error(
171
+ `packages['${source.target}']: 'noop' answers the names an installed package asks for, and ${source.target} is not installed`,
172
+ )
173
+ const modules: Record<string, NativeStub> = {}
174
+ const views: Record<string, NativeStub> = {}
175
+ for (const request of packageNativeRequests(dir)) {
176
+ if (request.kind === 'module') modules[request.name] = 'noop'
177
+ else views[request.name] = 'noop'
178
+ }
179
+ return { modules, views }
180
+ }
181
+
182
+ function lowerStub(stub: NativeStub, baseDir: string): 'noop' | { file: string } {
183
+ return stub === 'noop' ? stub : { file: resolve(baseDir, stub) }
184
+ }
185
+
186
+ function readPackageJson(dir: string): Record<string, unknown> | null {
187
+ const text = readSmallFile(join(dir, 'package.json'))
188
+ if (!text) return null
189
+ try {
190
+ const parsed: unknown = JSON.parse(text)
191
+ return isRecord(parsed) ? parsed : null
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ // the installed copy of a dependency, found the way node resolves it
198
+ export function installedPackageDir(appDir: string, name: string): string | null {
199
+ let current = appDir
200
+ while (true) {
201
+ const dir = join(current, 'node_modules', name)
202
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir)
203
+ const parent = dirname(current)
204
+ if (parent === current) return null
205
+ current = parent
206
+ }
207
+ }
208
+
209
+ export interface NativeRequest {
210
+ kind: 'module' | 'view'
211
+ name: string
212
+ }
213
+
214
+ 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
+ ]
243
+
244
+ // the native names a package's own JavaScript asks for, outside its native
245
+ // sources (top-level ios and android; src/ios is JavaScript), tests and examples
246
+ const NATIVE_SOURCE_DIRS = new Set(['ios', 'android'])
247
+ const PACKAGE_SKIP_DIRS = new Set([
248
+ 'example',
249
+ 'examples',
250
+ '__tests__',
251
+ '__mocks__',
252
+ 'docs',
253
+ ])
254
+ const LEGACY_NATIVE_MODULE = /\bNativeModules\s*\.\s*(\w+)/g
255
+
256
+ export function packageNativeRequests(dir: string): NativeRequest[] {
257
+ const found = new Map<string, NativeRequest>()
258
+ const directories = scanRepository(dir).directories.filter((sub) => {
259
+ const segments = relative(dir, sub).split('/')
260
+ return (
261
+ !NATIVE_SOURCE_DIRS.has(segments[0]) &&
262
+ !segments.some((segment) => PACKAGE_SKIP_DIRS.has(segment))
263
+ )
264
+ })
265
+ let files = 0
266
+ for (const sub of directories) {
267
+ for (const name of listDir(sub)) {
268
+ if (!SOURCE_FILE.test(name) || name.endsWith('.d.ts') || files >= 2_000) continue
269
+ 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
+ }
281
+ }
282
+ }
283
+ return [...found.values()].sort(
284
+ (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
285
+ )
286
+ }
287
+
288
+ export function listDir(dir: string): string[] {
289
+ try {
290
+ return readdirSync(dir)
291
+ } catch {
292
+ return []
293
+ }
294
+ }
295
+
296
+ export function readSmallFile(file: string): string | null {
297
+ try {
298
+ return statSync(file).size <= 1024 * 1024 ? readFileSync(file, 'utf8') : null
299
+ } catch {
300
+ return null
301
+ }
302
+ }
package/cli/bin.ts CHANGED
@@ -53,9 +53,13 @@ if (process.argv[2] === '__cloud-confirm') {
53
53
  // work does. so this is the branch that must skip the CLI rather than the CLI
54
54
  // that must skip it.
55
55
  const forkedScript = process.argv[2]
56
- if (process.env[RNX_FORKED_SCRIPT_ENV] === '1' && forkedScript?.endsWith('.js')) {
56
+ if (
57
+ process.env[RNX_FORKED_SCRIPT_ENV] === '1' &&
58
+ forkedScript &&
59
+ /\.(?:c|m)?js$/.test(forkedScript)
60
+ ) {
57
61
  const { runScript } = await import('./internal-child')
58
- runScript(forkedScript, process.argv.slice(3))
62
+ await runScript(forkedScript, process.argv.slice(3))
59
63
  } else if (process.argv[2] === RNX_INTERNAL_COMMAND) {
60
64
  const { runInternalChild } = await import('./internal-child')
61
65
  await runInternalChild(process.argv.slice(3))