peachsim 0.1.1620 → 0.1.1635

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 (185) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +45 -6
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +39 -18
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +248 -156
  7. package/cli/cloud-client.ts +68 -57
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/box/search-command.ts +3 -2
  10. package/cli/commands/box.ts +10 -29
  11. package/cli/commands/control.ts +391 -92
  12. package/cli/commands/doctor.ts +17 -184
  13. package/cli/commands/flow.ts +79 -11
  14. package/cli/commands/inspect/core.ts +10 -5
  15. package/cli/commands/inspect/settling.ts +71 -22
  16. package/cli/commands/inspect/wait-ready.ts +12 -0
  17. package/cli/commands/inspect.ts +26 -3
  18. package/cli/commands/install-desktop.ts +1 -0
  19. package/cli/commands/maestro-compare.ts +105 -67
  20. package/cli/commands/maestro.ts +9 -1
  21. package/cli/commands/platform.ts +71 -36
  22. package/cli/commands/record.ts +21 -1
  23. package/cli/commands/screenshot.ts +23 -1
  24. package/cli/commands/serve.ts +7 -2
  25. package/cli/commands/setup.ts +19 -42
  26. package/cli/commands/swift.ts +5 -0
  27. package/cli/commands/upload.ts +51 -8
  28. package/cli/desktop-companion.ts +102 -17
  29. package/cli/dev-build-freshness.ts +37 -2
  30. package/cli/drivers/desktop.ts +14 -1
  31. package/cli/drivers/playwright-sim-host.ts +44 -8
  32. package/cli/drivers/playwright.ts +9 -8
  33. package/cli/drivers/types.ts +5 -0
  34. package/cli/maestro-accessibility.ts +53 -0
  35. package/cli/maestro-compare.ts +108 -12
  36. package/cli/main.ts +2 -1
  37. package/cli/outbound-endpoints.ts +35 -6
  38. package/cli/parent-pid.ts +0 -9
  39. package/cli/pause-aware-timeout.ts +102 -0
  40. package/cli/remote-simulators.ts +112 -0
  41. package/cli/remoteSimulatorContract.ts +21 -0
  42. package/cli/scene-video.ts +89 -0
  43. package/cli/ws-bridge.ts +23 -14
  44. package/detox/attempt-events.cjs +4 -0
  45. package/detox/expectations.ts +5 -1
  46. package/detox/index.ts +241 -181
  47. package/detox/motion-change.cjs +73 -0
  48. package/detox/motion-change.d.cts +21 -0
  49. package/detox/navigation-lifecycle.ts +22 -4
  50. package/dist-lib/agent-daemon-client.cjs +3 -2
  51. package/dist-lib/agent-events.cjs +1 -1
  52. package/dist-lib/attached-projects.cjs +1 -1
  53. package/dist-lib/auth/shared-session.cjs +3 -2
  54. package/dist-lib/backend-origin.cjs +1 -1
  55. package/dist-lib/beta.cjs +1 -1
  56. package/dist-lib/beta.mjs +1 -1
  57. package/dist-lib/bridge-constants.cjs +13 -1
  58. package/dist-lib/bridge-contract-input.cjs +1 -1
  59. package/dist-lib/bridge-contract-input.mjs +1 -1
  60. package/dist-lib/bridge-contract.cjs +14 -8
  61. package/dist-lib/bridge-contract.mjs +13 -7
  62. package/dist-lib/bundle-channel.cjs +10480 -7962
  63. package/dist-lib/capture-contract.cjs +1 -1
  64. package/dist-lib/capture-contract.mjs +1 -1
  65. package/dist-lib/cloud-contract.cjs +1 -1
  66. package/dist-lib/cloud-contract.mjs +1 -1
  67. package/dist-lib/cloud-test-args.cjs +1 -1
  68. package/dist-lib/cloud-test-args.mjs +1 -1
  69. package/dist-lib/cloud.cjs +3 -2
  70. package/dist-lib/cloud.mjs +3 -2
  71. package/dist-lib/config.cjs +10 -6
  72. package/dist-lib/config.mjs +10 -6
  73. package/dist-lib/detox/index.cjs +1854 -966
  74. package/dist-lib/dev-bundle-resolution.cjs +64 -24
  75. package/dist-lib/expo-swift-bundle.cjs +8715 -6271
  76. package/dist-lib/expo-swift-metadata.cjs +7 -4
  77. package/dist-lib/expo-swift-metadata.mjs +7 -4
  78. package/dist-lib/home-paths.cjs +32 -3
  79. package/dist-lib/host/bridge-host.cjs +10135 -7060
  80. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  81. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  82. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  83. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  84. package/dist-lib/host/websocket-proxy.cjs +1 -1
  85. package/dist-lib/index.cjs +1843 -408
  86. package/dist-lib/jump-to-source-babel.cjs +1 -1
  87. package/dist-lib/jump-to-source-native.cjs +1 -1
  88. package/dist-lib/kotlin-host.cjs +1 -1
  89. package/dist-lib/menu.cjs +3 -2
  90. package/dist-lib/menu.mjs +3 -2
  91. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  92. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  93. package/dist-lib/metro-production-bundle.cjs +5 -4
  94. package/dist-lib/metro-production-bundle.mjs +5 -4
  95. package/dist-lib/metro.cjs +1182 -418
  96. package/dist-lib/native-app-identity-project.cjs +782 -23
  97. package/dist-lib/native-app-identity-project.mjs +781 -23
  98. package/dist-lib/native-app-identity.cjs +276 -9
  99. package/dist-lib/native-app-identity.mjs +273 -8
  100. package/dist-lib/native-firebase-metadata.cjs +5 -4
  101. package/dist-lib/native-firebase-metadata.mjs +5 -4
  102. package/dist-lib/plist.cjs +1 -1
  103. package/dist-lib/plist.mjs +1 -1
  104. package/dist-lib/profiles.cjs +1 -1
  105. package/dist-lib/public-brand.cjs +3 -2
  106. package/dist-lib/react-native-host-modules.cjs +8 -1
  107. package/dist-lib/react-native-host-modules.mjs +8 -1
  108. package/dist-lib/scripts/dev-server-scanner.cjs +106 -28
  109. package/dist-lib/sdk.cjs +115 -34
  110. package/dist-lib/sdk.mjs +115 -33
  111. package/dist-lib/skills.cjs +15274 -9904
  112. package/dist-lib/swift-compile.cjs +1165 -173
  113. package/dist-lib/swift-compile.mjs +1212 -203
  114. package/dist-lib/swift-package.cjs +12 -3
  115. package/dist-lib/swift-package.mjs +12 -3
  116. package/dist-lib/swift-submit.cjs +7 -1
  117. package/dist-lib/swift-submit.mjs +5 -1
  118. package/dist-lib/swift-toolchain.cjs +133 -18
  119. package/dist-lib/swift.cjs +1 -1
  120. package/dist-lib/vite-swift.cjs +2648 -862
  121. package/dist-lib/vite.cjs +70 -14
  122. package/dist-lib/worklet-executable.cjs +25 -2
  123. package/dist-lib/worklet-executable.mjs +25 -2
  124. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  125. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  126. package/package.json +2 -2
  127. package/scripts/dev-server-scanner.ts +35 -14
  128. package/skills/peach-perf/SKILL.md +11 -5
  129. package/skills/peach-setup/SKILL.md +16 -19
  130. package/src/admitted-mac-build.ts +34 -8
  131. package/src/android-app-identity.ts +146 -0
  132. package/src/android-autolinking.ts +75 -0
  133. package/src/android-string-value.ts +36 -0
  134. package/src/android-strings.ts +5 -36
  135. package/src/androidAutolinkingContract.ts +40 -0
  136. package/src/bridge-constants.ts +11 -0
  137. package/src/bridge-contract.ts +22 -34
  138. package/src/bundleGlobalFooter.ts +26 -7
  139. package/src/codex-client.ts +3 -2
  140. package/src/config.ts +15 -0
  141. package/src/desktop-host-delivery.ts +5 -1
  142. package/src/dev-bundle-resolution.ts +60 -26
  143. package/src/expo-kotlin-host.ts +6 -36
  144. package/src/expo-manifest-body.ts +13 -0
  145. package/src/expo-swift-bundle.ts +17 -12
  146. package/src/expo-swift-host.ts +640 -192
  147. package/src/expo-swift-ready.ts +78 -0
  148. package/src/expoSwiftContract.ts +19 -0
  149. package/src/home-paths.ts +48 -2
  150. package/src/host/bridge-host.ts +12 -57
  151. package/src/host/staged-host-files.ts +29 -2
  152. package/src/installed-package-versions.ts +54 -14
  153. package/src/launchMetadataContract.ts +24 -0
  154. package/src/metro-plugin.ts +37 -42
  155. package/src/native-app-identity-project.ts +142 -5
  156. package/src/native-app-identity.ts +176 -11
  157. package/src/native-firebase-config.ts +209 -13
  158. package/src/nitro-compile.ts +2 -5
  159. package/src/nitro-source.ts +147 -63
  160. package/src/process-group.ts +114 -61
  161. package/src/process-output.ts +0 -9
  162. package/src/public-brand.ts +1 -0
  163. package/src/react-native-bridge-modules.ts +416 -0
  164. package/src/react-native-host-modules.ts +7 -0
  165. package/src/sim-client.ts +6 -4
  166. package/src/swift-binary-modules.ts +2 -2
  167. package/src/swift-compile.ts +1007 -108
  168. package/src/swift-compiler-digest.ts +14 -0
  169. package/src/swift-distribution.lock.json +40 -12
  170. package/src/swift-distribution.ts +61 -9
  171. package/src/swift-host.ts +149 -56
  172. package/src/swift-icu.ts +14 -6
  173. package/src/swift-objc-sdk.ts +133 -10
  174. package/src/swift-objc-standin.ts +38 -3
  175. package/src/swift-package-manifest.ts +21 -6
  176. package/src/swift-package.ts +15 -1
  177. package/src/swift-packages.ts +119 -60
  178. package/src/swift-standin-emit.ts +57 -9
  179. package/src/swift-standins.ts +185 -25
  180. package/src/swift-submit.ts +10 -0
  181. package/src/swift-toolchain.ts +149 -23
  182. package/src/vite-plugin.ts +20 -20
  183. package/src/worklet-executable.ts +30 -3
  184. package/src/worklets-babel.ts +35 -14
  185. package/src/xcode-project.ts +10 -0
