expo-modules-autolinking 55.0.18 → 55.0.19

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,15 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 55.0.19 — 2026-04-28
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [iOS] Resolve npm-bundled precompiled XCFrameworks for nested Expo packages like `expo-modules-core` during `pod install`. ([#45166](https://github.com/expo/expo/pull/45166) by [@chrfalch](https://github.com/chrfalch))
18
+ - [iOS] Build precompiled Expo modules from source when a required upstream Expo dependency is unavailable as prebuilt. ([#45160](https://github.com/expo/expo/pull/45160) by [@chrfalch](https://github.com/chrfalch))
19
+ - [iOS] Add support for optionally downloading external precompiled XCFramework tarballs from during `pod install`. ([#45067](https://github.com/expo/expo/pull/45067) by [@chrfalch](https://github.com/chrfalch))
20
+ - [iOS] Resolve 3rd-party prebuilt xcframework packages via `@react-native-community/cli` autolinking output instead of guessing `node_modules/<pkg>`, fixing pnpm non-hoisted layouts, transitive native deps, yarn resolutions/PnP, and aliased specifiers ([#45004](https://github.com/expo/expo/pull/45004) by [@chrfalch](https://github.com/chrfalch))
21
+
13
22
  ## 55.0.18 — 2026-04-21
14
23
 
15
24
  ### 🐛 Bug fixes
@@ -36,6 +36,14 @@ Configs live in `packages/expo-modules-autolinking/external-configs/ios/<package
36
36
  - **Config location**: `packages/expo-modules-autolinking/external-configs/ios/<package-name>/spm.config.json`
37
37
  - **Source location**: `node_modules/<package-name>/` (resolved at build time)
38
38
  - **Output location**: `packages/precompile/.build/<package-name>/output/<flavor>/xcframeworks/`
39
+ - **Remote fallback**: external package prebuilds can download missing tarballs from a remote host when `EXPO_PRECOMPILED_MODULES_BASE_URL` is set.
40
+
41
+ ### Remote Artifact Downloads
42
+
43
+ Remote downloads are opt-in via `EXPO_PRECOMPILED_MODULES_BASE_URL`.
44
+
45
+ - Supports `http://` and `https://` URLs.
46
+ - Final download path: `<baseUrl>/<npm-package>/output/<packageVersion>/<reactNativeVersion>/<hermesVersion>/<flavor>/xcframeworks/<Product>.tar.gz` when versions are known, otherwise `<baseUrl>/<npm-package>/output/<flavor>/xcframeworks/<Product>.tar.gz`
39
47
 
40
48
  ### The SPMPackageSource Interface
41
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-autolinking",
3
- "version": "55.0.18",
3
+ "version": "55.0.19",
4
4
  "description": "Scripts that autolink Expo modules.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -43,5 +43,5 @@
43
43
  "chalk": "^4.1.0",
44
44
  "commander": "^7.2.0"
45
45
  },
46
- "gitHead": "e37e614d97c3ca53f16b91609a787675d044c284"
46
+ "gitHead": "be06cb45cb9eb8076b6910daa98813a6a3b03287"
47
47
  }
@@ -28,6 +28,8 @@
28
28
 
29
29
  require 'fileutils'
30
30
  require 'json'
31
+ require 'net/http'
32
+ require 'set'
31
33
  require 'uri'
32
34
 
33
35
  module Expo
@@ -41,6 +43,9 @@ module Expo
41
43
  # Environment variable for custom precompiled modules base path
42
44
  MODULES_PATH_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_PATH'.freeze
43
45
 
46
+ # Environment variable for a shared remote base URL used by external prebuilt packages.
47
+ EXTERNAL_MODULES_BASE_URL_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_BASE_URL'.freeze
48
+
44
49
  # Subdirectory within each pod dir for tarballs and build state
45
50
  ARTIFACTS_DIR_NAME = 'artifacts'.freeze
46
51
 
@@ -56,6 +61,10 @@ module Expo
56
61
  # Regex to strip `framework module React { ... }` from modulemaps
57
62
  FRAMEWORK_MODULE_REACT_REGEX = /framework module React \{.*?\n\}\s*/m
58
63
 
64
+ # ExpoModulesJSI is always provided as an xcframework by its own podspec/npm package,
65
+ # so it is not resolved through the Expo precompiled tarball pipeline.
66
+ CUSTOM_XCFRAMEWORK_DEPENDENCIES = %w[ExpoModulesJSI].freeze
67
+
59
68
  # Module-level caches (initialized lazily)
60
69
  @pod_lookup_map = nil
61
70
  @repo_root = nil
@@ -81,6 +90,11 @@ module Expo
81
90
  ENV[MODULES_PATH_ENV_VAR]
82
91
  end
83
92
 
93
+ # Returns the shared base URL for remote external prebuilt artifacts, if set.
94
+ def external_modules_base_url
95
+ ENV[EXTERNAL_MODULES_BASE_URL_ENV_VAR]
96
+ end
97
+
84
98
  # Returns true if precompiled modules are enabled via environment variable
85
99
  def enabled?
86
100
  ENV[ENV_VAR] == '1'
@@ -104,6 +118,19 @@ module Expo
104
118
  }
105
119
  end
106
120
 
121
+ # @react-native-community/cli autolinking output (same shape as RN CLI's `config`).
122
+ # Used to locate 3rd-party packages via real node resolution so non-flat node_modules
123
+ # layouts (pnpm non-hoisted, yarn PnP, aliased specifiers) resolve correctly.
124
+ def react_native_config
125
+ @react_native_config ||= invoke_autolinking('react-native-config', platform: 'ios')
126
+ end
127
+
128
+ # The resolved Expo modules list. Used by scan_node_modules_configs to locate
129
+ # each internal package's spm.config.json via its resolved podspec dir.
130
+ def resolved_modules
131
+ @resolved_modules ||= invoke_autolinking('resolve', platform: 'apple').fetch('modules', [])
132
+ end
133
+
107
134
  # ──────────────────────────────────────────────────────────────────────
108
135
  # Facade methods — called from installer.rb / autolinking_manager.rb
109
136
  # ──────────────────────────────────────────────────────────────────────
@@ -213,7 +240,7 @@ module Expo
213
240
  def has_prebuilt_xcframework?(pod_name)
214
241
  return false unless enabled?
215
242
 
216
- !resolve_prebuilt_info(pod_name).nil?
243
+ resolve_prebuilt_status(pod_name)[:available]
217
244
  end
218
245
 
219
246
  # Returns whether test specs should be included for a pod.
@@ -456,13 +483,13 @@ module Expo
456
483
  def try_link_with_prebuilt_xcframework(spec)
457
484
  return false unless enabled?
458
485
 
459
- resolved = resolve_prebuilt_info(spec.name)
460
- unless resolved
461
- log_linking_status(spec.name, false, "no prebuilt xcframework available")
486
+ resolution = resolve_prebuilt_status(spec.name)
487
+ unless resolution[:available]
488
+ log_linking_status(spec.name, false, resolution)
462
489
  return false
463
490
  end
464
491
 
465
- pod_info, product_name, default_tarball = resolved
492
+ pod_info, product_name, default_tarball = resolution[:resolved]
466
493
 
467
494
  log_linking_status(spec.name, true, default_tarball)
468
495
 
@@ -491,10 +518,13 @@ module Expo
491
518
  # @param spec [Pod::Specification] The podspec to patch
492
519
  # @return [Pod::Specification] A new patched specification (or original on failure)
493
520
  def patch_spec_for_prebuilt(spec)
494
- resolved = resolve_prebuilt_info(spec.name)
495
- return spec unless resolved
521
+ resolution = resolve_prebuilt_status(spec.name)
522
+ unless resolution[:available]
523
+ log_linking_status(spec.name, false, resolution) if resolution[:reason] == :dependency_unavailable
524
+ return spec
525
+ end
496
526
 
497
- pod_info, product_name, default_tarball = resolved
527
+ pod_info, product_name, default_tarball = resolution[:resolved]
498
528
 
499
529
  log_linking_status(spec.name, true, default_tarball)
500
530
 
@@ -563,13 +593,19 @@ module Expo
563
593
  prefix = "[Expo-precompiled] ".blue
564
594
  Pod::UI.info "#{prefix}Precompiled modules:"
565
595
  @linked_pods.sort_by { |name, _| name.downcase }.each do |pod_name, info|
596
+ version = installed_version_for(pod_name)
597
+ version_suffix = version ? " #{"(#{version})".dark}" : ""
566
598
  if info[:found]
567
- Pod::UI.info "#{prefix} 📦 #{pod_name.green}"
599
+ Pod::UI.info "#{prefix} 📦 #{pod_name.green}#{version_suffix}"
568
600
  else
569
- Pod::UI.info "#{prefix} ⚠️ #{pod_name} #{"(Build from source: framework not found #{info[:path]})".yellow}"
601
+ reason = format_prebuilt_unavailable_reason(info)
602
+ Pod::UI.info "#{prefix} ⚠️ #{pod_name}#{version_suffix} #{"(#{reason})".dark}"
570
603
  end
604
+ spm_versions = pod_lookup_map.dig(pod_name, :spm_dependency_versions) || {}
571
605
  info[:spm_deps].each do |dep_name|
572
- Pod::UI.info "#{prefix} ∟ #{dep_name}.xcframework".green
606
+ dep_version = spm_versions[dep_name]
607
+ dep_suffix = dep_version ? " #{"(#{dep_version})".dark}" : ""
608
+ Pod::UI.info "#{prefix} ∟ #{"#{dep_name}.xcframework".green}#{dep_suffix}"
573
609
  end
574
610
  end
575
611
 
@@ -578,6 +614,19 @@ module Expo
578
614
  end
579
615
  end
580
616
 
617
+ # Returns the installed version for a pod by reading its package.json, or nil.
618
+ def installed_version_for(pod_name)
619
+ package_root = pod_lookup_map.dig(pod_name, :package_root)
620
+ return nil unless package_root
621
+
622
+ pkg_json = File.join(package_root, 'package.json')
623
+ return nil unless File.exist?(pkg_json)
624
+
625
+ JSON.parse(File.read(pkg_json))['version']
626
+ rescue JSON::ParserError, Errno::ENOENT
627
+ nil
628
+ end
629
+
581
630
  # ──────────────────────────────────────────────────────────────────────
582
631
  # Post-install configuration steps
583
632
  # ──────────────────────────────────────────────────────────────────────
@@ -606,7 +655,7 @@ module Expo
606
655
 
607
656
  # Copy both flavor tarballs
608
657
  ['debug', 'release'].each do |flavor|
609
- src = File.join(build_output_dir, flavor, 'xcframeworks', "#{product_name}.tar.gz")
658
+ src = resolve_prebuilt_tarball(info, product_name, flavor, pod_name)
610
659
  dst = File.join(artifacts_dir, "#{product_name}-#{flavor}.tar.gz")
611
660
  FileUtils.cp(src, dst) if File.exist?(src) && !File.exist?(dst)
612
661
  end
@@ -618,7 +667,7 @@ module Expo
618
667
  # Self-healing: extract xcframework if missing (CocoaPods cache issue)
619
668
  xcframework_dir = File.join(pod_dir, "#{product_name}.xcframework")
620
669
  unless File.directory?(xcframework_dir)
621
- tarball = File.join(build_output_dir, build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
670
+ tarball = resolve_prebuilt_tarball(info, product_name, build_flavor, pod_name)
622
671
  if File.exist?(tarball)
623
672
  Pod::UI.info "#{'[Expo-precompiled] '.blue}Extracting #{product_name}.xcframework (cache miss)"
624
673
  system("tar", "xzf", tarball, "-C", pod_dir)
@@ -1224,68 +1273,76 @@ module Expo
1224
1273
  scan_external_configs(repo_root)
1225
1274
  end
1226
1275
 
1227
- # Scans node_modules for spm.config.json files in standalone projects.
1228
- # Used when EXPO_PRECOMPILED_MODULES_PATH is set but no monorepo root is found.
1276
+ # Locates spm.config.json for internal Expo modules in standalone projects
1277
+ # (no monorepo root). Uses resolved podspec dirs so non-flat layouts work.
1229
1278
  def scan_node_modules_configs(project_root)
1230
- node_modules = File.join(project_root, 'node_modules')
1231
- return unless File.directory?(node_modules)
1279
+ resolved_modules.each do |mod|
1280
+ podspec_dir = (mod['pods'] || []).map { |pod| pod['podspecDir'] }.compact.first
1281
+ next unless podspec_dir
1232
1282
 
1233
- # Internal Expo packages: node_modules/*/spm.config.json
1234
- Dir.glob(File.join(node_modules, '*', 'spm.config.json')).each do |config_path|
1235
- process_spm_config(config_path, :internal, project_root)
1236
- end
1283
+ config_path = spm_config_path_for_podspec_dir(podspec_dir)
1284
+ next unless config_path
1237
1285
 
1238
- # Internal Expo scoped packages: node_modules/@scope/*/spm.config.json
1239
- Dir.glob(File.join(node_modules, '@*', '*', 'spm.config.json')).each do |config_path|
1240
1286
  process_spm_config(config_path, :internal, project_root)
1241
1287
  end
1242
1288
 
1243
- # External 3rd-party packages: bundled in external-configs/ios/
1244
1289
  scan_external_configs(project_root)
1245
1290
  end
1246
1291
 
1292
+ # Resolves spm.config.json from an Expo autolinking podspecDir. Most modules
1293
+ # report `<package>/ios`, while packages with root podspecs report `<package>`.
1294
+ def spm_config_path_for_podspec_dir(podspec_dir)
1295
+ [podspec_dir, File.dirname(podspec_dir)].uniq.each do |package_dir|
1296
+ config_path = File.join(package_dir, 'spm.config.json')
1297
+ return config_path if File.exist?(config_path)
1298
+ end
1299
+
1300
+ nil
1301
+ end
1302
+
1247
1303
  # Scans spm.config.json files from external-configs/ios/ for 3rd-party packages
1248
1304
  # (e.g. react-native-screens, react-native-svg) that don't ship their own spm.config.json.
1249
1305
  # Shared by both monorepo and standalone project paths.
1250
1306
  #
1251
- # @param effective_root [String] The project or repo root used to locate node_modules
1307
+ # @param effective_root [String] The project or repo root (used to compute the default build output dir)
1252
1308
  def scan_external_configs(effective_root)
1253
1309
  external_configs_dir = File.join(__dir__, '..', '..', 'external-configs', 'ios')
1254
1310
  return unless File.directory?(external_configs_dir)
1255
1311
 
1256
- node_modules = File.join(effective_root, 'node_modules')
1257
-
1258
1312
  # Non-scoped: external-configs/ios/*/spm.config.json
1259
1313
  Dir.glob(File.join(external_configs_dir, '*', 'spm.config.json')).each do |config_path|
1260
1314
  npm_package = File.basename(File.dirname(config_path))
1261
- process_external_config(config_path, npm_package, node_modules, effective_root)
1315
+ process_external_config(config_path, npm_package, effective_root)
1262
1316
  end
1263
1317
 
1264
1318
  # Scoped: external-configs/ios/@scope/*/spm.config.json
1265
1319
  Dir.glob(File.join(external_configs_dir, '@*', '*', 'spm.config.json')).each do |config_path|
1266
1320
  rel = config_path.sub("#{external_configs_dir}/", '')
1267
1321
  npm_package = File.dirname(rel) # e.g. "@shopify/react-native-skia"
1268
- process_external_config(config_path, npm_package, node_modules, effective_root)
1322
+ process_external_config(config_path, npm_package, effective_root)
1269
1323
  end
1270
1324
  end
1271
1325
 
1272
1326
  # Processes a single external spm.config.json for a 3rd-party package.
1273
- def process_external_config(config_path, npm_package, node_modules, effective_root)
1327
+ def process_external_config(config_path, npm_package, effective_root)
1274
1328
  config = JSON.parse(File.read(config_path))
1275
1329
  products = config['products'] || []
1276
- package_root = File.join(node_modules, npm_package)
1277
1330
 
1278
- # Only process if the package is actually installed
1279
- return unless File.directory?(package_root)
1331
+ # Resolve via rncli autolinking so we use real node resolution (handles pnpm,
1332
+ # yarn resolutions/PnP, aliased specifiers). Skip if the package isn't installed.
1333
+ dep = react_native_config.dig('dependencies', npm_package)
1334
+ return unless dep
1335
+
1336
+ package_root = dep['root']
1337
+ pkg_version = dep.dig('platforms', 'ios', 'version')
1280
1338
 
1281
- # Prefer codegenConfig.name from the installed package.json
1339
+ # codegenConfig.name isn't surfaced by rncli; read it from package.json.
1282
1340
  installed_codegen_name = nil
1283
- pkg_version = nil
1284
1341
  pkg_json_path = File.join(package_root, 'package.json')
1285
1342
  if File.exist?(pkg_json_path)
1286
1343
  pkg_json = JSON.parse(File.read(pkg_json_path))
1287
1344
  installed_codegen_name = pkg_json.dig('codegenConfig', 'name')
1288
- pkg_version = pkg_json['version']
1345
+ pkg_version ||= pkg_json['version']
1289
1346
  end
1290
1347
 
1291
1348
  base_dir = custom_modules_path || File.join(effective_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR)
@@ -1326,6 +1383,7 @@ module Expo
1326
1383
  .map { |t| { name: t['name'], path: t['path'] } }
1327
1384
 
1328
1385
  spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact
1386
+ spm_dependency_versions = spm_package_versions(product['spmPackages'])
1329
1387
 
1330
1388
  @pod_lookup_map[pod_name] = {
1331
1389
  type: :external,
@@ -1337,6 +1395,8 @@ module Expo
1337
1395
  product_name: product_name,
1338
1396
  targets: targets,
1339
1397
  spm_dependency_frameworks: spm_dependency_frameworks,
1398
+ spm_dependency_versions: spm_dependency_versions,
1399
+ prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']),
1340
1400
  autolink_when: product['autolinkWhen']
1341
1401
  }
1342
1402
  end
@@ -1344,6 +1404,14 @@ module Expo
1344
1404
  Pod::UI.warn "[Expo-precompiled] Failed to read external config at #{config_path}: #{e.message}"
1345
1405
  end
1346
1406
 
1407
+ # Returns { productName => versionString } from a spm.config.json spmPackages array.
1408
+ def spm_package_versions(spm_packages)
1409
+ (spm_packages || []).each_with_object({}) do |pkg, h|
1410
+ version = pkg['version'].is_a?(Hash) ? pkg['version']['exact'] : pkg['version']
1411
+ h[pkg['productName']] = version if pkg['productName'] && version
1412
+ end
1413
+ end
1414
+
1347
1415
  # Processes a single spm.config.json file and adds entries to @pod_lookup_map.
1348
1416
  def process_spm_config(config_path, type, repo_root)
1349
1417
  config = JSON.parse(File.read(config_path))
@@ -1397,6 +1465,7 @@ module Expo
1397
1465
  .map { |t| { name: t['name'], path: t['path'] } }
1398
1466
 
1399
1467
  spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact
1468
+ spm_dependency_versions = spm_package_versions(product['spmPackages'])
1400
1469
 
1401
1470
  {
1402
1471
  type: type,
@@ -1408,10 +1477,31 @@ module Expo
1408
1477
  product_name: product_name,
1409
1478
  targets: targets,
1410
1479
  spm_dependency_frameworks: spm_dependency_frameworks,
1480
+ spm_dependency_versions: spm_dependency_versions,
1481
+ prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']),
1411
1482
  autolink_when: product['autolinkWhen']
1412
1483
  }
1413
1484
  end
1414
1485
 
1486
+ # Returns local Expo product dependencies whose prebuilt availability must
1487
+ # match this product's availability. Runtime deps like React/Hermes are not
1488
+ # encoded as package/product strings and are ignored here.
1489
+ def prebuilt_dependency_pods(external_dependencies)
1490
+ (external_dependencies || []).filter_map do |dep|
1491
+ next unless dep.is_a?(String) && dep.include?('/')
1492
+
1493
+ parts = dep.split('/')
1494
+ is_scoped = parts[0].start_with?('@')
1495
+ package_name = is_scoped ? "#{parts[0]}/#{parts[1]}" : parts[0]
1496
+ product_name = is_scoped ? parts[2] : parts[1]
1497
+
1498
+ next unless package_name&.start_with?('expo-', '@expo/')
1499
+ next if CUSTOM_XCFRAMEWORK_DEPENDENCIES.include?(product_name)
1500
+
1501
+ product_name
1502
+ end.uniq
1503
+ end
1504
+
1415
1505
  # Resolves the codegen module name. For external packages, prefers codegenConfig.name
1416
1506
  # from the installed package.json over spm.config.json's codegenName.
1417
1507
  def resolve_codegen_name(product, pod_name, npm_package, type, repo_root)
@@ -1490,6 +1580,16 @@ module Expo
1490
1580
  nil
1491
1581
  end
1492
1582
 
1583
+ # Invokes `expo-modules-autolinking <subcommand> --json` and parses the output.
1584
+ # Used by `react_native_config` and `resolved_modules` above.
1585
+ def invoke_autolinking(subcommand, platform:)
1586
+ args = ['node', '--no-warnings', '--eval', "require('expo/bin/autolinking')",
1587
+ 'expo-modules-autolinking', subcommand, '--platform', platform, '--json']
1588
+ JSON.parse(IO.popen(args, &:read))
1589
+ rescue => error
1590
+ raise "Failed to invoke `expo-modules-autolinking #{subcommand}`: #{error}"
1591
+ end
1592
+
1493
1593
  # ──────────────────────────────────────────────────────────────────────
1494
1594
  # Helpers: bundled frameworks
1495
1595
  # ──────────────────────────────────────────────────────────────────────
@@ -1525,7 +1625,13 @@ module Expo
1525
1625
  results = []
1526
1626
  pod_lookup_map.each do |pod_name, info|
1527
1627
  next unless info[:type] == :external
1528
- next unless has_prebuilt_xcframework?(pod_name)
1628
+
1629
+ unless has_prebuilt_xcframework?(pod_name)
1630
+ product_name = info[:product_name] || pod_name
1631
+ expected = File.join(info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
1632
+ Pod::UI.warn "[Expo-precompiled] #{pod_name}: prebuilt xcframework not found. Expected tarball at #{expected}"
1633
+ next
1634
+ end
1529
1635
 
1530
1636
  podspec_file = File.join(info[:podspec_dir], "#{pod_name}.podspec")
1531
1637
  next unless File.exist?(podspec_file)
@@ -1565,11 +1671,15 @@ module Expo
1565
1671
  # Helpers: version resolution for 3rd-party prebuild versioning
1566
1672
  # ──────────────────────────────────────────────────────────────────────
1567
1673
 
1568
- # Returns the installed React Native version from node_modules.
1674
+ # RN package path, as resolved by rncli (handles pnpm workspaces correctly).
1675
+ def react_native_path
1676
+ react_native_config['reactNativePath']
1677
+ end
1678
+
1569
1679
  def react_native_version
1570
1680
  @react_native_version ||= begin
1571
- rn_pkg = File.join(find_node_modules_dir, 'react-native', 'package.json')
1572
- File.exist?(rn_pkg) ? JSON.parse(File.read(rn_pkg))['version'] : nil
1681
+ rn_pkg = react_native_path && File.join(react_native_path, 'package.json')
1682
+ rn_pkg && File.exist?(rn_pkg) ? JSON.parse(File.read(rn_pkg))['version'] : nil
1573
1683
  end
1574
1684
  end
1575
1685
 
@@ -1577,37 +1687,21 @@ module Expo
1577
1687
  # Mirrors the TypeScript resolution logic in tools/src/prebuilds/Utils.ts.
1578
1688
  def hermes_version
1579
1689
  @hermes_version ||= begin
1580
- rn_path = File.join(find_node_modules_dir, 'react-native')
1581
- is_v1 = ENV['RCT_HERMES_V1_ENABLED'] == '1'
1582
- version = nil
1583
-
1584
- # Read from version.properties (primary source)
1585
- props_path = File.join(rn_path, 'sdks', 'hermes-engine', 'version.properties')
1586
- if File.exist?(props_path)
1587
- props = parse_version_properties(props_path)
1588
- version = is_v1 ? props['HERMES_V1_VERSION_NAME'] : props['HERMES_VERSION_NAME']
1589
- end
1590
-
1591
- # Fallback to tag files
1592
- unless version
1593
- tag_file = is_v1 ? '.hermesv1version' : '.hermesversion'
1594
- tag_path = File.join(rn_path, 'sdks', tag_file)
1595
- version = File.read(tag_path).strip if File.exist?(tag_path)
1596
- end
1597
-
1598
- # Normalize: strip "hermes-" prefix and "v" prefix
1599
- version&.gsub(/^hermes-?/i, '')&.gsub(/^v/i, '')&.strip
1600
- end
1601
- end
1690
+ rn_path = react_native_path
1691
+ if rn_path
1692
+ is_v1 = ENV['RCT_HERMES_V1_ENABLED'] == '1'
1693
+ props_path = File.join(rn_path, 'sdks', 'hermes-engine', 'version.properties')
1694
+ version = File.exist?(props_path) ?
1695
+ parse_version_properties(props_path)[is_v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME'] : nil
1696
+
1697
+ # Fallback to tag files
1698
+ unless version
1699
+ tag_path = File.join(rn_path, 'sdks', is_v1 ? '.hermesv1version' : '.hermesversion')
1700
+ version = File.read(tag_path).strip if File.exist?(tag_path)
1701
+ end
1602
1702
 
1603
- # Returns the node_modules directory for the project.
1604
- def find_node_modules_dir
1605
- @node_modules_dir ||= begin
1606
- repo_root = find_repo_root
1607
- if repo_root
1608
- File.join(repo_root, 'node_modules')
1609
- else
1610
- File.join(File.dirname(Dir.pwd), 'node_modules')
1703
+ # Normalize: strip "hermes-" prefix and "v" prefix
1704
+ version&.gsub(/^hermes-?/i, '')&.gsub(/^v/i, '')&.strip
1611
1705
  end
1612
1706
  end
1613
1707
  end
@@ -1641,16 +1735,108 @@ module Expo
1641
1735
  # Resolves prebuilt xcframework info for a pod.
1642
1736
  # @return [Array, nil] [pod_info, product_name, tarball_path] or nil
1643
1737
  def resolve_prebuilt_info(pod_name)
1644
- return nil if build_from_source?(pod_name)
1738
+ resolution = resolve_prebuilt_status(pod_name)
1739
+ resolution[:available] ? resolution[:resolved] : nil
1740
+ end
1645
1741
 
1742
+ # Resolves only this pod's own prebuilt artifact without checking parent dependencies.
1743
+ # @return [Hash] Availability information with :available, :resolved, :reason, and :path.
1744
+ def resolve_own_prebuilt_info(pod_name)
1646
1745
  pod_info = pod_lookup_map[pod_name]
1647
- return nil unless pod_info
1746
+ return { available: false, reason: :missing_config } unless pod_info
1648
1747
 
1649
1748
  product_name = pod_info[:product_name] || pod_name
1650
- tarball = File.join(pod_info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz")
1651
- return nil unless File.exist?(tarball)
1749
+ tarball = resolve_prebuilt_tarball(pod_info, product_name, build_flavor, pod_name)
1750
+ return { available: false, reason: :missing_tarball, path: tarball } unless File.exist?(tarball)
1652
1751
 
1653
- [pod_info, product_name, tarball]
1752
+ { available: true, resolved: [pod_info, product_name, tarball] }
1753
+ end
1754
+
1755
+ # A pod may use a prebuilt xcframework only when its own prebuilt artifact
1756
+ # exists and every local Expo dependency also uses prebuilt.
1757
+ def resolve_prebuilt_status(pod_name, visiting = Set.new)
1758
+ return { available: false, reason: :build_from_source } if build_from_source?(pod_name)
1759
+ return { available: true } if visiting.include?(pod_name)
1760
+
1761
+ own_resolution = resolve_own_prebuilt_info(pod_name)
1762
+ return own_resolution unless own_resolution[:available]
1763
+
1764
+ pod_info = own_resolution[:resolved][0]
1765
+ next_visiting = visiting.dup.add(pod_name)
1766
+
1767
+ (pod_info[:prebuilt_dependency_pods] || []).each do |dep_name|
1768
+ dep_resolution = resolve_prebuilt_status(dep_name, next_visiting)
1769
+ next if dep_resolution[:available]
1770
+
1771
+ return {
1772
+ available: false,
1773
+ reason: :dependency_unavailable,
1774
+ dependency: dep_name,
1775
+ dependency_reason: dep_resolution[:reason],
1776
+ dependency_path: dep_resolution[:path]
1777
+ }
1778
+ end
1779
+
1780
+ own_resolution
1781
+ end
1782
+
1783
+ def resolve_prebuilt_tarball(pod_info, product_name, flavor, pod_name = nil)
1784
+ tarball = File.join(pod_info[:build_output_dir], flavor, 'xcframeworks', "#{product_name}.tar.gz")
1785
+ return tarball if File.exist?(tarball)
1786
+
1787
+ base_url = external_modules_base_url
1788
+ return tarball unless pod_info[:type] == :external && base_url
1789
+
1790
+ output_prefix = File.join(pod_info[:npm_package], 'output')
1791
+ idx = pod_info[:build_output_dir].rindex(output_prefix)
1792
+ relative_path = [
1793
+ (idx ? pod_info[:build_output_dir][idx..] : output_prefix),
1794
+ flavor,
1795
+ 'xcframeworks',
1796
+ "#{product_name}.tar.gz"
1797
+ ].join('/')
1798
+ remote_url = "#{base_url.chomp('/')}/#{relative_path}"
1799
+
1800
+ download_remote_tarball(remote_url, tarball, pod_name || product_name, flavor)
1801
+ end
1802
+
1803
+ def download_remote_tarball(remote_url, destination_path, pod_name, flavor)
1804
+ FileUtils.mkdir_p(File.dirname(destination_path))
1805
+ tmp_path = "#{destination_path}.download-#{Process.pid}"
1806
+
1807
+ Pod::UI.info "#{'[Expo-precompiled] '.blue}#{pod_name}: downloading #{flavor} artifact from #{remote_url}"
1808
+
1809
+ download_to_file(remote_url, tmp_path)
1810
+ FileUtils.mv(tmp_path, destination_path)
1811
+ destination_path
1812
+ rescue => e
1813
+ FileUtils.rm_f(tmp_path) if tmp_path && File.exist?(tmp_path)
1814
+ Pod::UI.warn "[Expo-precompiled] #{pod_name}: failed to download #{flavor} artifact from #{remote_url}: #{e.message}"
1815
+ nil
1816
+ end
1817
+
1818
+ def download_to_file(url, destination_path, limit = 5)
1819
+ raise 'Too many HTTP redirects' if limit <= 0
1820
+
1821
+ uri = URI.parse(url)
1822
+
1823
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.is_a?(URI::HTTPS), open_timeout: 10, read_timeout: 120) do |http|
1824
+ request = Net::HTTP::Get.new(uri.request_uri)
1825
+
1826
+ http.request(request) do |response|
1827
+ case response
1828
+ when Net::HTTPSuccess
1829
+ File.open(destination_path, 'wb') do |file|
1830
+ response.read_body { |chunk| file.write(chunk) }
1831
+ end
1832
+ when Net::HTTPRedirection
1833
+ redirect_url = URI.join(uri.to_s, response['location']).to_s
1834
+ return download_to_file(redirect_url, destination_path, limit - 1)
1835
+ else
1836
+ raise "HTTP #{response.code}"
1837
+ end
1838
+ end
1839
+ end
1654
1840
  end
1655
1841
 
1656
1842
  # ──────────────────────────────────────────────────────────────────────
@@ -1863,10 +2049,27 @@ module Expo
1863
2049
  # ──────────────────────────────────────────────────────────────────────
1864
2050
 
1865
2051
  # Records the linking status for a pod (only once per pod to avoid duplicates).
1866
- def log_linking_status(pod_name, found, path)
2052
+ def log_linking_status(pod_name, found, info = nil)
1867
2053
  @linked_pods ||= {}
1868
2054
  return if @linked_pods[pod_name]
1869
- @linked_pods[pod_name] = { found: found, path: path, spm_deps: [] }
2055
+ status = info.is_a?(Hash) ? info.dup : { path: info }
2056
+ @linked_pods[pod_name] = status.merge(found: found, spm_deps: [])
2057
+ end
2058
+
2059
+ def format_prebuilt_unavailable_reason(info)
2060
+ case info[:reason]
2061
+ when :build_from_source
2062
+ 'configured by buildFromSource'
2063
+ when :missing_config
2064
+ 'prebuilt config not found'
2065
+ when :missing_tarball
2066
+ 'prebuilt tarball not found'
2067
+ when :dependency_unavailable
2068
+ reason = format_prebuilt_unavailable_reason(reason: info[:dependency_reason], path: info[:dependency_path])
2069
+ "dependency #{info[:dependency]} is not using prebuilt: #{reason}"
2070
+ else
2071
+ info[:path] || 'prebuilt unavailable'
2072
+ end
1870
2073
  end
1871
2074
 
1872
2075
  # Records an SPM dependency xcframework bundled inside a precompiled pod.