# Handles precompiled XCFramework module integration for Expo. # # This module provides functionality to: # 1. Link pods with prebuilt XCFrameworks instead of building from source # 2. Discover which pods are using prebuilt XCFrameworks # 3. Filter prebuilt libraries from React Native's codegen to avoid duplicate symbols # # When EXPO_USE_PRECOMPILED_MODULES=1 is set, packages with matching XCFrameworks # in the centralized build output will be linked as vendored frameworks. # # Build output: packages/precompile/.build//output//xcframeworks/.tar.gz # Custom path: Set EXPO_PRECOMPILED_MODULES_PATH to override the base directory # (replaces packages/precompile/.build/), keeping /output/... structure # # Pod install flow: # 1. has_prebuilt_xcframework?(pod_name) returns true → autolinking uses :podspec instead of :path # 2. store_podspec hook auto-patches the spec via patch_spec_for_prebuilt (sandbox.rb) # (ExpoModulesCore still uses inline try_link_with_prebuilt_xcframework for ExpoModulesJSI) # 3. spec.source is set to {:http => "file:///"} → CocoaPods extracts into Pods// # 4. prepare_command copies both flavor tarballs into artifacts/ subdirectory # 5. Script phases switch debug/release at build time via tarball extraction # # Resulting layout in Pods//: # .xcframework/ (extracted by CocoaPods from source tarball) # artifacts/-debug.tar.gz (copied by prepare_command) # artifacts/-release.tar.gz (copied by prepare_command) # artifacts/.last_build_configuration (written by prepare_command / switch script) require 'fileutils' require 'json' require 'net/http' require 'open3' require 'set' require 'tempfile' require 'uri' module Expo module PrecompiledModules # The environment variable that enables precompiled modules ENV_VAR = 'EXPO_USE_PRECOMPILED_MODULES'.freeze # Environment variable for build flavor override BUILD_FLAVOR_ENV_VAR = 'EXPO_PRECOMPILED_FLAVOR'.freeze # Environment variable for custom precompiled modules base path MODULES_PATH_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_PATH'.freeze # Environment variable for a shared remote base URL used by external prebuilt packages. EXTERNAL_MODULES_BASE_URL_ENV_VAR = 'EXPO_PRECOMPILED_MODULES_BASE_URL'.freeze # Subdirectory within each pod dir for tarballs and build state ARTIFACTS_DIR_NAME = 'artifacts'.freeze # Centralized build output directory under packages/precompile/ PRECOMPILE_BUILD_DIR = '.build'.freeze # Subdir under packages/precompile/.build/ holding //.xcframework (monorepo source). SHARED_SPM_DEPS_SOURCE_DIR = '.spm-deps'.freeze # Subdir inside an npm package that bundles shared SPM xcframeworks for standalone consumers. BUNDLED_SHARED_SPM_DEPS_SUBPATH = File.join('prebuilds', 'spm-deps').freeze # Apple platforms supported by CocoaPods podspecs APPLE_PLATFORMS = %w[ios osx tvos watchos visionos].freeze # Implementation source file extensions (everything except headers) SOURCE_FILE_EXTENSIONS = %w[.m .mm .c .cpp .swift].freeze # Regex to strip `framework module React { ... }` from modulemaps FRAMEWORK_MODULE_REACT_REGEX = /framework module React \{.*?\n\}\s*/m # ExpoModulesJSI is always provided as an xcframework by its own podspec/npm package, # so it is not resolved through the Expo precompiled tarball pipeline. CUSTOM_XCFRAMEWORK_DEPENDENCIES = %w[ExpoModulesJSI].freeze # Module-level caches (initialized lazily) @pod_lookup_map = nil @repo_root = nil @linked_pods = nil @build_from_source_patterns = [] @react_native_version = nil @hermes_version = nil @claimed_vendored_frameworks = nil # Set — xcframework names already claimed by a prebuilt pod @framework_owner_map = nil # Hash: framework_name -> owning_pod_name @failed_remote_downloads = Set.new @warned_no_prebuilt_react = false @target_platform = nil @xcframework_slice_cache = nil @status_cache = {} # Hash: pod_name -> resolve_prebuilt_status result class << self # Returns the build flavor (debug/release) for precompiled modules. # Defaults to 'debug', can be overridden via EXPO_PRECOMPILED_FLAVOR env var. def build_flavor ENV[BUILD_FLAVOR_ENV_VAR] || 'debug' end # Returns custom base path for precompiled module output, if set. # When set, replaces the default `packages/precompile/.build/` base directory. # The directory structure under this path must match: # /output//xcframeworks/.tar.gz def custom_modules_path ENV[MODULES_PATH_ENV_VAR] end # Returns the shared base URL for remote external prebuilt artifacts, if set. def external_modules_base_url ENV[EXTERNAL_MODULES_BASE_URL_ENV_VAR] end # Returns true if precompiled modules are enabled via environment variable. # Precompiled module xcframeworks are linked against the prebuilt # React.xcframework, so they also require RCT_USE_PREBUILT_RNCORE=1. If # the user opted in to precompiled modules but React Native is still set # to build from source, fall back to source-built modules and print once. def enabled? return false unless ENV[ENV_VAR] == '1' return true if prebuilt_react_active? unless @warned_no_prebuilt_react @warned_no_prebuilt_react = true Pod::UI.puts "[Expo] EXPO_USE_PRECOMPILED_MODULES=1 was set, but React Native is configured to build from source (RCT_USE_PREBUILT_RNCORE is not 1). Precompiled Expo modules require the prebuilt React.xcframework, so every Expo module will be built from source for this install. To use precompiled modules, ensure `ios.buildReactNativeFromSource` is not `true` in the `expo-build-properties` plugin (the default uses the prebuilt framework), or export RCT_USE_PREBUILT_RNCORE=1 before running `pod install`.".yellow end false end def configure(target_platform: nil, build_from_source: nil) self.target_platform = target_platform unless target_platform.nil? self.build_from_source = build_from_source unless build_from_source.nil? end def build_from_source=(patterns) @build_from_source_patterns = (patterns || []).map { |p| Regexp.new("^#{p}$") } @status_cache = {} end def target_platform=(platform) normalized = normalize_xcframework_platform(platform) return if @target_platform == normalized @target_platform = normalized @all_bundled_frameworks = nil @claimed_vendored_frameworks = nil @framework_owner_map = nil @xcframework_slice_cache = nil @status_cache = {} end # Checks if a pod is configured to be built from source via buildFromSource. # Matches against both the pod name and the npm package name. def build_from_source?(pod_name) return false unless @build_from_source_patterns&.any? npm_package = pod_lookup_map[pod_name]&.dig(:npm_package) @build_from_source_patterns.any? { |re| re.match?(pod_name) || (npm_package && re.match?(npm_package)) } end # @react-native-community/cli autolinking output (same shape as RN CLI's `config`). # Used to locate 3rd-party packages via real node resolution so non-flat node_modules # layouts (pnpm non-hoisted, yarn PnP, aliased specifiers) resolve correctly. def react_native_config @react_native_config ||= invoke_autolinking('react-native-config', platform: 'ios') end # The resolved Expo modules list. Used by scan_node_modules_configs to locate # each internal package's spm.config.json via its resolved podspec dir. def resolved_modules @resolved_modules ||= invoke_autolinking('resolve', platform: 'apple').fetch('modules', []) end # ────────────────────────────────────────────────────────────────────── # Facade methods — called from installer.rb / autolinking_manager.rb # ────────────────────────────────────────────────────────────────────── # Runs all precompiled module post-install steps. # Called from installer.rb after CocoaPods' own post-install actions. # # @param installer [Pod::Installer] The CocoaPods installer instance def perform_post_install(installer) print_linking_summary disable_swift_interface_verification(installer) configure_use_frameworks(installer) ensure_artifacts(installer) configure_header_search_paths(installer) configure_codegen_for_prebuilt_modules(installer) stub_bundled_pod_targets(installer) end # Runs all precompiled module pre-install steps. # Disables use_frameworks! for pods that can't be built as frameworks: # - Hardcoded list of pods with React headers in their public API # - Pods that vendor xcframeworks (already precompiled) # - Source-built pods that depend on React-Core (non-modular includes) # # Also stages shared SPM dep xcframework symlinks inside their owner pod's # directory — must run before `generate_pods_project` reads each xcframework's # Info.plist to slice it. # # @param installer [Pod::Installer] The CocoaPods installer instance def perform_pre_install(installer) return unless enabled? ensure_shared_spm_deps(installer) return if linkage(installer).nil? pods_to_downgrade = Set.new(installer.podfile.framework_modules_to_patch) installer.pod_targets.each do |t| if has_vendored_xcframeworks?(t) pods_to_downgrade.add(t.name) elsif t.root_spec.dependencies.any? { |d| d.name.start_with?('React-Core') } pods_to_downgrade.add(t.name) end end Pod::UI.puts "[Expo] ".blue + "Disabling USE_FRAMEWORKS for #{pods_to_downgrade.size} pods (#{pods_to_downgrade.to_a.join(', ')})" installer.pod_targets.each do |t| if pods_to_downgrade.include?(t.name) def t.build_type Pod::BuildType.static_library end end end end # Symlinks each shared SPM dependency xcframework (e.g. SDWebImage) into the # pod directory of its owner. Ownership is whatever `build_vendored_paths` set # in `@framework_owner_map` during `store_podspec` (resolution-first); # falls back to alphabetical-first only if the map has no entry. Must run # before `generate_pods_project` so CocoaPods sees the symlinks when reading # each xcframework's Info.plist. def ensure_shared_spm_deps(installer) return unless enabled? consumers_by_dep = collect_shared_spm_deps(installer) return if consumers_by_dep.empty? @framework_owner_map ||= {} @claimed_vendored_frameworks ||= Set.new unresolved = [] staged = 0 consumers_by_dep.each do |dep_name, consumers| existing = @framework_owner_map[dep_name] owner_name = (existing && consumers.key?(existing)) ? existing : consumers.keys.sort.first owner_info = consumers[owner_name] @framework_owner_map[dep_name] ||= owner_name @claimed_vendored_frameworks.add(dep_name) source_path = shared_spm_dep_xcframework_path(dep_name, owner_info, build_flavor) owner_pod_dir = File.join(installer.sandbox.root, owner_name) unless source_path && File.directory?(owner_pod_dir) unresolved << dep_name next end FileUtils.rm_rf(File.join(owner_pod_dir, "#{dep_name}.xcframework")) File.symlink(source_path, File.join(owner_pod_dir, "#{dep_name}.xcframework")) staged += 1 end if unresolved.any? Pod::UI.warn "[Expo-precompiled] Shared SPM xcframeworks not found for: #{unresolved.join(', ')} (flavor: #{build_flavor}). The Expo modules that depend on them will fail at runtime with dyld 'Library not loaded: @rpath/.framework/'. Run the precompile prebuild pipeline, or ensure each consuming npm package ships prebuilds/spm-deps///.xcframework." end Pod::UI.puts "[Expo] ".blue + "Staged #{staged}/#{consumers_by_dep.size} shared SPM xcframework(s) (#{build_flavor})" if staged > 0 end # dep_name => { pod_name => pod_info } for shared SPM deps consumed by enabled prebuilt pods in this install. def collect_shared_spm_deps(installer) by_dep = {} installer.pod_targets.each do |pod_target| info = pod_lookup_map[pod_target.name] next unless info && has_prebuilt_xcframework?(pod_target.name) (info[:spm_dependency_frameworks] || []).each do |dep_name| (by_dep[dep_name] ||= {})[pod_target.name] = info end end by_dep end # ────────────────────────────────────────────────────────────────────── # Cache management # ────────────────────────────────────────────────────────────────────── # Clears CocoaPods caches for precompiled pods to ensure specs are re-fetched # and patched on every `pod install`. Without this, incremental installs reuse # stale unpatched specs from `Pods/Local Podspecs/` and the external download # cache, causing precompiled pods to fall back to source builds. def clear_cocoapods_cache return unless enabled? cache_root = File.join(Dir.home, 'Library', 'Caches', 'CocoaPods', 'Pods', 'External') pods_root = Pod::Config.instance.sandbox_root rescue nil local_podspecs_dir = pods_root ? File.join(pods_root, 'Local Podspecs') : nil pod_lookup_map.each_key do |pod_name| next unless has_prebuilt_xcframework?(pod_name) # Clear the external download cache if cache_root && File.directory?(cache_root) cache_dir = File.join(cache_root, pod_name) FileUtils.rm_rf(cache_dir) if File.directory?(cache_dir) end # Clear cached podspecs so store_podspec is called again on the next install, # allowing the spec to be patched for precompiled xcframework usage. if local_podspecs_dir && File.directory?(local_podspecs_dir) cached_spec = File.join(local_podspecs_dir, "#{pod_name}.podspec.json") FileUtils.rm_f(cached_spec) if File.exist?(cached_spec) end # Clear the pod's installed directory so CocoaPods re-downloads from the # patched spec's source tarball instead of reusing stale source-build artifacts. if pods_root pod_dir = File.join(pods_root, pod_name) if File.directory?(pod_dir) product_name = pod_lookup_map[pod_name]&.dig(:product_name) || pod_name xcfw_info = File.join(pod_dir, "#{product_name}.xcframework", 'Info.plist') unless File.exist?(xcfw_info) FileUtils.rm_rf(pod_dir) end end end end end # ────────────────────────────────────────────────────────────────────── # Query methods # ────────────────────────────────────────────────────────────────────── # Checks whether a prebuilt XCFramework tarball exists for the given pod. # Used by autolinking_manager to decide between :podspec and :path registration. # # @param pod_name [String] The pod name to check # @return [Boolean] true if a prebuilt tarball exists in the build output def has_prebuilt_xcframework?(pod_name) return false unless enabled? resolve_prebuilt_status(pod_name)[:available] end # Returns whether test specs should be included for a pod. # Prebuilt pods don't contain test files, so test specs must be skipped. # # @param pod_name [String] The pod name to check # @return [Boolean] true if test specs should be included def should_include_test_specs?(pod_name) !has_prebuilt_xcframework?(pod_name) end # Checks whether a pod should be stubbed because it is bundled inside a prebuilt # xcframework. When stubbed, the pod keeps its headers (so dependents can compile) # but has no implementation files (the symbols come from the xcframework). # # @param name [String] The pod name # @return [Boolean] true if this pod is bundled in a prebuilt xcframework def bundled_dependency?(name) return false unless enabled? root_name = name.split('/').first bundled = all_bundled_frameworks bundled.include?(name) || bundled.include?(root_name) end # Checks whether a podspec should be auto-patched for precompiled module support. # Returns true if the pod has a prebuilt xcframework AND the spec hasn't already # been configured by an inline try_link_with_prebuilt_xcframework call. # # ExpoModulesCore is excluded — it has a source-only dependency on ExpoModulesJSI # that requires special handling via the inline conditional. # # @param name [String] The pod name # @param spec [Pod::Specification] The evaluated podspec # @return [Boolean] true if the spec should be auto-patched def should_auto_patch_spec?(name, spec) return false unless enabled? return false if name == 'ExpoModulesCore' return false unless has_prebuilt_xcframework?(name) vendored = spec.attributes_hash['vendored_frameworks'] if vendored frameworks = vendored.is_a?(Array) ? vendored : [vendored] return false if frameworks.any? { |f| f.to_s.include?('.xcframework') } end true end # Returns true if the pod target vendors .xcframework files. # These pods are already precompiled — CocoaPods doesn't need to wrap them in a framework. def has_vendored_xcframeworks?(pod_target) vendored = pod_target.root_spec.attributes_hash['vendored_frameworks'] return false unless vendored frameworks = vendored.is_a?(Array) ? vendored : [vendored] frameworks.any? { |f| f.to_s.include?('.xcframework') } end # Returns the linkage type if use_frameworks! is active, otherwise returns nil. # Checks both the USE_FRAMEWORKS env var and podfile properties (ios.useFrameworks). # # @param installer [Pod::Installer] The CocoaPods installer instance # @return [Symbol, nil] :dynamic, :static, or nil def linkage(installer) value = ENV["USE_FRAMEWORKS"] if value.nil? podfile_dir = File.dirname(installer.podfile.defined_in_file) props_path = File.join(podfile_dir, 'Podfile.properties.json') if File.exist?(props_path) value = JSON.parse(File.read(props_path))['ios.useFrameworks'] end end return nil if value.nil? return :dynamic if value.downcase == 'dynamic' return :static if value.downcase == 'static' nil end # ────────────────────────────────────────────────────────────────────── # Pod registration (called from autolinking_manager.rb) # ────────────────────────────────────────────────────────────────────── # Returns the pod registration options hash for a given pod. # When a prebuilt xcframework exists, returns :podspec options so CocoaPods # respects spec.source and extracts the tarball. Otherwise returns :path options. # # @param pod [Expo::PackagePod] The pod to register # @param project_directory [Pathname] The project root for computing relative paths # @param debug_configurations [Array] Debug configuration names # @param package [Expo::Package] The package containing the pod # @param global_flags [Hash] Global flags to merge into pod options # @return [Hash] Pod options hash suitable for passing to podfile.pod def pod_registration_options(pod, project_directory, debug_configurations, package, global_flags) configuration = package.debugOnly ? debug_configurations : [] if has_prebuilt_xcframework?(pod.pod_name) podspec_file_path = Pathname.new(pod.podspec_dir) .relative_path_from(project_directory) .join("#{pod.pod_name}.podspec").to_path { :podspec => podspec_file_path, :configuration => configuration }.merge(global_flags, package.flags) else podspec_dir_path = Pathname.new(pod.podspec_dir).relative_path_from(project_directory).to_path { :path => podspec_dir_path, :configuration => configuration }.merge(global_flags, package.flags) end end # Registers external (3rd-party) prebuilt pods with :podspec BEFORE RN CLI's # use_native_modules! runs. RN CLI always uses :path which makes CocoaPods ignore # spec.source. By registering here first, RN CLI will skip these pods (it checks # for existing dependencies), and CocoaPods will respect the spec.source tarball URL. # # @param podfile [Pod::Podfile] The podfile to register pods in # @param target_definition [Pod::Podfile::TargetDefinition] The current target definition # @param project_directory [Pathname] The project root for computing relative paths def register_external_pods(podfile, target_definition, project_directory) external_prebuilt_pods(project_directory).each do |ext_pod| next if target_definition.dependencies.any? { |dep| dep.name == ext_pod[:pod_name] } Pod::UI.message "— #{ext_pod[:pod_name].green} (prebuilt xcframework)" podfile.pod(ext_pod[:pod_name], :podspec => ext_pod[:podspec_path]) end end # Registers companion pods gated by a Podfile property or resolved dependency. # A product can declare `autolinkWhen` in its spm.config.json to opt into this flow. # The pod is auto-registered when: # 1. The podspec exists (source build) or prebuilt xcframework exists (precompiled) # 2. The gating Podfile.properties.json value is not the disabled value, or # the gating dependency is present in the resolved dependency graph # 3. It's not already registered in the Podfile # 4. All of its local dependencies (pods in the lookup map) are already registered # # Works for both precompiled and source builds. For precompiled builds, the # podspec is patched to use the xcframework. For source builds, CocoaPods # builds from source via :podspec. # # Companion pods are production-only code (they never declare test specs) and # typically depend on their sibling main pod. When the Podfile calls # `use_expo_modules_tests!` (tests_only), main pods without test specs are skipped, # so registering a companion that depends on a skipped main pod would fail # dependency resolution. Skip companions entirely in tests-only mode. # # Example spm.config.json: # "autolinkWhen": { # "podfileProperty": "expo.camera.barcode-scanner-enabled", # "disabledValue": "false" # } # "autolinkWhen": { # "podName": "RNWorklets" # } def register_companion_pods(podfile, target_definition, project_directory, tests_only: false) return if tests_only properties = read_podfile_properties(project_directory) pod_lookup_map.each do |pod_name, info| condition = info[:autolink_when] next unless condition next if target_definition.dependencies.any? { |dep| dep.name == pod_name } next unless companion_autolink_condition_met?(condition, properties) podspec_file = File.join(info[:podspec_dir], "#{pod_name}.podspec") unless File.exist?(podspec_file) Pod::UI.warn "[Expo] Companion pod #{pod_name}: podspec not found at #{podspec_file}" next end podspec_rel = Pathname.new(podspec_file).relative_path_from(project_directory).to_s # Parse the companion podspec to inspect its dependencies. begin spec = Pod::Specification.from_file(podspec_file) rescue => e Pod::UI.warn "[Expo] Companion pod #{pod_name}: failed to parse podspec: #{e.message}" next end # Skip companion pods whose local dependencies (sibling pods from the same # monorepo / node_modules) aren't registered in the Podfile. For example, # ExpoCameraBarcodeScanning depends on ExpoCamera — if expo-camera isn't # installed in the project, ExpoCamera won't be in the Podfile and CocoaPods # would fail with "Unable to find a specification for ExpoCamera". registered_pod_names = target_definition.dependencies.map(&:name) missing_local_dep = spec.all_dependencies.find do |dep| root_spec_name = dep.name.partition('/').first dep_info = pod_lookup_map[root_spec_name] dep_info && dep_info[:type] == :internal && !registered_pod_names.include?(root_spec_name) end if missing_local_dep Pod::UI.message "[Expo] Skipping companion pod #{pod_name}: dependency #{missing_local_dep.name} is not installed" next end # Enable modular headers for the companion pod's transitive Objective-C dependencies so # the Swift pod can `import` them. Mirrors the logic in autolinking_manager.rb's # `use_modular_headers_for_dependencies`. spec.all_dependencies.each do |dep| root_spec_name = dep.name.partition('/').first unless target_definition.build_pod_as_module?(root_spec_name) target_definition.set_use_modular_headers_for_pod(root_spec_name, true) end end condition_label = companion_autolink_condition_label(condition) if enabled? && has_prebuilt_xcframework?(pod_name) Pod::UI.message "— #{pod_name.green} (prebuilt companion, gated by #{condition_label})" podfile.pod(pod_name, :podspec => podspec_rel) else Pod::UI.message "— #{pod_name.green} (companion, gated by #{condition_label})" podspec_dir_rel = Pathname.new(info[:podspec_dir]).relative_path_from(project_directory).to_s podfile.pod(pod_name, :path => podspec_dir_rel) end end end # Reads Podfile.properties.json from the Podfile's directory (installation root). # Returns an empty hash if the file doesn't exist or fails to parse. def read_podfile_properties(_project_directory) props_path = File.join(Pod::Config.instance.installation_root.to_s, 'Podfile.properties.json') return {} unless File.exist?(props_path) JSON.parse(File.read(props_path)) rescue {} end def companion_autolink_condition_met?(condition, properties) pod_name = condition['podName'] return pod_lookup_map.key?(pod_name) if pod_name npm_package = condition['npmPackage'] return react_native_config.dig('dependencies', npm_package) != nil if npm_package property = condition['podfileProperty'] return false unless property # Only skip if the property is explicitly set to the disabled value. properties[property] != condition['disabledValue'] end def companion_autolink_condition_label(condition) condition['podName'] || condition['npmPackage'] || condition['podfileProperty'] end # ────────────────────────────────────────────────────────────────────── # Spec patching (called from sandbox.rb / podspecs) # ────────────────────────────────────────────────────────────────────── # Links a pod spec with a prebuilt XCFramework. # # NOTE: This method is only used by ExpoModulesCore.podspec, which needs an inline # conditional for its source-only ExpoModulesJSI dependency. All other pods are # handled by auto-patching in store_podspec → patch_spec_for_prebuilt. # # Sets spec.source to the local tarball so CocoaPods extracts it into Pods//. # Adds a prepare_command to copy both flavor tarballs into artifacts/. # Adds script phases for debug/release switching at build time. # # @param spec [Pod::Spec] The podspec to link with a prebuilt framework # @return [Boolean] true if a prebuilt framework was linked, false otherwise def try_link_with_prebuilt_xcframework(spec) return false unless enabled? resolution = resolve_prebuilt_status(spec.name) unless resolution[:available] log_linking_status(spec.name, false, resolution) return false end pod_info, product_name, default_tarball = resolution[:resolved] log_linking_status(spec.name, true, default_tarball) spec.source = { :http => local_file_uri(default_tarball), :flatten => false } spec.vendored_frameworks = build_vendored_paths(product_name, pod_info, spec.name) extra_fw_paths = framework_search_paths_for_skipped_deps(spec.name, pod_info) if extra_fw_paths.any? spec.pod_target_xcconfig ||= {} existing = spec.pod_target_xcconfig['FRAMEWORK_SEARCH_PATHS'] || '$(inherited)' spec.pod_target_xcconfig['FRAMEWORK_SEARCH_PATHS'] = ([existing] + extra_fw_paths).join(' ') end strip_bundled_deps_from_spec(spec, pod_info) spec.prepare_command = prepare_command_script(product_name, pod_info[:build_output_dir]) add_script_phases(spec, product_name, pod_info) true end # Takes a fully-evaluated podspec (with source-build attributes set), # converts it to JSON, patches it for precompiled xcframework usage, # and returns a new Pod::Specification. # # @param spec [Pod::Specification] The podspec to patch # @return [Pod::Specification] A new patched specification (or original on failure) def patch_spec_for_prebuilt(spec) resolution = resolve_prebuilt_status(spec.name) unless resolution[:available] log_linking_status(spec.name, false, resolution) if resolution[:reason] == :dependency_unavailable return spec end pod_info, product_name, default_tarball = resolution[:resolved] log_linking_status(spec.name, true, default_tarball) spec_json = JSON.parse(spec.to_pretty_json) # Override source to local tarball spec_json['source'] = { 'http' => local_file_uri(default_tarball), 'flatten' => false } spec_json['vendored_frameworks'] = build_vendored_paths(product_name, pod_info, spec.name) # Clear source-build attributes %w[source_files exclude_files static_framework header_dir header_mappings_dir private_header_files compiler_flags].each do |attr| spec_json.delete(attr) end # Clear platform-specific source attributes clear_platform_source_attributes(spec_json) # Remove subspecs and testspecs (source file groupings / test files not in tarball) spec_json.delete('subspecs') spec_json.delete('testspecs') strip_bundled_deps_from_json(spec_json, pod_info, spec.name) spec_json['prepare_command'] = prepare_command_script(product_name, pod_info[:build_output_dir]) spec_json['script_phases'] = build_script_phases_json(spec.name, product_name, pod_info) # Ensure DEFINES_MODULE is set spec_json['pod_target_xcconfig'] ||= {} spec_json['pod_target_xcconfig']['DEFINES_MODULE'] = 'YES' # Add framework search paths for shared SPM deps owned by another prebuilt pod extra_fw_paths = framework_search_paths_for_skipped_deps(spec.name, pod_info) if extra_fw_paths.any? existing = spec_json['pod_target_xcconfig']['FRAMEWORK_SEARCH_PATHS'] || '$(inherited)' spec_json['pod_target_xcconfig']['FRAMEWORK_SEARCH_PATHS'] = ([existing] + extra_fw_paths).join(' ') end Pod::Specification.from_json(spec_json.to_json) end # Patches a podspec to be header-only, removing implementation files. # The pod's headers remain available so dependent pods can still compile, # but no object files are produced — the symbols live in the prebuilt xcframework. # # @param spec [Pod::Specification] The podspec to stub # @return [Pod::Specification] A new header-only specification def stub_bundled_pod(spec) spec_json = JSON.parse(spec.to_pretty_json) Pod::UI.puts "#{'[Expo-precompiled] '.blue}Stubbing '#{spec.name}' — bundled in prebuilt xcframework" stub_source_files_to_headers(spec_json) (spec_json['subspecs'] || []).each do |subspec| stub_source_files_to_headers(subspec) end Pod::Specification.from_json(spec_json.to_json) end # Prints a consolidated summary of all precompiled module linking results. def print_linking_summary return unless @linked_pods&.any? prefix = "[Expo-precompiled] ".blue Pod::UI.info "#{prefix}Precompiled modules:" @linked_pods.sort_by { |name, _| name.downcase }.each do |pod_name, info| version = installed_version_for(pod_name) version_suffix = version ? " #{"(#{version})".dark}" : "" if info[:found] Pod::UI.info "#{prefix} 📦 #{pod_name.green}#{version_suffix}" else reason = format_prebuilt_unavailable_reason(info) Pod::UI.info "#{prefix} ⚠️ #{pod_name}#{version_suffix} #{"(#{reason})".dark}" end spm_versions = pod_lookup_map.dig(pod_name, :spm_dependency_versions) || {} info[:spm_deps].each do |dep_name| dep_version = spm_versions[dep_name] dep_suffix = dep_version ? " #{"(#{dep_version})".dark}" : "" Pod::UI.info "#{prefix} ∟ #{"#{dep_name}.xcframework".green}#{dep_suffix}" end end if @linked_pods.none? { |_, info| info[:found] } Pod::UI.warn "#{prefix}⚠️ Precompiled modules enabled but no xcframeworks found. All modules will build from source." end end # Returns the installed version for a pod by reading its package.json, or nil. def installed_version_for(pod_name) package_root = pod_lookup_map.dig(pod_name, :package_root) return nil unless package_root pkg_json = File.join(package_root, 'package.json') return nil unless File.exist?(pkg_json) JSON.parse(File.read(pkg_json))['version'] rescue JSON::ParserError, Errno::ENOENT nil end # ────────────────────────────────────────────────────────────────────── # Post-install configuration steps # ────────────────────────────────────────────────────────────────────── # Copies flavor tarballs into Pods//artifacts/ for all precompiled pods. # Called from post_install which runs reliably on every pod install, unlike # prepare_command which CocoaPods skips for "unchanged" pods. # # @param installer [Pod::Installer] The CocoaPods installer instance def ensure_artifacts(installer) return unless enabled? pods_root = installer.sandbox.root pod_lookup_map.each do |pod_name, info| next unless has_prebuilt_xcframework?(pod_name) product_name = info[:product_name] || pod_name build_output_dir = info[:build_output_dir] pod_dir = File.join(pods_root, pod_name) next unless File.directory?(pod_dir) artifacts_dir = File.join(pod_dir, ARTIFACTS_DIR_NAME) FileUtils.mkdir_p(artifacts_dir) # Copy both flavor tarballs ['debug', 'release'].each do |flavor| src = resolve_prebuilt_tarball(info, product_name, flavor, pod_name) dst = File.join(artifacts_dir, "#{product_name}-#{flavor}.tar.gz") FileUtils.cp(src, dst) if File.exist?(src) && !File.exist?(dst) end # Write initial .last_build_configuration if missing last_config_file = File.join(artifacts_dir, '.last_build_configuration') File.write(last_config_file, build_flavor) unless File.exist?(last_config_file) # Self-healing: extract xcframework if missing (CocoaPods cache issue) xcframework_dir = File.join(pod_dir, "#{product_name}.xcframework") unless File.directory?(xcframework_dir) tarball = resolve_prebuilt_tarball(info, product_name, build_flavor, pod_name) if File.exist?(tarball) Pod::UI.info "#{'[Expo-precompiled] '.blue}Extracting #{product_name}.xcframework (cache miss)" system("tar", "xzf", tarball, "-C", pod_dir) end end end end # Prevents swiftinterface verification failures when BUILD_LIBRARY_FOR_DISTRIBUTION=YES # is propagated from brownfield/framework user targets to pod targets by CocoaPods 1.16+. def disable_swift_interface_verification(installer) return unless prebuilt_react_active? Pod::UI.puts "[Expo] ".blue + "Disabling SWIFT_EMIT_MODULE_INTERFACE for pod targets (prebuilt React compatibility)" installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, _| xcconfig_path = pod_target.xcconfig_path(config_name) next unless File.exist?(xcconfig_path) content = File.read(xcconfig_path) next if content.include?('SWIFT_EMIT_MODULE_INTERFACE') File.open(xcconfig_path, 'a') do |f| f.puts 'SWIFT_EMIT_MODULE_INTERFACE = NO' end end end end # Configures use_frameworks! compatibility for prebuilt React.xcframework. # With use_frameworks!, the framework's modulemap resolves to DerivedData # paths the VFS doesn't cover. This method: # 1. Creates a non-framework modulemap so resolves through -isystem + VFS # 2. Patches framework modulemaps to remove `framework module React` (keep React_RCTAppDelegate) # 3. Injects -isystem and -fmodule-map-file into all pod and aggregate xcconfigs # # The modulemap is placed in Target Support Files/ rather than in the pod # directory itself, because React Native's replace-rncore-version.js script # phase deletes and re-extracts the entire React-Core-prebuilt/ directory at # build time when switching Debug↔Release configurations. def configure_use_frameworks(installer) return unless prebuilt_react_active? return if linkage(installer).nil? react_prebuilt_dir = File.join(installer.sandbox.root, 'React-Core-prebuilt') xcframework_path = File.join(react_prebuilt_dir, 'React.xcframework') return unless File.exist?(xcframework_path) target_support_dir = File.join(installer.sandbox.root, 'Target Support Files', 'React-Core-prebuilt') FileUtils.mkdir_p(target_support_dir) create_nonframework_modulemap(target_support_dir, installer.sandbox.root) patch_framework_modulemaps(xcframework_path) inject_isystem_flags(installer, target_support_dir) Pod::UI.puts "[Expo] ".blue + "Created non-framework React modulemap for use_frameworks! compatibility" end # TODO(ExpoModulesJSI-xcframework): Remove this method when ExpoModulesJSI.xcframework # is built and distributed separately. At that point, pods can depend on ExpoModulesJSI # directly and this header search path workaround won't be needed. # # Configures header search paths for prebuilt XCFrameworks in the post_install phase. # When using prebuilt modules, ExpoModulesJSI headers are bundled inside # ExpoModulesCore.xcframework. This method adds the necessary header search paths # to all pod targets so that `#import ` works correctly. # # @param installer [Pod::Installer] The CocoaPods installer instance def configure_header_search_paths(installer) return unless enabled? expo_core_xcframework = find_expo_modules_core_xcframework(installer) return unless expo_core_xcframework header_search_paths = collect_xcframework_header_paths(expo_core_xcframework) return if header_search_paths.empty? paths_string = header_search_paths.map { |p| "\"#{p}\"" }.join(' ') Pod::UI.info "#{'[Expo-precompiled] '.blue}Adding ExpoModulesJSI header search paths to all targets" # Modify xcconfig files directly - these take precedence over Xcode project settings target_support_files_dir = File.join(installer.sandbox.root, 'Target Support Files') Dir.glob(File.join(target_support_files_dir, '**', '*.xcconfig')).each do |xcconfig_path| update_xcconfig_header_search_paths(xcconfig_path, paths_string) end # Also update the main project targets' build settings directly installer.pods_project.targets.each do |target| target.build_configurations.each do |config| existing = config.build_settings['HEADER_SEARCH_PATHS'] || '$(inherited)' existing = existing.join(' ') if existing.is_a?(Array) unless existing.include?(paths_string) config.build_settings['HEADER_SEARCH_PATHS'] = "#{existing} #{paths_string}" end end end end # Configures the ReactCodegen target to properly handle prebuilt modules. # Removes source file references for prebuilt libraries from the compile sources phase # and adds a shell script build phase to clean up regenerated codegen files. def configure_codegen_for_prebuilt_modules(installer) return unless enabled? script_phase_name = '[Expo] Remove duplicate codegen output' react_codegen_target = installer.pods_project.targets.find { |target| target.name == 'ReactCodegen' } unless react_codegen_target Pod::UI.puts "[Expo] ".yellow + "ReactCodegen target not found in pods project" return end already_exists = react_codegen_target.build_phases.any? do |phase| phase.is_a?(Xcodeproj::Project::Object::PBXShellScriptBuildPhase) && phase.name == script_phase_name end codegen_exclusions = codegen_exclusion_list(installer.pod_targets) if codegen_exclusions.any? Pod::UI.puts "[Expo] ".blue + "Will remove codegen output for prebuilt libraries: #{codegen_exclusions.join(', ')}" remove_codegen_sources_from_compile_phase(react_codegen_target, codegen_exclusions) end unless already_exists Pod::UI.puts "[Expo] ".blue + "Adding '#{script_phase_name}' build phase to ReactCodegen" add_codegen_cleanup_script_phase(react_codegen_target, script_phase_name, codegen_exclusions) installer.pods_project.save end end # Removes implementation files from compile phases of pod targets that are bundled # inside prebuilt xcframeworks. This handles CDN pods (like SDWebImage) that don't # go through the store_podspec hook. # # @param installer [Pod::Installer] The CocoaPods installer instance def stub_bundled_pod_targets(installer) return unless enabled? bundled = all_bundled_frameworks return if bundled.empty? stubbed_lib_names = Set.new installer.pods_project.targets.each do |target| target_root = target.name.split('-').first.split('/').first next unless bundled.include?(target.name) || bundled.include?(target_root) compile_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXSourcesBuildPhase) } next unless compile_phase files_removed = 0 compile_phase.files.to_a.each do |build_file| file_ref = build_file.file_ref next unless file_ref path = file_ref.path.to_s if SOURCE_FILE_EXTENSIONS.any? { |ext| path.end_with?(ext) } compile_phase.files.delete(build_file) files_removed += 1 end end if files_removed > 0 stubbed_lib_names.add(target.name) Pod::UI.puts "#{'[Expo-precompiled] '.blue}Stubbed '#{target.name}': removed #{files_removed} source files from compile phase" end end if stubbed_lib_names.any? remove_linker_flags_for_stubbed_libs(installer, stubbed_lib_names) end installer.pods_project.save end # Returns the package_root for a given pod name, or nil if not found. def package_root_for(pod_name) pod_lookup_map[pod_name]&.dig(:package_root) end private # ────────────────────────────────────────────────────────────────────── # Helpers: common checks # ────────────────────────────────────────────────────────────────────── # Returns true when the prebuilt React.xcframework is in use. def prebuilt_react_active? ENV['RCT_USE_PREBUILT_RNCORE'] == '1' end # Builds a `file://` URI for a local filesystem path, percent-encoding # any non-ASCII characters so paths with Unicode segments (e.g. emoji or # accented characters) don't trip URI::File.build's RFC 3986 path # validation. def local_file_uri(path) URI::File.build(path: URI::DEFAULT_PARSER.escape(path)).to_s end # ────────────────────────────────────────────────────────────────────── # Helpers: use_frameworks! configuration # ────────────────────────────────────────────────────────────────────── # Creates a non-framework modulemap so resolves through -isystem + VFS. def create_nonframework_modulemap(target_support_dir, pods_root) modulemap_path = File.join(target_support_dir, 'React-use-frameworks.modulemap') umbrella_header = File.join(pods_root, 'React-Core-prebuilt', 'React.xcframework', 'Headers', 'React_Core', 'React_Core-umbrella.h') modulemap_content = <<~MODULEMAP module React { umbrella header "#{umbrella_header}" export * } MODULEMAP File.write(modulemap_path, modulemap_content) end # Patches framework modulemaps to remove `framework module React` but keep # `framework module React_RCTAppDelegate` (its umbrella uses quoted includes). def patch_framework_modulemaps(xcframework_path) Dir.glob(File.join(xcframework_path, '*/React.framework/Modules/module.modulemap')).each do |fw_modulemap| content = File.read(fw_modulemap) content.gsub!(FRAMEWORK_MODULE_REACT_REGEX, '') File.write(fw_modulemap, content) end shared_modulemap = File.join(xcframework_path, 'Modules', 'module.modulemap') if File.exist?(shared_modulemap) content = File.read(shared_modulemap) content.gsub!(FRAMEWORK_MODULE_REACT_REGEX, '') File.write(shared_modulemap, content) end end # Injects -fmodule-map-file and -isystem into all pod and aggregate xcconfigs. # Module builds don't inherit -I (HEADER_SEARCH_PATHS) but DO inherit -isystem. def inject_isystem_flags(installer, target_support_dir) modulemap_flag = "-fmodule-map-file=\"${PODS_ROOT}/Target\\ Support\\ Files/React-Core-prebuilt/React-use-frameworks.modulemap\"" extra_isystem = "-isystem \"${PODS_ROOT}/React-Core-prebuilt/React.xcframework/Headers\"" swift_modulemap = "-Xcc -fmodule-map-file=\"${PODS_ROOT}/Target\\ Support\\ Files/React-Core-prebuilt/React-use-frameworks.modulemap\"" swift_extra_isystem = "-Xcc -isystem -Xcc \"${PODS_ROOT}/React-Core-prebuilt/React.xcframework/Headers\"" skip_marker = 'React-use-frameworks.modulemap' # Patch pod target xcconfigs installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, _| xcconfig_path = pod_target.xcconfig_path(config_name) next unless File.exist?(xcconfig_path) content = File.read(xcconfig_path) next if content.include?(skip_marker) all_isystem_paths = extract_isystem_paths(content) isystem_flags = all_isystem_paths.map { |p| "-isystem \"#{p}\"" }.join(' ') + " #{extra_isystem}" swift_isystem = all_isystem_paths.map { |p| "-Xcc -isystem -Xcc \"#{p}\"" }.join(' ') + " #{swift_extra_isystem}" inject_flags_into_xcconfig(content, isystem_flags, modulemap_flag, swift_isystem, swift_modulemap) File.write(xcconfig_path, content) end end # Patch aggregate target xcconfigs (these flow to the app target) installer.aggregate_targets.each do |agg_target| agg_target.user_build_configurations.each_key do |config_name| xcconfig_path = agg_target.xcconfig_path(config_name) next unless File.exist?(xcconfig_path) content = File.read(xcconfig_path) next if content.include?(skip_marker) inject_flags_into_xcconfig(content, extra_isystem, modulemap_flag, swift_extra_isystem, swift_modulemap) File.write(xcconfig_path, content) end end end # Extracts header and framework search paths from xcconfig content for -isystem conversion. def extract_isystem_paths(content) paths = [] if content =~ /HEADER_SEARCH_PATHS\s*=\s*(.*)/ paths += $1.scan(/"([^"]+)"/).flatten end if content =~ /FRAMEWORK_SEARCH_PATHS\s*=\s*(.*)/ $1.scan(/"([^"]+)"/).flatten.each do |fw_dir| basename = fw_dir.split('/').last paths << "#{fw_dir}/#{basename}.framework/Headers" end end paths end # Injects C and Swift flags into xcconfig content (mutates in place). def inject_flags_into_xcconfig(content, c_isystem, c_modulemap, swift_isystem, swift_modulemap) if content.include?('OTHER_CFLAGS') content.gsub!(/(OTHER_CFLAGS\s*=\s*)(.*)/) { "#{$1}#{$2} #{c_isystem} #{c_modulemap}" } end if content.include?('OTHER_SWIFT_FLAGS') content.gsub!(/(OTHER_SWIFT_FLAGS\s*=\s*)(.*)/) { "#{$1}#{$2} #{swift_isystem} #{swift_modulemap}" } end end # ────────────────────────────────────────────────────────────────────── # Helpers: spec patching # ────────────────────────────────────────────────────────────────────── # Clears platform-specific source attributes from a spec JSON hash. def clear_platform_source_attributes(spec_json) APPLE_PLATFORMS.each do |platform| next unless spec_json[platform].is_a?(Hash) %w[source_files exclude_files private_header_files header_dir header_mappings_dir compiler_flags vendored_frameworks].each do |attr| spec_json[platform].delete(attr) end spec_json.delete(platform) if spec_json[platform].empty? end end # Replaces source_files with header-only patterns in a spec or subspec JSON hash. def stub_source_files_to_headers(json) if json['source_files'] json['source_files'] = header_only_pattern(json['source_files']) end APPLE_PLATFORMS.each do |platform| next unless json[platform].is_a?(Hash) && json[platform]['source_files'] json[platform]['source_files'] = header_only_pattern(json[platform]['source_files']) end end # Converts source_files patterns to header-only patterns. # # @param patterns [String, Array] Original source_files patterns # @return [Array] Patterns matching only header files def header_only_pattern(patterns) patterns = [patterns] unless patterns.is_a?(Array) patterns.map do |p| p.gsub(/\.\{[^}]+\}/, '.{h,hpp}') .gsub(/\*\.(m|mm|swift|c|cpp)$/, '*.{h,hpp}') end.uniq end # Strips dependencies on SPM packages bundled in the xcframework from a spec JSON hash. # # @param spec_json [Hash] The podspec as a parsed JSON hash (modified in place) # @param pod_info [Hash] Package info from spm.config.json lookup # @param pod_name [String] Pod name for logging def strip_bundled_deps_from_json(spec_json, pod_info, pod_name) bundled = bundled_framework_set(pod_info) return if bundled.empty? || !spec_json['dependencies'].is_a?(Hash) strip_matching_dependencies(spec_json['dependencies'], bundled, pod_name) spec_json.delete('dependencies') if spec_json['dependencies'].empty? end # Strips dependencies on SPM packages bundled in the xcframework from a live spec object. # # NOTE: Pod::Specification doesn't expose a public API to remove dependencies, # so we modify the attributes_hash directly. # # @param spec [Pod::Specification] The podspec to modify # @param pod_info [Hash] Package info from spm.config.json lookup def strip_bundled_deps_from_spec(spec, pod_info) bundled = bundled_framework_set(pod_info) return if bundled.empty? deps = spec.attributes_hash['dependencies'] return unless deps.is_a?(Hash) strip_matching_dependencies(deps, bundled, spec.name) end # Returns the set of bundled framework names from pod_info. def bundled_framework_set(pod_info) (pod_info[:spm_dependency_frameworks] || []).to_set end # Deletes dependencies whose name (or root name) matches the bundled set. def strip_matching_dependencies(deps_hash, bundled, pod_name) deps_hash.delete_if do |dep_name, _| root_name = dep_name.split('/').first if bundled.include?(dep_name) || bundled.include?(root_name) Pod::UI.puts "#{'[Expo-precompiled] '.blue}Stripping bundled dependency '#{dep_name}' from #{pod_name}" true else false end end end # Returns vendored_frameworks paths for a prebuilt pod: the product's own # xcframework plus any shared SPM deps this pod owns (first to claim wins; # non-owners get FRAMEWORK_SEARCH_PATHS instead). Shared dep entries are # symlinks staged by `ensure_shared_spm_deps` inside the owner's pod dir. def build_vendored_paths(product_name, pod_info, pod_name) @claimed_vendored_frameworks ||= Set.new @framework_owner_map ||= {} paths = ["#{product_name}.xcframework"] @claimed_vendored_frameworks.add(product_name) @framework_owner_map[product_name] = pod_name (pod_info[:spm_dependency_frameworks] || []).each do |dep_name| owner = (@framework_owner_map[dep_name] ||= pod_name) if owner == pod_name paths << "#{dep_name}.xcframework" else Pod::UI.puts "#{'[Expo-precompiled] '.blue}Skipping #{dep_name}.xcframework from #{pod_name} — already vendored by #{owner}" end log_spm_dependency(pod_name, dep_name) end paths end # FRAMEWORK_SEARCH_PATHS entries for shared SPM deps claimed by another pod. # CocoaPods slices each xcframework into `${PODS_XCFRAMEWORKS_BUILD_DIR}//`, # which is where the linker resolves the framework. def framework_search_paths_for_skipped_deps(pod_name, pod_info) @framework_owner_map ||= {} owners = (pod_info[:spm_dependency_frameworks] || []).filter_map do |dep_name| owner = @framework_owner_map[dep_name] owner if owner && owner != pod_name end.uniq owners.flat_map { |owner| [%("${PODS_XCFRAMEWORKS_BUILD_DIR}/#{owner}"), %("${PODS_ROOT}/#{owner}")] } end # ────────────────────────────────────────────────────────────────────── # Helpers: script phases # ────────────────────────────────────────────────────────────────────── # Builds the switch and dSYM script phases as JSON-compatible hashes. # # @param spec_name [String] The pod name # @param product_name [String] The product/module name # @param pod_info [Hash] Package info from spm.config.json lookup # @return [Array] Array of script phase hashes with string keys def build_script_phases_json(spec_name, product_name, pod_info) project_root = Pod::Config.instance.installation_root scripts_dir = __dir__ switch_script_rel = Pathname.new(File.join(scripts_dir, 'replace-xcframework.js')).relative_path_from(project_root).to_s dsym_script_rel = Pathname.new(File.join(scripts_dir, 'resolve-dsym-sourcemaps.js')).relative_path_from(project_root).to_s package_root_rel = Pathname.new(pod_info[:package_root]).relative_path_from(project_root).to_s pods_parent = "$PODS_ROOT/.." switch_script_path = "#{pods_parent}/#{switch_script_rel}" dsym_script_path = "#{pods_parent}/#{dsym_script_rel}" xcframeworks_dir_var = "$PODS_ROOT/#{spec_name}" package_root_var = "#{pods_parent}/#{package_root_rel}" dsym_stamp = "$(DERIVED_FILE_DIR)/expo-dsym-resolve-#{product_name}-$(CONFIGURATION).stamp" shared_deps = shared_dep_switch_args(spec_name, pod_info) switch_phase = { 'name' => "[Expo] Switch #{spec_name} XCFramework for build configuration", 'execution_position' => 'before_compile', 'input_files' => ["#{pods_parent}/#{switch_script_rel}"], 'script' => xcframework_switch_script(product_name, xcframeworks_dir_var, switch_script_path, shared_deps), } if Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.13.0') switch_phase['always_out_of_date'] = '1' end dsym_phase = { 'name' => "[Expo] Resolve #{spec_name} dSYM source maps", 'execution_position' => 'before_compile', 'input_files' => ["#{pods_parent}/#{dsym_script_rel}"], 'output_files' => [dsym_stamp], 'script' => dsym_resolve_script(product_name, xcframeworks_dir_var, dsym_script_path, pod_info[:npm_package], package_root_var), } [switch_phase, dsym_phase] end # Adds script phases to the podspec for xcframework switching and dSYM resolution. # Converts string keys to symbols (CocoaPods inline API uses symbols). def add_script_phases(spec, product_name, pod_info) json_phases = build_script_phases_json(spec.name, product_name, pod_info) spec.script_phases = json_phases.map do |phase| phase.each_with_object({}) do |(k, v), h| h[k.to_sym] = k == 'execution_position' ? v.to_sym : v end end end # Generates the prepare_command shell script. def prepare_command_script(product_name, build_output_dir) <<~SH # Self-healing: extract xcframework from tarball if CocoaPods cache was stale/empty if [ ! -d "#{product_name}.xcframework" ]; then TARBALL="#{build_output_dir}/#{build_flavor}/xcframeworks/#{product_name}.tar.gz" if [ -f "$TARBALL" ]; then echo "[Expo XCFramework] #{product_name}: Extracting xcframework from build output (cache miss)" tar xzf "$TARBALL" fi fi SH end # Shell script for the xcframework switch phase. With no shared deps the # script short-circuits in shell when the per-pod state file matches; with # shared deps Node is always invoked so each dep's symlink can be repointed # (it has its own per-dep state file inside replace-xcframework.js). def xcframework_switch_script(product_name, xcframeworks_dir, script_path, shared_deps = []) config_detect = <<~SH.chomp CONFIG="release" if echo "$GCC_PREPROCESSOR_DEFINITIONS" | grep -q "DEBUG=1"; then CONFIG="debug" fi SH if shared_deps.empty? <<~SH # Auto-generated by expo-modules-autolinking #{config_detect} LAST_CONFIG_FILE="#{xcframeworks_dir}/artifacts/.last_build_configuration" if [ -f "$LAST_CONFIG_FILE" ] && [ "$(cat "$LAST_CONFIG_FILE")" = "$CONFIG" ]; then exit 0 fi . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" "$NODE_BINARY" "#{script_path}" -c "$CONFIG" -m "#{product_name}" -x "#{xcframeworks_dir}" SH else shared_args = shared_deps.map { |arg| " #{arg}" }.join(" \\\n") <<~SH # Auto-generated by expo-modules-autolinking #{config_detect} . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" "$NODE_BINARY" "#{script_path}" \\ -c "$CONFIG" \\ -m "#{product_name}" \\ -x "#{xcframeworks_dir}" \\ #{shared_args} SH end end # '--shared ":"' tokens for shared SPM deps this pod owns. # Non-owners reach the framework via FRAMEWORK_SEARCH_PATHS at link time. def shared_dep_switch_args(pod_name, pod_info) return [] unless pod_info && pod_info[:spm_dependency_frameworks] @framework_owner_map ||= {} pod_info[:spm_dependency_frameworks].filter_map do |dep_name| next nil unless @framework_owner_map[dep_name] == pod_name source_base = shared_spm_dep_source_base(dep_name, pod_info) next nil unless source_base %(--shared "#{dep_name}:#{source_base.gsub(/[\\"$`]/) { |c| "\\#{c}" }}") end end # Returns the shell script for the dSYM source map resolution phase. def dsym_resolve_script(product_name, xcframeworks_dir, script_path, npm_package, package_root) <<~SH # Resolve dSYM source path mappings for prebuilt xcframeworks # This script is auto-generated by expo-modules-autolinking # # Writes UUID plists into dSYMs so lldb can remap # /expo-src/... paths back to local package paths. STAMP_FILE="$DERIVED_FILE_DIR/expo-dsym-resolve-#{product_name}-$CONFIGURATION.stamp" # Find dSYMs inside the xcframework (all slices) XCFW_PATH="#{xcframeworks_dir}/#{product_name}.xcframework" if [ -d "$XCFW_PATH" ]; then FOUND_DSYM=0 for DSYM_DIR in "$XCFW_PATH"/*/dSYMs; do if [ -d "$DSYM_DIR/#{product_name}.framework.dSYM" ]; then if [ $FOUND_DSYM -eq 0 ]; then . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" FOUND_DSYM=1 fi "$NODE_BINARY" "#{script_path}" \\ -d "$DSYM_DIR" \\ -m "#{product_name}" \\ -n "#{npm_package}" \\ -r "#{package_root}" fi done fi # Touch stamp file for Xcode dependency tracking mkdir -p "$(dirname "$STAMP_FILE")" touch "$STAMP_FILE" SH end # ────────────────────────────────────────────────────────────────────── # Helpers: pod lookup map # ────────────────────────────────────────────────────────────────────── # Builds and caches a map from pod names to package information. # Scans all spm.config.json files in: # - packages/*/spm.config.json (internal Expo packages) # - external-configs/ios/*/spm.config.json (3rd-party packages, bundled with autolinking) # # @return [Hash] Map of podName -> { type:, npm_package:, podspec_dir:, build_output_dir:, ... } def pod_lookup_map return @pod_lookup_map if @pod_lookup_map @pod_lookup_map = {} repo_root = find_repo_root if repo_root scan_spm_configs(repo_root) else # Standalone project: discover spm.config.json from node_modules instead of packages/. # Prebuilds are resolved from EXPO_PRECOMPILED_MODULES_PATH if set, # otherwise from prebuilds/ bundled inside each package directory. project_root = File.dirname(Dir.pwd) # Dir.pwd is ios/ during pod install scan_node_modules_configs(project_root) end @pod_lookup_map end # Scans all spm.config.json files and populates @pod_lookup_map. # Used in monorepo context where find_repo_root succeeds. def scan_spm_configs(repo_root) # Internal Expo packages: packages/*/spm.config.json Dir.glob(File.join(repo_root, 'packages', '*', 'spm.config.json')).each do |config_path| process_spm_config(config_path, :internal, repo_root) end # External 3rd-party packages: bundled in external-configs/ios/ scan_external_configs(repo_root) end # Locates spm.config.json for internal Expo modules in standalone projects # (no monorepo root). Uses resolved podspec dirs so non-flat layouts work. def scan_node_modules_configs(project_root) resolved_modules.each do |mod| podspec_dir = (mod['pods'] || []).map { |pod| pod['podspecDir'] }.compact.first next unless podspec_dir config_path = spm_config_path_for_podspec_dir(podspec_dir) next unless config_path process_spm_config(config_path, :internal, project_root) end scan_external_configs(project_root) end # Resolves spm.config.json from an Expo autolinking podspecDir. Most modules # report `/ios`, while packages with root podspecs report ``. def spm_config_path_for_podspec_dir(podspec_dir) [podspec_dir, File.dirname(podspec_dir)].uniq.each do |package_dir| config_path = File.join(package_dir, 'spm.config.json') return config_path if File.exist?(config_path) end nil end # Scans spm.config.json files from external-configs/ios/ for 3rd-party packages # (e.g. react-native-screens, react-native-svg) that don't ship their own spm.config.json. # Shared by both monorepo and standalone project paths. # # @param effective_root [String] The project or repo root (used to compute the default build output dir) def scan_external_configs(effective_root) external_configs_dir = File.join(__dir__, '..', '..', 'external-configs', 'ios') return unless File.directory?(external_configs_dir) # Non-scoped: external-configs/ios/*/spm.config.json Dir.glob(File.join(external_configs_dir, '*', 'spm.config.json')).each do |config_path| npm_package = File.basename(File.dirname(config_path)) process_external_config(config_path, npm_package, effective_root) end # Scoped: external-configs/ios/@scope/*/spm.config.json Dir.glob(File.join(external_configs_dir, '@*', '*', 'spm.config.json')).each do |config_path| rel = config_path.sub("#{external_configs_dir}/", '') npm_package = File.dirname(rel) # e.g. "@shopify/react-native-skia" process_external_config(config_path, npm_package, effective_root) end end # Processes a single external spm.config.json for a 3rd-party package. def process_external_config(config_path, npm_package, effective_root) config = JSON.parse(File.read(config_path)) products = config['products'] || [] # Resolve via rncli autolinking so we use real node resolution (handles pnpm, # yarn resolutions/PnP, aliased specifiers). Skip if the package isn't installed. dep = react_native_config.dig('dependencies', npm_package) return unless dep package_root = dep['root'] pkg_version = dep.dig('platforms', 'ios', 'version') # codegenConfig.name isn't surfaced by rncli; read it from package.json. installed_codegen_name = nil pkg_json_path = File.join(package_root, 'package.json') if File.exist?(pkg_json_path) pkg_json = JSON.parse(File.read(pkg_json_path)) installed_codegen_name = pkg_json.dig('codegenConfig', 'name') pkg_version ||= pkg_json['version'] end base_dir = custom_modules_path || File.join(effective_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR) # Compute versioned output path for 3rd-party packages: # //output/// version_prefix = version_prefix_for_external_package(pkg_version) products.each do |product| pod_name = product['podName'] next unless pod_name product_name = product['name'] || pod_name codegen_name = installed_codegen_name || product['codegenName'] # Use versioned path if versions are available, otherwise fall back to flat path if version_prefix build_output_dir = File.join(base_dir, npm_package, 'output', version_prefix) else build_output_dir = File.join(base_dir, npm_package, 'output') end # Fallback: check for prebuilds bundled inside the package directory (shipped in npm) # Try versioned path first, then flat path for backward compatibility if !File.directory?(File.join(build_output_dir, build_flavor, 'xcframeworks')) bundled_versioned_dir = version_prefix ? File.join(package_root, 'prebuilds', 'output', version_prefix) : nil bundled_flat_dir = File.join(package_root, 'prebuilds', 'output') if bundled_versioned_dir && File.directory?(bundled_versioned_dir) build_output_dir = bundled_versioned_dir elsif File.directory?(bundled_flat_dir) build_output_dir = bundled_flat_dir end end targets = (product['targets'] || []) .select { |t| t['type'] != 'framework' && !t['path']&.start_with?('.build/') } .map { |t| { name: t['name'], path: t['path'] } } spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact spm_dependency_versions = spm_package_versions(product['spmPackages']) @pod_lookup_map[pod_name] = { type: :external, npm_package: npm_package, package_root: package_root, podspec_dir: package_root, build_output_dir: build_output_dir, codegen_name: codegen_name, product_name: product_name, targets: targets, spm_dependency_frameworks: spm_dependency_frameworks, spm_dependency_versions: spm_dependency_versions, prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']), autolink_when: product['autolinkWhen'] } end rescue JSON::ParserError, StandardError => e Pod::UI.warn "[Expo-precompiled] Failed to read external config at #{config_path}: #{e.message}" end # Returns { productName => versionString } from a spm.config.json spmPackages array. def spm_package_versions(spm_packages) (spm_packages || []).each_with_object({}) do |pkg, h| version = pkg['version'].is_a?(Hash) ? pkg['version']['exact'] : pkg['version'] h[pkg['productName']] = version if pkg['productName'] && version end end # Processes a single spm.config.json file and adds entries to @pod_lookup_map. def process_spm_config(config_path, type, repo_root) config = JSON.parse(File.read(config_path)) products = config['products'] || [] package_dir = File.dirname(config_path) npm_package = resolve_npm_package_name(package_dir, type, repo_root) products.each do |product| pod_name = product['podName'] next unless pod_name @pod_lookup_map[pod_name] = build_pod_info(product, pod_name, npm_package, package_dir, type, repo_root) end rescue JSON::ParserError, StandardError => e Pod::UI.warn "[Expo-precompiled] Failed to read spm.config.json at #{config_path}: #{e.message}" end # Resolves the npm package name from a package directory. # External scoped packages derive it from the path; internal packages read package.json. def resolve_npm_package_name(package_dir, type, repo_root) if type == :external external_dir = File.join(repo_root, 'packages', 'external') package_dir.sub("#{external_dir}/", '') else package_json_path = File.join(package_dir, 'package.json') if File.exist?(package_json_path) JSON.parse(File.read(package_json_path))['name'] || File.basename(package_dir) else File.basename(package_dir) end end end # Builds a pod info hash for a single product from spm.config.json. def build_pod_info(product, pod_name, npm_package, package_dir, type, repo_root) product_name = product['name'] || pod_name codegen_name = resolve_codegen_name(product, pod_name, npm_package, type, repo_root) base_dir = custom_modules_path || File.join(repo_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR) build_output_dir = File.join(base_dir, npm_package, 'output') # Fallback: check for prebuilds bundled inside the package directory (shipped in npm) bundled_output_dir = File.join(package_dir, 'prebuilds', 'output') if !File.directory?(build_output_dir) && File.directory?(bundled_output_dir) build_output_dir = bundled_output_dir end package_root, podspec_dir = resolve_package_paths(pod_name, package_dir, npm_package, type, repo_root) targets = (product['targets'] || []) .select { |t| t['type'] != 'framework' && !t['path']&.start_with?('.build/') } .map { |t| { name: t['name'], path: t['path'] } } spm_dependency_frameworks = (product['spmPackages'] || []).map { |pkg| pkg['productName'] }.compact spm_dependency_versions = spm_package_versions(product['spmPackages']) { type: type, npm_package: npm_package, package_root: package_root, podspec_dir: podspec_dir, build_output_dir: build_output_dir, codegen_name: codegen_name, product_name: product_name, targets: targets, spm_dependency_frameworks: spm_dependency_frameworks, spm_dependency_versions: spm_dependency_versions, prebuilt_dependency_pods: prebuilt_dependency_pods(product['externalDependencies']), autolink_when: product['autolinkWhen'] } end # Returns the product names of dependencies whose prebuilt availability must # match this product's availability. Both slash-form package/product strings # (e.g. "expo-modules-core/ExpoModulesCore") and bare product names (e.g. # "RNWorklets") are surfaced. Which of them are actually precompiled-managed # pods is decided later by @pod_lookup_map membership in the resolver, so # runtime deps like React/Hermes are surfaced here but ignored there. def prebuilt_dependency_pods(external_dependencies) (external_dependencies || []).filter_map do |dep| next unless dep.is_a?(String) parts = dep.split('/') product_name = if parts.length == 1 parts[0] # bare, e.g. "RNWorklets" elsif parts[0].start_with?('@') parts[2] # @scope/package/Product else parts[1] # package/Product end next if CUSTOM_XCFRAMEWORK_DEPENDENCIES.include?(product_name) product_name end.uniq end # Resolves the codegen module name. For external packages, prefers codegenConfig.name # from the installed package.json over spm.config.json's codegenName. def resolve_codegen_name(product, pod_name, npm_package, type, repo_root) codegen_name = product['codegenName'] return codegen_name unless type == :external && codegen_name ext_pkg_json = File.join(repo_root, 'node_modules', npm_package, 'package.json') return codegen_name unless File.exist?(ext_pkg_json) begin rn_codegen_name = JSON.parse(File.read(ext_pkg_json)).dig('codegenConfig', 'name') if rn_codegen_name && rn_codegen_name != codegen_name Pod::UI.info "#{'[Expo-precompiled] '.blue}#{pod_name}: using codegenConfig.name '#{rn_codegen_name}' instead of '#{codegen_name}'" return rn_codegen_name end rescue JSON::ParserError # Fall back to spm.config.json value end codegen_name end # Resolves the package_root and podspec_dir for a pod. # @return [Array] [package_root, podspec_dir] def resolve_package_paths(pod_name, package_dir, npm_package, type, repo_root) if type == :internal package_root = package_dir ios_podspec = File.join(package_root, 'ios', "#{pod_name}.podspec") root_podspec = File.join(package_root, "#{pod_name}.podspec") podspec_dir = if File.exist?(ios_podspec) File.join(package_root, 'ios') elsif File.exist?(root_podspec) package_root else File.join(package_root, 'ios') end [package_root, podspec_dir] else package_root = resolve_external_package_root(npm_package, repo_root) [package_root, package_root] end end # Resolves the package root for an external (3rd-party) npm package. # Tries multiple node_modules locations to support pnpm/yarn workspaces. def resolve_external_package_root(npm_package, repo_root) # Try repo root node_modules first (works for npm/yarn classic) candidate = File.join(repo_root, 'node_modules', npm_package) return candidate if File.exist?(candidate) # Try resolving from the Podfile directory (works for pnpm workspaces # where packages are symlinked in the app's node_modules) podfile_dir = Dir.pwd project_root = File.dirname(podfile_dir) candidate = File.join(project_root, 'node_modules', npm_package) return candidate if File.exist?(candidate) # Fallback to original path File.join(repo_root, 'node_modules', npm_package) end # Finds the repository root by walking up from the current directory. def find_repo_root(start_dir = nil) current_dir = start_dir || Dir.pwd loop do return current_dir if File.exist?(File.join(current_dir, 'packages', 'expo-modules-core', 'spm.config.json')) parent = File.dirname(current_dir) break if parent == current_dir current_dir = parent end nil end # Invokes `expo-modules-autolinking --json` and parses the output. # Used by `react_native_config` and `resolved_modules` above. def invoke_autolinking(subcommand, platform:) args = ['node', '--no-warnings', '--eval', "require('expo/bin/autolinking')", 'expo-modules-autolinking', subcommand, '--platform', platform, '--json'] JSON.parse(IO.popen(args, &:read)) rescue => error raise "Failed to invoke `expo-modules-autolinking #{subcommand}`: #{error}" end # ────────────────────────────────────────────────────────────────────── # Helpers: bundled frameworks # ────────────────────────────────────────────────────────────────────── # Returns the set of all SPM dependency framework names that are bundled inside # prebuilt XCFrameworks. These pods should be stubbed (header-only) when they appear # as source pods, to avoid duplicate symbols with the xcframework. # # @return [Set] Framework names bundled across all prebuilt pods def all_bundled_frameworks @all_bundled_frameworks ||= begin bundled = Set.new pod_lookup_map.each do |pod_name, info| next unless resolve_prebuilt_info(pod_name) (info[:spm_dependency_frameworks] || []).each { |f| bundled.add(f) } end Pod::UI.puts "#{'[Expo-precompiled] '.blue}Bundled SPM frameworks: #{bundled.to_a.join(', ')}" if bundled.any? bundled end end # ────────────────────────────────────────────────────────────────────── # Helpers: external prebuilt pods # ────────────────────────────────────────────────────────────────────── # Returns external (3rd-party) pods that have prebuilt XCFrameworks. # # @param project_directory [Pathname] The project root for computing relative paths # @return [Array] Array of {:pod_name, :podspec_path} for external prebuilt pods def external_prebuilt_pods(project_directory) return [] unless enabled? results = [] pod_lookup_map.each do |pod_name, info| next unless info[:type] == :external unless has_prebuilt_xcframework?(pod_name) product_name = info[:product_name] || pod_name expected = File.join(info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz") Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{"#{pod_name}: prebuilt xcframework unavailable; building from source".yellow}" Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{gray(" Expected tarball: #{expected}")}" next end podspec_file = File.join(info[:podspec_dir], "#{pod_name}.podspec") next unless File.exist?(podspec_file) patched_podspec = generate_prepatched_podspec(pod_name, podspec_file, info) next unless patched_podspec podspec_rel = Pathname.new(patched_podspec).relative_path_from(project_directory).to_s results << { pod_name: pod_name, podspec_path: podspec_rel } end results end # Generates a pre-patched podspec JSON file for an external pod. def generate_prepatched_podspec(pod_name, podspec_file, info) begin spec = Pod::Specification.from_file(podspec_file) rescue => e Pod::UI.warn "[Expo-precompiled] Failed to evaluate podspec for #{pod_name}: #{e.message}" return nil end patched_spec = patch_spec_for_prebuilt(spec) return nil if patched_spec.equal?(spec) spec_json = JSON.parse(patched_spec.to_pretty_json) spec_json.delete('dependencies') json_path = podspec_file.sub(/\.podspec$/, '.podspec.json') File.write(json_path, JSON.pretty_generate(spec_json)) json_path end # ────────────────────────────────────────────────────────────────────── # Helpers: version resolution for 3rd-party prebuild versioning # ────────────────────────────────────────────────────────────────────── # RN package path, as resolved by rncli (handles pnpm workspaces correctly). def react_native_path react_native_config['reactNativePath'] end def react_native_version @react_native_version ||= begin rn_pkg = react_native_path && File.join(react_native_path, 'package.json') rn_pkg && File.exist?(rn_pkg) ? JSON.parse(File.read(rn_pkg))['version'] : nil end end # Returns the Hermes version. Hermes v1 is the default; classic Hermes is opt-out. # Mirrors the TypeScript resolution logic in tools/src/prebuilds/Utils.ts. def hermes_version @hermes_version ||= begin rn_path = react_native_path if rn_path # Matches hermes-engine.podspec polarity: V1 is the default, classic # is selected only when the consuming app explicitly sets the env var to "0". is_v1 = ENV['RCT_HERMES_V1_ENABLED'] != '0' props_path = File.join(rn_path, 'sdks', 'hermes-engine', 'version.properties') version = File.exist?(props_path) ? parse_version_properties(props_path)[is_v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME'] : nil # Fallback to tag files unless version tag_path = File.join(rn_path, 'sdks', is_v1 ? '.hermesv1version' : '.hermesversion') version = File.read(tag_path).strip if File.exist?(tag_path) end # Normalize: strip "hermes-" prefix and "v" prefix version&.gsub(/^hermes-?/i, '')&.gsub(/^v/i, '')&.strip end end end # Parses a Java-style .properties file into a Hash. def parse_version_properties(file_path) props = {} File.readlines(file_path).each do |line| trimmed = line.strip next if trimmed.empty? || trimmed.start_with?('#') key, value = trimmed.split('=', 2) props[key.strip] = value.strip if key && value end props end # Returns the version prefix path for a 3rd-party package. # Format: "//" # Returns nil if versions cannot be resolved. def version_prefix_for_external_package(package_version) rn_ver = react_native_version hermes_ver = hermes_version return nil unless package_version && rn_ver && hermes_ver File.join(package_version, rn_ver, hermes_ver) end # ────────────────────────────────────────────────────────────────────── # Helpers: prebuilt info resolution # ────────────────────────────────────────────────────────────────────── # Resolves prebuilt xcframework info for a pod. # @return [Array, nil] [pod_info, product_name, tarball_path] or nil def resolve_prebuilt_info(pod_name) resolution = resolve_prebuilt_status(pod_name) resolution[:available] ? resolution[:resolved] : nil end # Resolves only this pod's own prebuilt artifact without checking parent dependencies. # @return [Hash] Availability information with :available, :resolved, :reason, and :path. def resolve_own_prebuilt_info(pod_name) pod_info = pod_lookup_map[pod_name] return { available: false, reason: :missing_config } unless pod_info product_name = pod_info[:product_name] || pod_name tarball = resolve_prebuilt_tarball(pod_info, product_name, build_flavor, pod_name) return { available: false, reason: :missing_tarball, path: tarball } unless File.exist?(tarball) return { available: false, reason: :missing_platform_slice, path: tarball } unless xcframework_supports_target_platform?(tarball) { available: true, resolved: [pod_info, product_name, tarball] } end # A pod may use a prebuilt xcframework only when its own prebuilt artifact # exists and every local Expo dependency also uses prebuilt. def resolve_prebuilt_status(pod_name, visiting = Set.new) return _resolve_prebuilt_status_uncached(pod_name, visiting) unless visiting.empty? @status_cache[pod_name] ||= _resolve_prebuilt_status_uncached(pod_name, visiting) end def _resolve_prebuilt_status_uncached(pod_name, visiting) return { available: false, reason: :build_from_source } if build_from_source?(pod_name) return { available: true } if visiting.include?(pod_name) own_resolution = resolve_own_prebuilt_info(pod_name) return own_resolution unless own_resolution[:available] pod_info = own_resolution[:resolved][0] next_visiting = visiting.dup.add(pod_name) (pod_info[:prebuilt_dependency_pods] || []).each do |dep_name| # Only precompiled-managed pods participate in availability propagation. # Runtime deps like React/Hermes are surfaced by prebuilt_dependency_pods # but are not in the map, so they are ignored here. next unless pod_lookup_map.key?(dep_name) dep_resolution = resolve_prebuilt_status(dep_name, next_visiting) next if dep_resolution[:available] return { available: false, reason: :dependency_unavailable, dependency: dep_name, dependency_reason: dep_resolution[:reason], dependency_path: dep_resolution[:path] } end own_resolution end # Candidate parent dirs (each holds /.xcframework subtrees) for # a shared SPM dep. Ordered: EXPO_PRECOMPILED_MODULES_PATH override, monorepo # .spm-deps, then the consumer-side npm-bundled location. def shared_spm_dep_source_base_candidates(dep_name, pod_info) candidates = [] candidates << File.join(custom_modules_path, SHARED_SPM_DEPS_SOURCE_DIR, dep_name) if custom_modules_path candidates << File.join(memoized_repo_root, 'packages', 'precompile', PRECOMPILE_BUILD_DIR, SHARED_SPM_DEPS_SOURCE_DIR, dep_name) if memoized_repo_root candidates << File.join(pod_info[:package_root], BUNDLED_SHARED_SPM_DEPS_SUBPATH, dep_name) if pod_info && pod_info[:package_root] candidates end # First candidate base that has at least one flavor on disk (used to build switch-script source_base args). def shared_spm_dep_source_base(dep_name, pod_info) shared_spm_dep_source_base_candidates(dep_name, pod_info).find do |base| %w[debug release].any? { |f| File.directory?(File.join(base, f, "#{dep_name}.xcframework")) } end end # First candidate that has the requested flavor on disk (walks all candidates so a partial monorepo doesn't shadow a complete npm bundle). def shared_spm_dep_xcframework_path(dep_name, pod_info, flavor) shared_spm_dep_source_base_candidates(dep_name, pod_info).each do |base| path = File.join(base, flavor, "#{dep_name}.xcframework") return path if File.directory?(path) end nil end def memoized_repo_root return @repo_root if @memoized_repo_root_set begin @repo_root = find_repo_root ensure @memoized_repo_root_set = true end @repo_root end def resolve_prebuilt_tarball(pod_info, product_name, flavor, pod_name = nil) tarball = File.join(pod_info[:build_output_dir], flavor, 'xcframeworks', "#{product_name}.tar.gz") return tarball if File.exist?(tarball) base_url = external_modules_base_url return tarball unless pod_info[:type] == :external && base_url output_prefix = File.join(pod_info[:npm_package], 'output') idx = pod_info[:build_output_dir].rindex(output_prefix) relative_path = [ (idx ? pod_info[:build_output_dir][idx..] : output_prefix), flavor, 'xcframeworks', "#{product_name}.tar.gz" ].join('/') remote_url = "#{base_url.chomp('/')}/#{relative_path}" remote_tarball = File.join(remote_precompiled_artifacts_dir, relative_path) return remote_tarball if File.exist?(remote_tarball) return remote_tarball if failed_remote_downloads.include?(remote_url) download_remote_tarball(remote_url, remote_tarball, pod_name || product_name, flavor) remote_tarball end def normalize_xcframework_platform(platform) name = platform.respond_to?(:name) ? platform.name : platform name = name.string_name if name.respond_to?(:string_name) normalized = name.to_s.downcase normalized == 'osx' ? 'macos' : normalized end def xcframework_supports_target_platform?(path) return true unless @target_platform @xcframework_slice_cache ||= {} cache_key = [path, @target_platform] return @xcframework_slice_cache[cache_key] if @xcframework_slice_cache.key?(cache_key) @xcframework_slice_cache[cache_key] = begin info_plists = File.directory?(path) ? [read_plist(File.join(path, 'Info.plist'))] : read_xcframework_info_plists_from_tarball(path) info_plists.any? && info_plists.all? { |info_plist| info_plist_supports_target_platform?(info_plist) } end end def info_plist_supports_target_platform?(info_plist) return false unless info_plist available_libraries = info_plist['AvailableLibraries'] return false unless available_libraries.is_a?(Array) available_libraries.any? do |library| normalize_xcframework_platform(library['SupportedPlatform']) == @target_platform end end def read_xcframework_info_plists_from_tarball(tarball) entries_output, status = Open3.capture2e('tar', 'tzf', tarball) unless status.success? Pod::UI.warn "[Expo-precompiled] Failed to inspect #{File.basename(tarball)}: #{entries_output.strip}" return [] end entries = entries_output.lines.map(&:strip) plist_entries = entries.select { |entry| entry.end_with?('.xcframework/Info.plist') } Pod::UI.warn "[Expo-precompiled] No XCFramework Info.plist found in #{File.basename(tarball)}" if plist_entries.empty? plist_entries.filter_map do |entry| plist_data, plist_status = Open3.capture2e('tar', 'xOzf', tarball, entry) unless plist_status.success? Pod::UI.warn "[Expo-precompiled] Failed to extract #{entry} from #{File.basename(tarball)}: #{plist_data.strip}" next end Tempfile.create(['expo-xcframework-info', '.plist']) do |file| file.binmode file.write(plist_data) file.flush read_plist(file.path) end end rescue StandardError => e Pod::UI.warn "[Expo-precompiled] Failed to inspect #{File.basename(tarball)}: #{e.message}" [] end def read_plist(path) Xcodeproj::Plist.read_from_path(path) rescue StandardError => e Pod::UI.warn "[Expo-precompiled] Failed to read #{File.basename(path)}: #{e.message}" nil end def failed_remote_downloads @failed_remote_downloads ||= Set.new end def remote_precompiled_artifacts_dir pods_root = Pod::Config.instance.sandbox_root rescue File.join(Dir.pwd, 'Pods') File.join(pods_root.to_s, 'ExpoPrecompiledArtifacts') end def gray(text) text.respond_to?(:gray) ? text.gray : text end def download_remote_tarball(remote_url, destination_path, pod_name, flavor) FileUtils.mkdir_p(File.dirname(destination_path)) tmp_path = "#{destination_path}.download-#{Process.pid}" download_to_file(remote_url, tmp_path) FileUtils.mv(tmp_path, destination_path) Pod::UI.info "#{'[Expo-precompiled] '.blue}#{pod_name}: downloaded remote #{flavor} artifact" destination_path rescue => e FileUtils.rm_f(tmp_path) if tmp_path && File.exist?(tmp_path) failed_remote_downloads.add(remote_url) Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{"#{pod_name}: remote #{flavor} artifact unavailable (#{e.message}); building from source".yellow}" Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{gray(" URL: #{remote_url}")}" nil end def download_to_file(url, destination_path, limit = 5) raise 'Too many HTTP redirects' if limit <= 0 uri = URI.parse(url) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.is_a?(URI::HTTPS), open_timeout: 10, read_timeout: 120) do |http| request = Net::HTTP::Get.new(uri.request_uri) http.request(request) do |response| case response when Net::HTTPSuccess File.open(destination_path, 'wb') do |file| response.read_body { |chunk| file.write(chunk) } end when Net::HTTPRedirection redirect_url = URI.join(uri.to_s, response['location']).to_s return download_to_file(redirect_url, destination_path, limit - 1) else raise "HTTP #{response.code}" end end end end # ────────────────────────────────────────────────────────────────────── # Helpers: codegen # ────────────────────────────────────────────────────────────────────── # Returns the codegenConfig library names for pods that should be excluded from codegen. # # @param pod_targets [Array] The pod targets # @return [Array] codegenConfig.name values to exclude from codegen def codegen_exclusion_list(pod_targets) return [] unless enabled? exclusions = [] pod_lookup_map.each do |pod_name, info| next unless info[:type] == :external next unless info[:codegen_name] next unless resolve_prebuilt_info(pod_name) exclusions << info[:codegen_name] Pod::UI.info "#{'[Expo-precompiled] '.blue}Found external package '#{info[:npm_package]}' with codegen module: #{info[:codegen_name]}" end exclusions.uniq end # Removes source file references for prebuilt libraries from ReactCodegen compile sources. def remove_codegen_sources_from_compile_phase(target, codegen_exclusions) compile_sources_phase = target.build_phases.find do |p| p.is_a?(Xcodeproj::Project::Object::PBXSourcesBuildPhase) end return unless compile_sources_phase files_to_remove = compile_sources_phase.files.select do |build_file| file_ref = build_file.file_ref next false unless file_ref codegen_file_matches_exclusion?(build_file, file_ref, codegen_exclusions) end if files_to_remove.any? Pod::UI.puts "[Expo] ".blue + "Removing #{files_to_remove.count} codegen source files from ReactCodegen compile sources" files_to_remove.each { |bf| compile_sources_phase.files.delete(bf) } end end # Checks whether a build file belongs to an excluded codegen library. def codegen_file_matches_exclusion?(build_file, file_ref, codegen_exclusions) file_path = file_ref.path.to_s display_name = build_file.display_name.to_s parent_name = (file_ref.respond_to?(:parent) && file_ref.parent) ? (file_ref.parent.name.to_s rescue '') : '' codegen_exclusions.any? do |lib| file_path.start_with?("#{lib}-") || file_path.start_with?("#{lib}JSI") || display_name.start_with?("#{lib}-") || display_name.start_with?("#{lib}JSI") || file_path.include?("/#{lib}/") || file_path.start_with?("#{lib}/") || parent_name == lib end end # Adds a shell script build phase to clean up codegen output for prebuilt libraries. def add_codegen_cleanup_script_phase(target, phase_name, codegen_exclusions) codegen_cleanup_list = codegen_exclusions.map { |lib| "\"#{lib}\"" }.join(' ') phase = target.new_shell_script_build_phase(phase_name) phase.shell_path = '/bin/sh' phase.shell_script = codegen_cleanup_shell_script(codegen_cleanup_list) phase.input_paths = ['$(PODS_ROOT)/../build/generated/autolinking/autolinking.json'] phase.output_paths = ['$(DERIVED_FILE_DIR)/expo-codegen-cleanup.stamp'] compile_sources_index = target.build_phases.find_index do |p| p.is_a?(Xcodeproj::Project::Object::PBXSourcesBuildPhase) end if compile_sources_index target.build_phases.delete(phase) target.build_phases.insert(compile_sources_index, phase) else Pod::UI.puts "[Expo] ".yellow + "Could not find 'Compile Sources' phase, build phase added at default position" end end # Returns the shell script content for the codegen cleanup build phase. def codegen_cleanup_shell_script(codegen_cleanup_list) <<~SH # Cleanup generated codegen files for prebuilt libraries to avoid duplicate symbols # When a library is prebuilt as an XCFramework, its codegen output is already included in the framework. # We need to remove the generated files so they don't get compiled into the ReactCodegen pod as well. # NOTE: The source file references have already been removed from the Xcode project during pod install, # but we still need to delete the files because codegen regenerates them on each build. PREBUILT_CODEGEN_LIBS=(#{codegen_cleanup_list}) CODEGEN_OUTPUT_DIR="$PODS_ROOT/../build/generated/ios/ReactCodegen" if [ ${#PREBUILT_CODEGEN_LIBS[@]} -gt 0 ]; then echo "[Expo] Cleaning up codegen output for prebuilt libraries: ${PREBUILT_CODEGEN_LIBS[*]}" for lib in "${PREBUILT_CODEGEN_LIBS[@]}"; do # Remove module directory (contains .h and -generated.mm files) if [ -d "$CODEGEN_OUTPUT_DIR/$lib" ]; then echo "[Expo] Removing module: $CODEGEN_OUTPUT_DIR/$lib" rm -rf "$CODEGEN_OUTPUT_DIR/$lib" fi # Remove JSI header file if [ -f "$CODEGEN_OUTPUT_DIR/${lib}JSI.h" ]; then echo "[Expo] Removing JSI header: $CODEGEN_OUTPUT_DIR/${lib}JSI.h" rm -f "$CODEGEN_OUTPUT_DIR/${lib}JSI.h" fi # Remove components directory if [ -d "$CODEGEN_OUTPUT_DIR/react/renderer/components/$lib" ]; then echo "[Expo] Removing components: $CODEGEN_OUTPUT_DIR/react/renderer/components/$lib" rm -rf "$CODEGEN_OUTPUT_DIR/react/renderer/components/$lib" fi done fi # Touch the stamp file for Xcode dependency tracking mkdir -p "$DERIVED_FILE_DIR" touch "$DERIVED_FILE_DIR/expo-codegen-cleanup.stamp" SH end # ────────────────────────────────────────────────────────────────────── # Helpers: xcconfig / header search paths # ────────────────────────────────────────────────────────────────────── # Removes -l linker flags for stubbed libraries from all xcconfig files. def remove_linker_flags_for_stubbed_libs(installer, stubbed_lib_names) lib_flags = stubbed_lib_names.flat_map { |name| ["-l\"#{name}\"", "-l#{name}"] } return if lib_flags.empty? xcconfig_dir = File.join(installer.sandbox.root, 'Target Support Files') return unless File.directory?(xcconfig_dir) Dir.glob(File.join(xcconfig_dir, '**', '*.xcconfig')).each do |xcconfig_path| content = File.read(xcconfig_path) original = content.dup lib_flags.each do |flag| content = content.gsub(/\s*#{Regexp.escape(flag)}/, '') end if content != original File.write(xcconfig_path, content) Pod::UI.puts "#{'[Expo-precompiled] '.blue}Cleaned linker flags from #{File.basename(File.dirname(xcconfig_path))}/#{File.basename(xcconfig_path)}" end end end # TODO(ExpoModulesJSI-xcframework): Remove this method when ExpoModulesJSI.xcframework # is built and distributed separately. # # Updates HEADER_SEARCH_PATHS in an xcconfig file def update_xcconfig_header_search_paths(xcconfig_path, paths_string) content = File.read(xcconfig_path) if content.include?('HEADER_SEARCH_PATHS') updated_content = content.gsub(/^(HEADER_SEARCH_PATHS\s*=\s*)(.*)$/) do |match| $2.include?(paths_string) ? match : "#{$1}#{$2} #{paths_string}" end File.write(xcconfig_path, updated_content) if updated_content != content else File.open(xcconfig_path, 'a') { |f| f.puts "HEADER_SEARCH_PATHS = $(inherited) #{paths_string}" } end end # Finds the ExpoModulesCore.xcframework path from the installer def find_expo_modules_core_xcframework(installer) installer.pod_targets.each do |target| next unless target.name == 'ExpoModulesCore' vendored = target.root_spec.attributes_hash['vendored_frameworks'] next unless vendored frameworks = vendored.is_a?(Array) ? vendored : [vendored] frameworks.each do |framework| if framework.to_s.include?('ExpoModulesCore.xcframework') podspec_dir = target.sandbox.pod_dir(target.name) framework_path = File.expand_path(framework, podspec_dir) Pod::UI.info "#{'[Expo-precompiled] '.blue}Looking for ExpoModulesCore.xcframework at: #{framework_path}" return framework_path if File.directory?(framework_path) end end end nil end # Collects header paths from all slices of an XCFramework def collect_xcframework_header_paths(xcframework_path) return [] unless File.directory?(xcframework_path) Dir.children(xcframework_path).filter_map do |slice| slice_path = File.join(xcframework_path, slice) next unless File.directory?(slice_path) framework_headers = File.join(slice_path, 'ExpoModulesCore.framework', 'Headers') framework_headers if File.directory?(framework_headers) end end # ────────────────────────────────────────────────────────────────────── # Helpers: linking summary tracking # ────────────────────────────────────────────────────────────────────── # Records the linking status for a pod (only once per pod to avoid duplicates). def log_linking_status(pod_name, found, info = nil) @linked_pods ||= {} return if @linked_pods[pod_name] status = info.is_a?(Hash) ? info.dup : { path: info } @linked_pods[pod_name] = status.merge(found: found, spm_deps: []) end def format_prebuilt_unavailable_reason(info) case info[:reason] when :build_from_source 'configured by buildFromSource' when :missing_config 'prebuilt config not found' when :missing_tarball 'prebuilt tarball not found' when :missing_platform_slice "prebuilt xcframework does not contain a slice for #{@target_platform}" when :dependency_unavailable reason = format_prebuilt_unavailable_reason(reason: info[:dependency_reason], path: info[:dependency_path]) "dependency #{info[:dependency]} is not using prebuilt: #{reason}" else info[:path] || 'prebuilt unavailable' end end # Records an SPM dependency xcframework bundled inside a precompiled pod. def log_spm_dependency(pod_name, dep_name) @linked_pods ||= {} @linked_pods[pod_name] ||= { found: true, path: nil, spm_deps: [] } @linked_pods[pod_name][:spm_deps] << dep_name end end end end