peachsim 0.1.1625 → 0.1.1640

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
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
@@ -323,7 +323,16 @@ resolve` re-pins it, and the CLI release publishes any bundle the bucket lacks.
323
323
  On Mac, headless launches attach an installed compatible native host. With no
324
324
  host, they render in the browser without downloading or placing the app. The
325
325
  browser logo menu offers the app; `peach desktop install --native` remains the
326
- 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.
327
336
 
328
337
  Headed launches on Linux use full Chromium, whose extra libraries the bundle
329
338
  also carries. Later opens reuse that sim and
@@ -590,7 +599,11 @@ foreground or background state. Both retain its browser session and app state.
590
599
  `sourceApp` does not add fields to React Native's `{ url }` event. A
591
600
  stopped guest rejects URL delivery. Explicit `newInstance: true`, deletion,
592
601
  and video recording create a new browser context; cold launches still use the
593
- supplied shell URL. When synchronization is enabled, URL delivery waits for
602
+ supplied shell URL. Process termination and `newInstance: true` preserve
603
+ cookies, local storage and IndexedDB for the next launch. `delete: true` and
604
+ driver cleanup clear that retained storage. Relaunch creates a fresh runtime,
605
+ so input focus and other in-memory component state do not survive termination.
606
+ When synchronization is enabled, URL delivery waits for
594
607
  existing work before dispatch and for navigation and painting afterward.
595
608
 
596
609
  Element taps finish viewport measurement before resolving the selected node's
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,18 +81,29 @@ 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
  }
87
90
  const wire = WIRE_SLOTS.filter((slot) => loaded[slot] !== undefined)
88
91
  if (wire.length > 0) {
89
- const migration = wireSlotMigration(project.appDir, {
90
- modules: { ...loaded.turboModules, ...loaded.nativeModules },
91
- views: { ...loaded.nativeViews },
92
- })
92
+ const slots: readonly string[] = WIRE_SLOTS
93
+ const kept = Object.entries(loaded)
94
+ .filter(([key, value]) => !slots.includes(key) && value !== undefined)
95
+ .map(([key]) => key)
96
+ const migration = wireSlotMigration(
97
+ project.appDir,
98
+ project.configPath,
99
+ {
100
+ modules: { ...loaded.turboModules, ...loaded.nativeModules },
101
+ views: { ...loaded.nativeViews },
102
+ },
103
+ kept,
104
+ )
93
105
  throw new Error(
94
- `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. replace them with:\n${migration}`,
106
+ `${wire.join(', ')} moved under packages, keyed by the package whose native side they answer. ${kept.length ? 'remove them and add' : 'replace the config with'}:\n${migration}`,
95
107
  )
96
108
  }
97
109
  return resolveConfigReplacementFiles(loaded, project.configPath)
package/cli/app-fonts.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  resolveAppFontsWire,
8
8
  expoFontPathsFromPlugins,
9
9
  nativeLinkedFontSpecsFromPaths,
10
- metroFontSpecsFromPaths,
11
10
  appFontAssetUrlsFromWire,
12
11
  type ConfigPluginFontSpec,
13
12
  type FontPlatform,
@@ -15,8 +14,10 @@ import {
15
14
  import { resolveListeningPid, resolveProcessCwd } from '../scripts/dev-server-scanner'
16
15
  import { loopbackOriginOf } from '../src/backend-origin'
17
16
  import { MANIFEST_PROBE_TIMEOUT_MS } from '../src/dev-bundle-resolution'
17
+ import { parseExpoManifestBody } from '../src/expo-manifest-body'
18
18
  import { stageHostFile } from '../src/host/staged-host-files'
19
19
  import { parsePlistSource } from '../src/plist.ts'
20
+ import { listXcodeAppTargets, readXcodeAppTarget } from '../src/xcode-project'
20
21
  import { resolveAppProject } from './app-project'
21
22
  import {
22
23
  materialSymbolFontPaths,
@@ -31,24 +32,22 @@ export type DiscoveredAppFontSpec = ConfigPluginFontSpec
31
32
 
32
33
  type ExpoConfigGetter = (dir: string, opts: Record<string, unknown>) => { exp?: unknown }
33
34
 
34
- // every font file is found on disk, so the asset urls are decided here and
35
- // never by asking the dev server: metro's asset endpoint resolves a path
36
- // against its config.projectRoot, which is the root it advertises and the one
37
- // projectDir names. a reachability request would make the result depend on how
38
- // 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.
39
39
  export function discoverNativeLinkedAppFonts(opts: {
40
- bundleUrl: string
41
40
  projectDir: string
42
41
  platform?: string
43
42
  nativeAssetOrigin: string
44
43
  }): DiscoveredAppFontSpec[] {
45
- const metroOrigin = loopbackOriginOf(opts.bundleUrl)
46
- if (!metroOrigin) return []
47
44
  const { paths, materialSymbols } = discoverNativeLinkedAppFontAssets(opts)
48
- const specs = metroFontSpecsFromPaths(
45
+ const realProjectRoot = fs.realpathSync(opts.projectDir)
46
+ const specs = nativeLinkedFontSpecsFromPaths(
49
47
  projectRelativeFontPaths(opts.projectDir, paths),
50
- metroOrigin,
51
48
  nativeFontPlatform(opts.platform),
49
+ (fontPath) =>
50
+ stageHostFile(path.resolve(realProjectRoot, fontPath), opts.nativeAssetOrigin),
52
51
  )
53
52
  attachMaterialSymbolMetadata(specs, materialSymbols, (asset) =>
54
53
  stageHostFile(asset, opts.nativeAssetOrigin),
@@ -286,6 +285,23 @@ function discoverNativeLinkedAppFontAssets(opts: {
286
285
  !entry.name.startsWith('.'),
287
286
  )
288
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
+ }
289
305
  const plistPaths = [path.join(iosDir, 'Info.plist')]
290
306
  for (const entry of targetDirs) {
291
307
  plistPaths.push(path.join(iosDir, entry.name, 'Info.plist'))
@@ -302,15 +318,19 @@ function discoverNativeLinkedAppFontAssets(opts: {
302
318
  if (!Array.isArray(declaredFonts)) continue
303
319
  for (const declaredFont of declaredFonts) {
304
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)
305
324
  const candidates = [
306
325
  path.resolve(path.dirname(plistPath), declaredFont),
307
326
  path.resolve(iosDir, declaredFont),
308
327
  ]
309
- const fontPath = candidates.find((candidate) => {
310
- const relative = path.relative(selectedAppDir, candidate)
311
- if (relative.startsWith('..') || path.isAbsolute(relative)) return false
312
- return isFile(candidate)
313
- })
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)
314
334
  if (fontPath) fontPaths.push(fontPath)
315
335
  }
316
336
  }
@@ -383,7 +403,7 @@ export async function resolveMetroProjectRoot(bundleUrl: string): Promise<string
383
403
  signal: controller.signal,
384
404
  })
385
405
  if (res.ok) {
386
- const manifest = (await res.json()) as {
406
+ const manifest = parseExpoManifestBody(await res.text()) as {
387
407
  extra?: { expoClient?: { _internal?: { projectRoot?: unknown } } }
388
408
  }
389
409
  servedManifest = true
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'
@@ -18,8 +25,16 @@ import {
18
25
  type PeachConfig,
19
26
  } from '../src/config'
20
27
  import { isRecord } from '../src/is-record'
28
+ import { rnxPublicBrand } from '../src/public-brand'
21
29
  import { visitParsedProgram } from '../src/visit-parsed-program'
22
- import { readProjectManifest, scanRepository } from './commands/detect'
30
+ import {
31
+ readProjectManifest,
32
+ scanRepository,
33
+ metadataValue,
34
+ readProjectJson,
35
+ } from './commands/detect'
36
+ import type { ProjectManifest } from './commands/projectReportContract'
37
+ import type { ScanResult } from '@peach/compat/web'
23
38
 
24
39
  // who answers a package, in rising precedence
25
40
  export type StubOrigin = 'library' | 'stub-package' | 'config'
@@ -125,7 +140,25 @@ export function resolveAppStubs(
125
140
  baseDir: dir,
126
141
  })
127
142
  }
128
- for (const [target, stubs] of Object.entries(config?.packages ?? {})) {
143
+ const configuredPackages = Object.entries(config?.packages ?? {})
144
+ const packageNames = new Set<string>()
145
+ if (typeof manifest?.name === 'string') packageNames.add(manifest.name)
146
+ for (const [target] of configuredPackages) {
147
+ const dir = installedPackageDir(appDir, target)
148
+ if (dir && readPackageJson(dir)?.name === target) packageNames.add(target)
149
+ }
150
+ if (manifest && configuredPackages.some(([target]) => !packageNames.has(target))) {
151
+ for (const root of [appDir, ...workspacePackageDirs(appDir, manifest)])
152
+ for (const dir of scanRepository(root).directories) {
153
+ const name = readPackageJson(dir)?.name
154
+ if (typeof name === 'string') packageNames.add(name)
155
+ }
156
+ }
157
+ for (const [target, stubs] of configuredPackages) {
158
+ if (manifest && !packageNames.has(target))
159
+ throw new Error(
160
+ `packages[${JSON.stringify(target)}]: no installed or local package has this name; use the name from the package's package.json`,
161
+ )
129
162
  sources.push({
130
163
  target,
131
164
  origin: 'config',
@@ -348,32 +381,73 @@ export function packageNativeRequests(dir: string): NativeRequest[] {
348
381
  )
349
382
  }
350
383
 
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(
384
+ // the native names each installed dependency's own JavaScript asks for, keyed
385
+ // `kind:name`. links to source outside node_modules are the app's own packages
386
+ // and are scanned as app code instead.
387
+ function dependencyNativeOwners(
356
388
  appDir: string,
357
- wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
358
- ): string {
389
+ manifest: ProjectManifest | null,
390
+ ): Map<string, string> {
359
391
  const owners = new Map<string, string>()
360
- const manifest = readProjectManifest(appDir)
361
392
  for (const name of Object.keys({
362
393
  ...manifest?.devDependencies,
363
394
  ...manifest?.dependencies,
364
395
  })) {
365
396
  const dir = installedPackageDir(appDir, name)
366
- if (!dir) continue
397
+ if (!dir || !dir.split('/').includes('node_modules')) continue
367
398
  for (const request of packageNativeRequests(dir))
368
399
  if (!owners.has(`${request.kind}:${request.name}`))
369
400
  owners.set(`${request.kind}:${request.name}`, name)
370
401
  }
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))
402
+ return owners
403
+ }
404
+
405
+ // a complete config module in the form the app's config file already uses, so
406
+ // the printed text pastes over it
407
+ function configModule(configPath: string | null, packages: string[]): string {
408
+ const body = [' packages: {', ...packages, ' },']
409
+ if (
410
+ configPath &&
411
+ (configPath.endsWith('.cjs') ||
412
+ (configPath.endsWith('.js') &&
413
+ /\bmodule\.exports\b/.test(readSmallFile(configPath) ?? '')))
414
+ )
415
+ return ['module.exports = {', ...body, '}'].join('\n')
416
+ return [
417
+ `import { defineConfig } from '${rnxPublicBrand.packageName}/config'`,
418
+ '',
419
+ 'export default defineConfig({',
420
+ ...body,
421
+ '})',
422
+ ].join('\n')
423
+ }
424
+
425
+ // what replaces a config's old top-level wire slots: each name keyed by the
426
+ // package whose JavaScript asks for it. a config holding only those slots gets
427
+ // a complete module; one with other settings (`kept`) gets just the packages
428
+ // entries, since a whole module would drop them. `packages` takes only 'noop'
429
+ // or a file path, so any other old value is named for a hand move instead of
430
+ // guessed.
431
+ export function wireSlotMigration(
432
+ appDir: string,
433
+ configPath: string | null,
434
+ wire: { modules: Record<string, unknown>; views: Record<string, unknown> },
435
+ kept: readonly string[] = [],
436
+ ): string {
437
+ const manifest = readProjectManifest(appDir)
438
+ const owners = dependencyNativeOwners(appDir, manifest)
439
+ if (manifest)
440
+ for (const request of scanLocalNative(
441
+ appDir,
442
+ manifest,
443
+ { module: new Set(), view: new Set() },
444
+ undefined,
445
+ new Set(),
446
+ true,
447
+ owners,
448
+ ))
374
449
  if (!owners.has(`${request.kind}:${request.name}`))
375
- owners.set(`${request.kind}:${request.name}`, manifest.name)
376
- const unknownOwner = '<package that asks for it>'
450
+ owners.set(`${request.kind}:${request.name}`, request.package)
377
451
  const blocks = new Map<string, { modules: string[]; views: string[] }>()
378
452
  const manual: string[] = []
379
453
  for (const [kind, entries] of [
@@ -395,7 +469,13 @@ export function wireSlotMigration(
395
469
  )
396
470
  continue
397
471
  }
398
- const owner = owners.get(`${kind}:${name}`) ?? unknownOwner
472
+ const owner = owners.get(`${kind}:${name}`)
473
+ if (!owner) {
474
+ manual.push(
475
+ `${name}: no requesting package was found; find the package that requests this ${kind} before adding its packages entry`,
476
+ )
477
+ continue
478
+ }
399
479
  const block = blocks.get(owner) ?? { modules: [], views: [] }
400
480
  block[kind === 'module' ? 'modules' : 'views'].push(
401
481
  `[${JSON.stringify(name)}]: ${stub}`,
@@ -410,9 +490,17 @@ export function wireSlotMigration(
410
490
  ].filter(Boolean)
411
491
  return ` [${JSON.stringify(owner)}]: { ${parts.join(', ')} },`
412
492
  })
493
+ if (lines.length && kept.length)
494
+ return [
495
+ ...manual.map((line) => `// ${line}`),
496
+ `// keep ${kept.join(', ')}; merge these into packages`,
497
+ ' packages: {',
498
+ ...lines,
499
+ ' },',
500
+ ].join('\n')
413
501
  return [
414
- ...(lines.length ? [' packages: {', ...lines, ' },'] : []),
415
- ...manual.map((line) => ` // ${line}`),
502
+ ...manual.map((line) => `// ${line}`),
503
+ ...(lines.length ? [configModule(configPath, lines)] : []),
416
504
  ].join('\n')
417
505
  }
