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.