Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion packages/react-native/scripts/cocoapods/__tests__/spm-test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
74 changes: 72 additions & 2 deletions packages/react-native/scripts/cocoapods/spm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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|
Expand All @@ -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
# "<Pod>/<Pod>.modulemap" to "<Pod>.modulemap"; the aggregate xcconfigs
# reference the old path via -fmodule-map-file. Must mutate the in-memory
Expand Down
7 changes: 5 additions & 2 deletions packages/react-native/scripts/react_native_pods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading