From 5552cc90272e0591092a9c176728aa935e2bc26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 30 Sep 2026 09:06:17 +0200 Subject: [PATCH] fix(iOS): embed the dynamic frameworks of spm_dependency Swift packages CocoaPods only embeds the frameworks of pods, so a dynamic framework that a Swift package adds through spm_dependency is linked but missing from the app bundle, and the app fails at launch with dyld "Library not loaded". Add them to the embed frameworks script, and remove the pod's duplicate xcframework signature that fails Xcode 26 archives. The new embed_frameworks argument names frameworks that differ from their product (Sentry-Dynamic is Sentry.framework). --- .../scripts/cocoapods/__tests__/spm-test.rb | 107 +++++++++++++++++- .../react-native/scripts/cocoapods/spm.rb | 74 +++++++++++- .../react-native/scripts/react_native_pods.rb | 7 +- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb index 89a308293c37..d3a546def0d7 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/spm-test.rb @@ -13,7 +13,17 @@ # from how `Pod::Project` hands out UUIDs, and cannot be observed against a mock. class SPMTests < Test::Unit::TestCase PodSpecStub = Struct.new(:name) - InstallerStub = Struct.new(:pods_project) + InstallerStub = Struct.new(:pods_project, :aggregate_targets) do + def initialize(pods_project, aggregate_targets = []) + super + end + end + PodTargetStub = Struct.new(:name) + AggregateTargetStub = Struct.new(:name, :pod_targets, :embed_frameworks_script_path) do + def xcconfigs + {} + end + end POD_NAME = "ReactNativeEnrichedMarkdown" TMP_DIR = File.join(Dir.tmpdir, "rn-spm-test") @@ -58,6 +68,46 @@ def simulate_reload(project) project.instance_variable_set(:@available_uuids, []) end + EMBED_SCRIPT = <<~'SH' + #!/bin/sh + install_framework() + { + echo "$1" + } + if [[ "$CONFIGURATION" == "Debug" ]]; then + install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework" + fi + if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then + wait + fi + SH + + def apply_with_embed_script(manager, project) + script_path = File.join(TMP_DIR, "Pods-App-frameworks.sh") + File.write(script_path, EMBED_SCRIPT) unless File.exist?(script_path) + aggregate_target = AggregateTargetStub.new("Pods-App", [PodTargetStub.new(POD_NAME)], script_path) + manager.apply_on_post_install(InstallerStub.new(project, [aggregate_target])) + File.read(script_path) + end + + def spm_manager(**embed_frameworks) + manager = SPMManager.new + manager.dependency( + PodSpecStub.new(POD_NAME), + url: "https://github.com/rive-app/rive-ios.git", + requirement: { kind: "exactVersion", version: "6.26.0" }, + products: ["RiveRuntime"] + ) + manager.dependency( + PodSpecStub.new(POD_NAME), + url: "https://github.com/getsentry/sentry-cocoa.git", + requirement: { kind: "exactVersion", version: "9.29.2" }, + products: ["Sentry-Dynamic"], + **embed_frameworks + ) + manager + end + def assert_loadable_project(path) reopened = nil assert_nothing_raised("Pods project must reload cleanly after SPM injection") do @@ -97,4 +147,59 @@ def test_injected_uuids_are_unique_across_all_objects uuids = reopened.objects.map(&:uuid) assert_equal(uuids.length, uuids.uniq.length, "all object UUIDs must be unique") end + + def test_embeds_frameworks_of_swift_packages_before_the_code_sign_wait + script = apply_with_embed_script(spm_manager(embed_frameworks: ["Sentry"]), build_project(1)) + calls = script.lines.grep(/^install_spm_framework "/).map(&:strip) + assert_equal(['install_spm_framework "RiveRuntime"', 'install_spm_framework "Sentry"'], calls) + assert_operator(script.index("install_spm_framework \"Sentry\""), :<, script.index("COCOAPODS_PARALLEL_CODE_SIGN")) + assert_includes(script, '# https://github.com/getsentry/sentry-cocoa.git {kind: "exactVersion", version: "9.29.2"}') + end + + def test_embed_frameworks_defaults_to_the_products + script = apply_with_embed_script(spm_manager, build_project(1)) + assert_includes(script, 'install_spm_framework "Sentry-Dynamic"') + end + + def test_embed_script_is_patched_once + manager = spm_manager + project = build_project(1) + apply_with_embed_script(manager, project) + script = apply_with_embed_script(manager, project) + assert_equal(1, script.scan("install_spm_framework()").length) + assert_equal(1, script.scan('install_spm_framework "RiveRuntime"').length) + end + + def test_repeated_podspec_evaluation_records_a_dependency_once + manager = spm_manager + manager.dependency( + PodSpecStub.new(POD_NAME), + url: "https://github.com/rive-app/rive-ios.git", + requirement: { kind: "exactVersion", version: "6.26.0" }, + products: ["RiveRuntime"] + ) + script = apply_with_embed_script(manager, build_project(1)) + assert_equal(1, script.scan('install_spm_framework "RiveRuntime"').length) + assert_equal(1, script.scan("# https://github.com/rive-app/rive-ios.git").length) + end + + def test_signature_cleanup_is_added_only_to_pods_not_built_into_the_shared_products_dir + project = build_project(0) + project.new_target(:framework, "DynamicPod", :ios) + project.save + manager = SPMManager.new + ["DynamicPod", POD_NAME].each do |pod_name| + manager.dependency( + PodSpecStub.new(pod_name), + url: "https://github.com/rive-app/rive-ios.git", + requirement: { kind: "exactVersion", version: "6.26.0" }, + products: ["RiveRuntime"] + ) + end + manager.apply_on_post_install(InstallerStub.new(project)) + phase_names = ->(name) { project.targets.find { |t| t.name == name }.shell_script_build_phases.map(&:name) } + assert_equal([SPMManager::SIGNATURE_PHASE_NAME], phase_names.call("DynamicPod")) + assert_empty(phase_names.call(POD_NAME)) + assert_loadable_project(project.path) + end end diff --git a/packages/react-native/scripts/cocoapods/spm.rb b/packages/react-native/scripts/cocoapods/spm.rb index f49230270d1f..63b3ce92f3a9 100644 --- a/packages/react-native/scripts/cocoapods/spm.rb +++ b/packages/react-native/scripts/cocoapods/spm.rb @@ -4,13 +4,36 @@ # LICENSE file in the root directory of this source tree. class SPMManager + EMBED_FUNCTION = 'install_spm_framework' + SIGNATURE_PHASE_NAME = '[RN] Remove duplicate Swift package xcframework signatures' + + # Embeds framework $1 from where Xcode builds binary targets (the shared products dir) and source packages + # (PackageFrameworks, or UninstalledProducts when archiving); static frameworks are linked into their + # consumer and a framework that is not found is skipped, so a misspelled name fails only at launch. + EMBED_FUNCTION_SOURCE = <<~'SH' + install_spm_framework() + { + local dir + for dir in "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks" "${OBJROOT}/UninstalledProducts/${PLATFORM_NAME}"; do + if [ -d "$dir/$1.framework" ]; then + if file -b "$dir/$1.framework/$1" | grep -q "dynamically linked"; then + install_framework "$dir/$1.framework" + fi + return + fi + done + } + SH + def initialize() @dependencies_by_pod = {} end - def dependency(pod_spec, url:, requirement:, products:) + def dependency(pod_spec, url:, requirement:, products:, embed_frameworks: products) @dependencies_by_pod[pod_spec.name] ||= [] - @dependencies_by_pod[pod_spec.name] << { url: url, requirement: requirement, products: products} + dependency = { url: url, requirement: requirement, products: products, embed_frameworks: embed_frameworks } + # CocoaPods can evaluate a podspec several times during one install. + @dependencies_by_pod[pod_spec.name] << dependency unless @dependencies_by_pod[pod_spec.name].include?(dependency) end def apply_on_post_install(installer) @@ -60,6 +83,10 @@ def apply_on_post_install(installer) rewrite_aggregate_modulemap_references(installer, flattened_pod_names) unless flattened_pod_names.empty? unless @dependencies_by_pod.empty? + log 'Embedding dynamic frameworks of Swift packages' + add_embed_frameworks(installer) + add_signature_cleanup(project, @dependencies_by_pod.keys - flattened_pod_names) + log_warning "If you're using Xcode 15 or earlier you might need to close and reopen the Xcode workspace" unless ENV["USE_FRAMEWORKS"] == "dynamic" @dependencies_by_pod.each do |pod_name, dependencies| @@ -71,6 +98,49 @@ def apply_on_post_install(installer) private + # CocoaPods' "[CP] Embed Pods Frameworks" script only embeds the frameworks of pods, so without these + # calls the app fails at launch with dyld "Library not loaded" for a Swift package framework. + def add_embed_frameworks(installer) + installer.aggregate_targets.each do |aggregate_target| + pod_names = aggregate_target.pod_targets.map(&:name) & @dependencies_by_pod.keys + script_path = aggregate_target.embed_frameworks_script_path + next if pod_names.empty? || !File.exist?(script_path) + + script = File.read(script_path) + next if script.include?("#{EMBED_FUNCTION}()") + anchor = /^if \[ "\$\{COCOAPODS_PARALLEL_CODE_SIGN\}" == "true" \]; then$/ + unless script.match?(anchor) + log_warning "Could not embed Swift package frameworks in #{script_path}, the app might fail to launch" + next + end + + dependencies = pod_names.flat_map { |pod_name| @dependencies_by_pod[pod_name] } + frameworks = dependencies.flat_map { |d| d[:embed_frameworks] }.uniq + # Listing the requirements makes a version change rewrite this script, an input of the embed phase, + # so the phase runs again and copies the new frameworks. + requirements = dependencies.map { |d| "# #{d[:url]} #{d[:requirement]}\n" }.uniq.join + calls = frameworks.map { |framework| "#{EMBED_FUNCTION} \"#{framework}\"\n" }.join + File.write(script_path, script.sub(anchor) { "#{EMBED_FUNCTION_SOURCE}#{requirements}#{calls}#{$&}" }) + log " Embedding #{frameworks.join(', ')} in #{aggregate_target.name}" + end + end + + # Xcode writes a binary target's xcframework signature both to the shared products dir and to the build dir + # of the pod using it, and Xcode 26 archives fail on the duplicate ("couldn't be copied to Signatures because + # an item with the same name already exists"), so the pod's copy is removed. + def add_signature_cleanup(project, pod_names) + pod_names.each do |pod_name| + target = project.targets.find { |t| t.name == pod_name } + next if target.nil? || target.shell_script_build_phases.any? { |phase| phase.name == SIGNATURE_PHASE_NAME } + + phase = new_object(project, Xcodeproj::Project::Object::PBXShellScriptBuildPhase) + phase.name = SIGNATURE_PHASE_NAME + phase.shell_script = 'rm -f "${CONFIGURATION_BUILD_DIR}"/*.xcframework-*.signature' + phase.always_out_of_date = '1' + target.build_phases << phase + end + end + # Flattening a pod's build dir moves its generated modulemap from # "/.modulemap" to ".modulemap"; the aggregate xcconfigs # reference the old path via -fmodule-map-file. Must mutate the in-memory diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index c0c13480dadd..90d14ae976a3 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -397,8 +397,11 @@ def podspec_sources(original_sources, sources_for_prebuilds) # - url: The URL of the Swift Package Manager dependency # - requirement: The version requirement of the Swift Package Manager dependency (eg. ` {kind: 'upToNextMajorVersion', minimumVersion: '5.9.1'},`) # - products: The product/target of the Swift Package Manager dependency (eg. AlamofireDynamic) -def spm_dependency(spec, url:, requirement:, products:) - SPM.dependency(spec, url: url, requirement: requirement, products: products) +# - embed_frameworks: The names of the dynamic frameworks the products load, including those of the packages they depend +# on, embedded in the app (eg. Sentry for the Sentry-Dynamic product; MapboxCommon, MapboxCoreMaps and Turf for +# MapboxMaps). Defaults to the product names. A name that matches no framework is not reported +def spm_dependency(spec, url:, requirement:, products:, embed_frameworks: products) + SPM.dependency(spec, url: url, requirement: requirement, products: products, embed_frameworks: embed_frameworks) end # It returns the default flags.