418
506
 
@@ -431,3 +519,179 @@ export function readSmallFile(file: string): string | null {
431
519
  return null
432
520
  }
433
521
  }
522
+
523
+ export function hasNativeCode(
524
+ dir: string,
525
+ platform: ScanResult['scoreSummary']['platform'],
526
+ ): boolean {
527
+ const expoConfig = join(dir, 'expo-module.config.json')
528
+ const platforms = metadataValue(readProjectJson(expoConfig), 'platforms')
529
+ if (Array.isArray(platforms))
530
+ return (
531
+ platforms.includes(platform) || (platform === 'ios' && platforms.includes('apple'))
532
+ )
533
+ if ([platform, 'expo-module.config.json'].some((entry) => existsSync(join(dir, entry))))
534
+ return true
535
+ if (listDir(dir).some((name) => name.endsWith('.podspec'))) return true
536
+ const manifest = readSmallFile(join(dir, 'package.json'))
537
+ return manifest !== null && manifest.includes('"codegenConfig"')
538
+ }
539
+
540
+ // an app's own native code, found from the names its JavaScript asks the native
541
+ // side for and from local Expo modules (a directory with expo-module.config.json).
542
+ // each name is a key under its package in peach.config.ts `packages`.
543
+ export interface LocalNative {
544
+ kind: 'module' | 'view'
545
+ name: string
546
+ // the package the name belongs to: the nearest package.json with a name
547
+ package: string
548
+ // first place the name appears, relative to the app, as file:line
549
+ path: string
550
+ // the local Expo module directory the name belongs to, relative to the app
551
+ expoModule: string | null
552
+ swift: boolean
553
+ // a stub already answers it
554
+ answered: boolean
555
+ }
556
+
557
+ const NATIVE_SOURCE_FILE = /\.(?:swift|kt)$/
558
+ export type AnsweredNames = Record<LocalNative['kind'], Set<string>>
559
+
560
+ export function scanLocalNative(
561
+ appDir: string,
562
+ pkg: ProjectManifest,
563
+ answered: AnsweredNames,
564
+ platform: ScanResult['scoreSummary']['platform'] | undefined,
565
+ knownPackages: ReadonlySet<string> = new Set(),
566
+ includeLegacy = false,
567
+ providers?: ReadonlyMap<string, string>,
568
+ ): LocalNative[] {
569
+ const expoModuleDirs: string[] = []
570
+ const excludedModuleDirs: string[] = []
571
+ const sourceFiles: string[] = []
572
+ for (const root of [appDir, ...workspacePackageDirs(appDir, pkg)]) {
573
+ for (const dir of scanRepository(root).directories) {
574
+ if (
575
+ excludedModuleDirs.some(
576
+ (excluded) => dir === excluded || dir.startsWith(`${excluded}/`),
577
+ )
578
+ )
579
+ continue
580
+ if (existsSync(join(dir, 'expo-module.config.json'))) {
581
+ if (platform && !hasNativeCode(dir, platform)) {
582
+ excludedModuleDirs.push(dir)
583
+ continue
584
+ }
585
+ expoModuleDirs.push(dir)
586
+ }
587
+ for (const name of listDir(dir)) {
588
+ if (SOURCE_FILE.test(name) && !name.endsWith('.d.ts'))
589
+ sourceFiles.push(join(dir, name))
590
+ }
591
+ }
592
+ }
593
+ const nativeFilesByModule = new Map(
594
+ expoModuleDirs.map((dir) => [
595
+ dir,
596
+ scanRepository(dir).directories.flatMap((sub) =>
597
+ listDir(sub)
598
+ .filter((name) => NATIVE_SOURCE_FILE.test(name))
599
+ .map((name) => join(sub, name)),
600
+ ),
601
+ ]),
602
+ )
603
+
604
+ const owners = new Map<string, string>()
605
+ const owner = (dir: string): string => {
606
+ const known = owners.get(dir)
607
+ if (known) return known
608
+ const name = readPackageJson(dir)?.name
609
+ const parent = dirname(dir)
610
+ const result =
611
+ typeof name === 'string'
612
+ ? name
613
+ : parent === dir
614
+ ? (pkg.name ?? 'app')
615
+ : owner(parent)
616
+ owners.set(dir, result)
617
+ return result
618
+ }
619
+ const found = new Map<string, LocalNative>()
620
+ // a name an installed dependency asks for belongs to that dependency, whoever
621
+ // in the app spells it out
622
+ const add = (kind: LocalNative['kind'], name: string, file: string, line: number) => {
623
+ const key = `${kind}:${name}`
624
+ if (found.has(key)) return
625
+ providers ??= dependencyNativeOwners(appDir, pkg)
626
+ const packageName = providers.get(key) ?? owner(dirname(file))
627
+ if (knownPackages.has(packageName)) return
628
+ const moduleDir = expoModuleDirs.find((dir) => file.startsWith(`${dir}/`)) ?? null
629
+ found.set(key, {
630
+ kind,
631
+ name,
632
+ package: packageName,
633
+ path: `${relative(appDir, file)}:${line}`,
634
+ expoModule: moduleDir ? relative(appDir, moduleDir) || '.' : null,
635
+ swift: moduleDir
636
+ ? (nativeFilesByModule.get(moduleDir) ?? []).some((path) =>
637
+ path.endsWith('.swift'),
638
+ )
639
+ : false,
640
+ answered: answered[kind].has(name),
641
+ })
642
+ }
643
+ for (const file of sourceFiles) {
644
+ const text = readSmallFile(file)
645
+ if (!text) continue
646
+ for (const { kind, name, line } of sourceNativeRequests(file, text, includeLegacy))
647
+ add(kind, name, file, line)
648
+ }
649
+ // a local expo module declares its name in its native definition, Name("X"),
650
+ // whether or not the app's javascript spells it out. swift first, so a
651
+ // module the report marks Swift points at its swift definition.
652
+ for (const [, files] of nativeFilesByModule) {
653
+ const swiftFirst = [...files].sort(
654
+ (a, b) => Number(b.endsWith('.swift')) - Number(a.endsWith('.swift')),
655
+ )
656
+ for (const file of swiftFirst) {
657
+ const text = readSmallFile(file)
658
+ if (!text) continue
659
+ for (const match of text.matchAll(/\bName\(\s*"(\w+)"\s*\)/g)) {
660
+ if (match[1])
661
+ add('module', match[1], file, text.slice(0, match.index).split('\n').length)
662
+ }
663
+ }
664
+ }
665
+ return [...found.values()].sort(
666
+ (a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name),
667
+ )
668
+ }
669
+
670
+ // dependencies installed as links to source outside any node_modules are the
671
+ // app's own workspace packages, which hold its native code as often as the app
672
+ // directory does.
673
+ function workspacePackageDirs(appDir: string, pkg: ProjectManifest): string[] {
674
+ const dirs = new Set<string>()
675
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
676
+ let current = appDir
677
+ while (true) {
678
+ const link = join(current, 'node_modules', name)
679
+ if (existsSync(link)) {
680
+ if (lstatSync(link).isSymbolicLink()) {
681
+ const target = realpathSync(link)
682
+ if (
683
+ !target.split('/').includes('node_modules') &&
684
+ !target.startsWith(`${appDir}/`)
685
+ ) {
686
+ dirs.add(target)
687
+ }
688
+ }
689
+ break
690
+ }
691
+ const parent = dirname(current)
692
+ if (parent === current) break
693
+ current = parent
694
+ }
695
+ }
696
+ return [...dirs]
697
+ }