package/LICENSE CHANGED
@@ -8,7 +8,7 @@ the Peach Software License Agreement: https://peachsim.com/legal/license
8
8
  In summary (the agreement governs): Seed is free for individuals and companies
9
9
  under 10 people and under $1M yearly revenue, including commercial desktop and
10
10
  local use. A company at or above either limit needs Team while it has under
11
- 100 people and under $10M yearly revenue, or Enterprise at or above either of
11
+ 25 people and under $3M yearly revenue, or Enterprise at or above either of
12
12
  those limits. Members, agents, API keys and local simulators are unlimited.
13
13
  Company size is self-reported and enforced through the license terms only.
14
14
  Embedding or redistributing the software requires a separate written agreement
package/README.md CHANGED
@@ -231,6 +231,11 @@ actions discover the sim's owning world. Each world probe has one two-second
231
231
  wall deadline covering both connection and response. An inferred default port
232
232
  does not override the named sim's world.
233
233
 
234
+ Bridge sockets use TCP keepalive so a governor-stopped browser can resume an
235
+ in-flight render without losing its connection to an unanswered application
236
+ ping. Command deadlines still bound stalled work; idle leases and abandoned
237
+ sim cleanup remain separate from transport liveness.
238
+
234
239
  **The sim** is a browser/Electron *page* launched by a CLI driver. Inside that
235
240
  page run three workers: the **shell worker** owns device and native state, the
236
241
  **compositor worker** paints every visible CanvasKit surface, and the **tenant
@@ -279,7 +284,7 @@ therefore runs before assertions in each flow.
279
284
  | runtime delivery + Peach home | Versioned engine runtime management. The shared machinery owns manifest fetch, sha256 verification, channels, and auto-update. Peach binds it to `https://contrast.dev` and `~/.peach/runtimes/<version>`. A repo can select `runtimeVersion` without changing the machine default. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts` |
280
285
  | standalone CLI delivery | Cached stable-version checks, bounded interactive notices, and verified atomic executable replacement. | `cli/cli-update.ts`, `cli/commands/upgrade.ts`, `cli/commands/version.ts`, `src/cli-version.ts` |
281
286
  | `peachsim/vite` + `peachsim/metro` (runtime serving) | Published bundler plugins. Both serve the installed engine runtime at `/__peachsim/` from `~/.peach/runtimes/<version>` so an app's own dev server can host the sim shell; the Vite plugin can opt into a named Electron window. | `src/vite-plugin-one.ts`, `src/metro-plugin.ts`, `src/runtime-assets.ts` |
282
- | `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, applies worklets/babel transforms, and instantiates `SootSimBridgeHost` in its dev server. Distinct from the published `peachsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/peach-compat/src/stub-manifest.ts` |
287
+ | `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, and applies worklets/babel transforms. Distinct from the published `peachsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/peach-compat/src/stub-manifest.ts` |
283
288
  | skills registry | Peach agent skills (`peach-setup`, `peach-debug`, `peach-test`, `peach-visual`, and the generated `contrast` index) installable with `peach skill`. | `skills/*.md`, `src/skills/registry.ts`, `src/skills/types.ts` |
284
289
 
285
290
  ## Getting started
@@ -318,7 +323,16 @@ resolve` re-pins it, and the CLI release publishes any bundle the bucket lacks.
318
323
  On Mac, headless launches attach an installed compatible native host. With no
319
324
  host, they render in the browser without downloading or placing the app. The
320
325
  browser logo menu offers the app; `peach desktop install --native` remains the
321
- explicit CLI installer. Linux still acquires its standalone host under `~/.peach`.
326
+ explicit CLI installer. Linux x64 headless opens acquire the native standalone
327
+ host under `~/.peach` by default. To choose plain Chromium for an open, use
328
+ `peach open <target> --driver playwright --headless --chromium`.
329
+ Pin the native host for reproducible Linux runs with
330
+ `PEACH_DESKTOP_VERSION=<build>.<attempt> peach open <target> --headless`.
331
+ A cached pin is used directly, even if another build is active; a missing pin
332
+ is downloaded exactly, and an unavailable or incompatible pin fails the open.
333
+ `peach list --drivers` shows installed hosts and each running sim's renderer
334
+ and native executable path. Renderer choices select a matching sim or launch
335
+ one; they never change a running sim's renderer.
322
336
 
323
337
  Headed launches on Linux use full Chromium, whose extra libraries the bundle
324
338
  also carries. Later opens reuse that sim and
@@ -510,8 +524,8 @@ a supported input; pass Peach the JavaScript bundle produced before Hermes
510
524
  compilation.
511
525
 
512
526
  If an app has unusual dependencies or fingerprint inference fails, you can
513
- optionally wrap your Metro configuration with `withPeach(config)`. It takes no
514
- options and enables two integrations:
527
+ optionally wrap your Metro configuration with `withPeach(config)`. It enables
528
+ two integrations:
515
529
 
516
530
  1. **Deterministic module identity:** appends Metro's exact module identity map
517
531
  as a trailing `globalThis.__sootsimModulePaths = { ... }` footer to every
@@ -521,6 +535,23 @@ options and enables two integrations:
521
535
  2. **In-browser dev shell:** serves the Peach simulator shell at `/__peachsim/` on
522
536
  the Metro dev server.
523
537
 
538
+ Native app identity also travels in the bundle. Android permissions come only
539
+ from a completed app manifest merger output. A sole output under
540
+ `android/<module>/build/intermediates/merged_manifests/` is selected automatically;
541
+ select one explicitly when multiple variants exist:
542
+
543
+ ```js
544
+ module.exports = withPeach(config, {
545
+ androidMergedManifest:
546
+ 'android/app/build/intermediates/merged_manifests/debug/processDebugManifest/AndroidManifest.xml',
547
+ })
548
+ ```
549
+
550
+ Without resolved merged output, Android permissions contain only the explicit
551
+ Expo `android.permissions` set minus `android.blockedPermissions`. Defaults,
552
+ plugins and linked library additions require completed merged output. See the
553
+ [native identity contract](../../specs/sootsim-engine/native-app-identity.md).
554
+
524
555
  The URL helpers request ordinary Metro graphs: `dev=false&minify=true` for
525
556
  production and `dev=true&minify=false` for development.
526
557
 
@@ -571,6 +602,12 @@ and video recording create a new browser context; cold launches still use the
571
602
  supplied shell URL. When synchronization is enabled, URL delivery waits for
572
603
  existing work before dispatch and for navigation and painting afterward.
573
604
 
605
+ Element taps finish viewport measurement before resolving the selected node's
606
+ current center, visible frame, and relative offset in the browser task that
607
+ sends the physical tap. Stillness samples do not supply later tap coordinates:
608
+ startup layout can change while other bridge reads finish. A removed node
609
+ rejects the action instead of reusing its old geometry.
610
+
574
611
  See `../../docs/migrating-from-detox.md` for details.
575
612
 
576
613
  ## Coming from Maestro
@@ -751,8 +788,10 @@ fails instead of loading empty native configuration. Bare Metro, which has no
751
788
  Expo manifest project root, uses
752
789
  the listening development process's cwd inside that same resolver. The invoking
753
790
  CLI's cwd is never an asset/config fallback. Asset wires stay on each opened
754
- project URL, so one daemon can serve several attached projects without sharing
755
- font or splash state between them.
791
+ project URL. Font catalogs use content-addressed bridge asset references, so
792
+ the navigation request stays bounded as the number of fonts grows. Recordings
793
+ resolve these references before capturing the catalog and its assets. One daemon
794
+ can serve several attached projects without sharing font or splash state.
756
795
 
757
796
  Bare iOS projects carry default Firebase options from their selected app
758
797
  target's Google services build resource when the app delegate initializes
package/cli/app-config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import { createJiti } from 'jiti'
3
4
  import * as peachConfigModule from '../src/config'
@@ -80,7 +81,9 @@ async function loadConfigFile(
80
81
  [`${rnxPublicBrand.packageName}/config`]: peachConfigModule,
81
82
  },
82
83
  })
83
- const loaded: unknown = await jiti.import(project.configPath, { default: true })
84
+ const loaded: unknown = project.configPath.endsWith('.json')
85
+ ? JSON.parse(readFileSync(project.configPath, 'utf8'))
86
+ : await jiti.import(project.configPath, { default: true })
84
87
  if (!isPeachConfig(loaded)) {
85
88
  throw new Error('the default export must be a peach config object')
86
89
  }
package/cli/app-fonts.ts CHANGED
@@ -4,9 +4,9 @@ import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
5
  import {
6
6
  encodeAppFontsWire,
7
+ resolveAppFontsWire,
7
8
  expoFontPathsFromPlugins,
8
9
  nativeLinkedFontSpecsFromPaths,
9
- metroFontSpecsFromPaths,
10
10
  appFontAssetUrlsFromWire,
11
11
  type ConfigPluginFontSpec,
12
12
  type FontPlatform,
@@ -14,8 +14,10 @@ import {
14
14
  import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
15
15
  import { loopbackOriginOf } from '../src/backend-origin'
16
16
  import { MANIFEST_PROBE_TIMEOUT_MS } from '../src/dev-bundle-resolution'
17
+ import { parseExpoManifestBody } from '../src/expo-manifest-body'
17
18
  import { stageHostFile } from '../src/host/staged-host-files'
18
19
  import { parsePlistSource } from '../src/plist.ts'
20
+ import { listXcodeAppTargets, readXcodeAppTarget } from '../src/xcode-project'
19
21
  import { resolveAppProject } from './app-project'
20
22
  import {
21
23
  materialSymbolFontPaths,
@@ -30,24 +32,22 @@ export type DiscoveredAppFontSpec = ConfigPluginFontSpec
30
32
 
31
33
  type ExpoConfigGetter = (dir: string, opts: Record<string, unknown>) => { exp?: unknown }
32
34
 
33
- // every font file is found on disk, so the asset urls are decided here and
34
- // never by asking the dev server: metro's asset endpoint resolves a path
35
- // against its config.projectRoot, which is the root it advertises and the one
36
- // projectDir names. a reachability request would make the result depend on how
37
- // busy the dev server is, and a cold build answers late enough to drop fonts.
35
+ // every font file is found on disk, so its bytes are staged as host files and
36
+ // never fetched from the dev server: only metro serves project files at
37
+ // `/assets/<path>`, and a reachability request would make the result depend on
38
+ // how busy the dev server is, so a cold build could answer late and drop fonts.
38
39
  export function discoverNativeLinkedAppFonts(opts: {
39
- bundleUrl: string
40
40
  projectDir: string
41
41
  platform?: string
42
42
  nativeAssetOrigin: string
43
43
  }): DiscoveredAppFontSpec[] {
44
- const metroOrigin = loopbackOriginOf(opts.bundleUrl)
45
- if (!metroOrigin) return []
46
44
  const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
47
- const specs = metroFontSpecsFromPaths(
45
+ const realProjectRoot = fs.realpathSync(opts.projectDir)
46
+ const specs = nativeLinkedFontSpecsFromPaths(
48
47
  projectRelativeFontPaths(opts.projectDir, paths),
49
- metroOrigin,
50
48
  nativeFontPlatform(opts.platform),
49
+ (fontPath) =>
50
+ stageHostFile(path.resolve(realProjectRoot, fontPath), opts.nativeAssetOrigin),
51
51
  )
52
52
  attachMaterialSymbolMetadata(specs, materialSymbols, (asset) =>
53
53
  stageHostFile(asset, opts.nativeAssetOrigin),
@@ -285,6 +285,23 @@ function discoverNativeLinkedAppFontAssets(opts: {
285
285
  !entry.name.startsWith('.'),
286
286
  )
287
287
  .slice(0, 64)
288
+ // a bare filename in UIAppFonts names a file the target copies into
289
+ // its bundle, wherever the project keeps it: resolve it by the target's
290
+ // resource build phase before trying paths beside the plist.
291
+ const bundledByName = new Map<string, string>()
292
+ for (const entry of fs.readdirSync(iosDir, { withFileTypes: true })) {
293
+ if (!entry.isDirectory() || !entry.name.endsWith('.xcodeproj')) continue
294
+ const xcodeproj = path.join(iosDir, entry.name)
295
+ for (const target of listXcodeAppTargets(xcodeproj, { requireSources: false })) {
296
+ const members = readXcodeAppTarget(xcodeproj, target, undefined, {
297
+ requireSources: false,
298
+ })
299
+ for (const file of members.resources) {
300
+ if (!bundledByName.has(path.basename(file)))
301
+ bundledByName.set(path.basename(file), file)
302
+ }
303
+ }
304
+ }
288
305
  const plistPaths = [path.join(iosDir, 'Info.plist')]
289
306
  for (const entry of targetDirs) {
290
307
  plistPaths.push(path.join(iosDir, entry.name, 'Info.plist'))
@@ -301,15 +318,19 @@ function discoverNativeLinkedAppFontAssets(opts: {
301
318
  if (!Array.isArray(declaredFonts)) continue
302
319
  for (const declaredFont of declaredFonts) {
303
320
  if (typeof declaredFont !== 'string') continue
321
+ // a file the target copies into its bundle is linked wherever it
322
+ // lives, including a workspace package's folder outside the app.
323
+ const bundled = bundledByName.get(declaredFont)
304
324
  const candidates = [
305
325
  path.resolve(path.dirname(plistPath), declaredFont),
306
326
  path.resolve(iosDir, declaredFont),
307
327
  ]
308
- const fontPath = candidates.find((candidate) => {
309
- const relative = path.relative(selectedAppDir, candidate)
310
- if (relative.startsWith('..') || path.isAbsolute(relative)) return false
311
- return isFile(candidate)
312
- })
328
+ const fontPath =
329
+ candidates.find((candidate) => {
330
+ const relative = path.relative(selectedAppDir, candidate)
331
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return false
332
+ return isFile(candidate)
333
+ }) ?? (bundled && isFile(bundled) ? bundled : undefined)
313
334
  if (fontPath) fontPaths.push(fontPath)
314
335
  }
315
336
  }
@@ -382,7 +403,7 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
382
403
  signal: controller.signal,
383
404
  })
384
405
  if (res.ok) {
385
- const manifest = (await res.json()) as {
406
+ const manifest = parseExpoManifestBody(await res.text()) as {
386
407
  extra?: { expoClient?: { _internal?: { projectRoot?: unknown } } }
387
408
  }
388
409
  servedManifest = true
@@ -448,7 +469,7 @@ export async function fetchAppFontFiles(
448
469
  ): Promise<FetchedAppFont[]> {
449
470
  const out: FetchedAppFont[] = []
450
471
  const seen = new Set<string>()
451
- for (const url of appFontAssetUrlsFromWire(wire)) {
472
+ for (const url of appFontAssetUrlsFromWire(await resolveAppFontsWire(wire, fetch))) {
452
473
  const urlhash = createHash('sha256').update(url).digest('hex')
453
474
  if (seen.has(urlhash)) continue
454
475
  seen.add(urlhash)
package/cli/app-stubs.ts CHANGED
@@ -3,7 +3,14 @@
3
3
  // packages, and a library's own `peach` field, lowered into the engine's
4
4
  // nativeModules / nativeViews wire slots.
5
5
 
6
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from 'node:fs'
7
14
  import { dirname, join, relative, resolve } from 'node:path'
8
15
  import { resolveCompatVersionRange } from '@peach/compat/version-range'
9
16
  import { parseSync, type Expression } from 'oxc-parser'
@@ -19,7 +26,14 @@ import {
19
26
  } from '../src/config'
20
27
  import { isRecord } from '../src/is-record'
21
28
  import { visitParsedProgram } from '../src/visit-parsed-program'
22
- import { readProjectManifest, scanRepository } from './commands/detect'
29
+ import {
30
+ readProjectManifest,
31
+ scanRepository,
32
+ metadataValue,
33
+ readProjectJson,
34
+ } from './commands/detect'
35
+ import type { ProjectManifest } from './commands/projectReportContract'
36
+ import type { ScanResult } from '@peach/compat/web'
23
37
 
24
38
  // who answers a package, in rising precedence
25
39
  export type StubOrigin = 'library' | 'stub-package' | 'config'
@@ -125,7 +139,25 @@ export function resolveAppStubs(
125
139
  baseDir: dir,
126
140
  })
127
141
  }
128
- for (const [target, stubs] of Object.entries(config?.packages ?? {})) {
142
+ const configuredPackages = Object.entries(config?.packages ?? {})
143
+ const packageNames = new Set<string>()
144
+ if (typeof manifest?.name === 'string') packageNames.add(manifest.name)
145
+ for (const [target] of configuredPackages) {
146
+ const dir = installedPackageDir(appDir, target)
147
+ if (dir && readPackageJson(dir)?.name === target) packageNames.add(target)
148
+ }
149
+ if (manifest && configuredPackages.some(([target]) => !packageNames.has(target))) {
150
+ for (const root of [appDir, ...workspacePackageDirs(appDir, manifest)])
151
+ for (const dir of scanRepository(root).directories) {
152
+ const name = readPackageJson(dir)?.name
153
+ if (typeof name === 'string') packageNames.add(name)
154
+ }
155
+ }
156
+ for (const [target, stubs] of configuredPackages) {
157
+ if (manifest && !packageNames.has(target))
158
+ throw new Error(
159
+ `packages[${JSON.stringify(target)}]: no installed or local package has this name; use the name from the package's package.json`,
160
+ )
129
161
  sources.push({
130
162
  target,
131
163
  origin: 'config',
@@ -368,12 +400,17 @@ export function wireSlotMigration(
368
400
  if (!owners.has(`${request.kind}:${request.name}`))
369
401
  owners.set(`${request.kind}:${request.name}`, name)
370
402
  }
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))
403
+ if (manifest)
404
+ for (const request of scanLocalNative(
405
+ appDir,
406
+ manifest,
407
+ { module: new Set(), view: new Set() },
408
+ undefined,
409
+ new Set(),
410
+ true,
411
+ ))
374
412
  if (!owners.has(`${request.kind}:${request.name}`))
375
- owners.set(`${request.kind}:${request.name}`, manifest.name)
376
- const unknownOwner = '<package that asks for it>'
413
+ owners.set(`${request.kind}:${request.name}`, request.package)
377
414
  const blocks = new Map<string, { modules: string[]; views: string[] }>()
378
415
  const manual: string[] = []
379
416
  for (const [kind, entries] of [
@@ -395,7 +432,13 @@ export function wireSlotMigration(
395
432
  )
396
433
  continue
397
434
  }
398
- const owner = owners.get(`${kind}:${name}`) ?? unknownOwner
435
+ const owner = owners.get(`${kind}:${name}`)
436
+ if (!owner) {
437
+ manual.push(
438
+ `${name}: no requesting package was found; find the package that requests this ${kind} before adding its packages entry`,
439
+ )
440
+ continue
441
+ }
399
442
  const block = blocks.get(owner) ?? { modules: [], views: [] }
400
443
  block[kind === 'module' ? 'modules' : 'views'].push(
401
444
  `[${JSON.stringify(name)}]: ${stub}`,
@@ -431,3 +474,175 @@ export function readSmallFile(file: string): string | null {
431
474
  return null
432
475
  }
433
476
  }
477
+
478
+ export function hasNativeCode(
479
+ dir: string,
480
+ platform: ScanResult['scoreSummary']['platform'],
481
+ ): boolean {
482
+ const expoConfig = join(dir, 'expo-module.config.json')
483
+ const platforms = metadataValue(readProjectJson(expoConfig), 'platforms')
484
+ if (Array.isArray(platforms))
485
+ return (
486
+ platforms.includes(platform) || (platform === 'ios' && platforms.includes('apple'))
487
+ )
488
+ if ([platform, 'expo-module.config.json'].some((entry) => existsSync(join(dir, entry))))
489
+ return true
490
+ if (listDir(dir).some((name) => name.endsWith('.podspec'))) return true
491
+ const manifest = readSmallFile(join(dir, 'package.json'))
492
+ return manifest !== null && manifest.includes('"codegenConfig"')
493
+ }
494
+
495
+ // an app's own native code, found from the names its JavaScript asks the native
496
+ // side for and from local Expo modules (a directory with expo-module.config.json).
497
+ // each name is a key under its package in peach.config.ts `packages`.
498
+ export interface LocalNative {
499
+ kind: 'module' | 'view'
500
+ name: string
501
+ // the package the name belongs to: the nearest package.json with a name
502
+ package: string
503
+ // first place the name appears, relative to the app, as file:line
504
+ path: string
505
+ // the local Expo module directory the name belongs to, relative to the app
506
+ expoModule: string | null
507
+ swift: boolean
508
+ // a stub already answers it
509
+ answered: boolean
510
+ }
511
+
512
+ const NATIVE_SOURCE_FILE = /\.(?:swift|kt)$/
513
+ export type AnsweredNames = Record<LocalNative['kind'], Set<string>>
514
+
515
+ export function scanLocalNative(
516
+ appDir: string,
517
+ pkg: ProjectManifest,
518
+ answered: AnsweredNames,
519
+ platform: ScanResult['scoreSummary']['platform'] | undefined,
520
+ knownPackages: ReadonlySet<string> = new Set(),
521
+ includeLegacy = false,
522
+ ): LocalNative[] {
523
+ const expoModuleDirs: string[] = []
524
+ const excludedModuleDirs: string[] = []
525
+ const sourceFiles: string[] = []
526
+ for (const root of [appDir, ...workspacePackageDirs(appDir, pkg)]) {
527
+ for (const dir of scanRepository(root).directories) {
528
+ if (
529
+ excludedModuleDirs.some(
530
+ (excluded) => dir === excluded || dir.startsWith(`${excluded}/`),
531
+ )
532
+ )
533
+ continue
534
+ if (existsSync(join(dir, 'expo-module.config.json'))) {
535
+ if (platform && !hasNativeCode(dir, platform)) {
536
+ excludedModuleDirs.push(dir)
537
+ continue
538
+ }
539
+ expoModuleDirs.push(dir)
540
+ }
541
+ for (const name of listDir(dir)) {
542
+ if (SOURCE_FILE.test(name) && !name.endsWith('.d.ts'))
543
+ sourceFiles.push(join(dir, name))
544
+ }
545
+ }
546
+ }
547
+ const nativeFilesByModule = new Map(
548
+ expoModuleDirs.map((dir) => [
549
+ dir,
550
+ scanRepository(dir).directories.flatMap((sub) =>
551
+ listDir(sub)
552
+ .filter((name) => NATIVE_SOURCE_FILE.test(name))
553
+ .map((name) => join(sub, name)),
554
+ ),
555
+ ]),
556
+ )
557
+
558
+ const owners = new Map<string, string>()
559
+ const owner = (dir: string): string => {
560
+ const known = owners.get(dir)
561
+ if (known) return known
562
+ const name = readPackageJson(dir)?.name
563
+ const parent = dirname(dir)
564
+ const result =
565
+ typeof name === 'string'
566
+ ? name
567
+ : parent === dir
568
+ ? (pkg.name ?? 'app')
569
+ : owner(parent)
570
+ owners.set(dir, result)
571
+ return result
572
+ }
573
+ const found = new Map<string, LocalNative>()
574
+ const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
575
+ const packageName = owner(dirname(file))
576
+ if (knownPackages.has(packageName)) return
577
+ const key = `${kind}:${name}`
578
+ if (found.has(key)) return
579
+ const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
580
+ found.set(key, {
581
+ kind,
582
+ name,
583
+ package: packageName,
584
+ path: `${relative(appDir, file)}:${line}`,
585
+ expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
586
+ swift: moduleDir
587
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
588
+ path.endsWith('.swift'),
589
+ )
590
+ : false,
591
+ answered: answered[kind].has(name),
592
+ })
593
+ }
594
+ for (const file of sourceFiles) {
595
+ const text = readSmallFile(file)
596
+ if (!text) continue
597
+ for (const { kind, name, line } of sourceNativeRequests(file, text, includeLegacy))
598
+ add(kind, name, file, line)
599
+ }
600
+ // a local expo module declares its name in its native definition, Name("X"),
601
+ // whether or not the app's javascript spells it out. swift first, so a
602
+ // module the report marks Swift points at its swift definition.
603
+ for (const [, files] of nativeFilesByModule) {
604
+ const swiftFirst = [...files].sort(
605
+ (a, b) => Number(b.endsWith('.swift')) - Number(a.endsWith('.swift')),
606
+ )
607
+ for (const file of swiftFirst) {
608
+ const text = readSmallFile(file)
609
+ if (!text) continue
610
+ for (const match of text.matchAll(/\bName\(\s*"(\w+)"\s*\)/g)) {
611
+ if (match[1])
612
+ add('module', match[1], file, text.slice(0, match.index).split('\n').length)
613
+ }
614
+ }
615
+ }
616
+ return [...found.values()].sort(
617
+ (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
618
+ )
619
+ }
620
+
621
+ // dependencies installed as links to source outside any node_modules are the
622
+ // app's own workspace packages, which hold its native code as often as the app
623
+ // directory does.
624
+ function workspacePackageDirs(appDir: string, pkg: ProjectManifest): string[] {
625
+ const dirs = new Set<string>()
626
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
627
+ let current = appDir
628
+ while (true) {
629
+ const link = join(current, 'node_modules', name)
630
+ if (existsSync(link)) {
631
+ if (lstatSync(link).isSymbolicLink()) {
632
+ const target = realpathSync(link)
633
+ if (
634
+ !target.split('/').includes('node_modules') &&
635
+ !target.startsWith(`${appDir}/`)
636
+ ) {
637
+ dirs.add(target)
638
+ }
639
+ }
640
+ break
641
+ }
642
+ const parent = dirname(current)
643
+ if (parent === current) break
644
+ current = parent
645
+ }
646
+ }
647
+ return [...dirs]
648
+ }