Skip to content

Commit d787826

Browse files
mfazekasmeta-codesync[bot]
authored andcommitted
fix(iOS): embed the dynamic frameworks of spm_dependency Swift packages (#58781)
Summary: `spm_dependency` links a pod against Swift package products, but CocoaPods only embeds the frameworks of pods. Dynamic frameworks from Swift packages (binary targets like rive-ios' `RiveRuntime`, or `type: .dynamic` products like `AlamofireDynamic`) never reach the app bundle, so the app builds but fails at launch with `dyld: Library not loaded: rpath/RiveRuntime.framework/RiveRuntime`. Separately, Xcode 26 archives fail on the duplicate signature Xcode writes for a binary target used by a pod: `"RiveRuntime.xcframework-ios.signature" couldn't be copied to "Signatures" because an item with the same name already exists.` This PR: 1. Adds an `install_spm_framework <name>` call per framework to the app's `[CP] Embed Pods Frameworks` script, which embeds the framework from where Xcode builds it, unless it is static. 2. Adds `embed_frameworks:` to `spm_dependency`, defaulting to `products`, for frameworks named differently from their product (`Sentry-Dynamic` is `Sentry.framework`) or coming from the packages a product depends on (`MapboxMaps` loads `MapboxCommon`, `MapboxCoreMaps` and `Turf`). 3. Removes the pod's duplicate `*.xcframework-*.signature` for pods not built into the shared products dir. 4. Records a dependency once, even though CocoaPods evaluates a podspec several times per install. Libraries work around this on their own today, e.g. react-native-firebase's `firebase_spm.rb`, stripe/stripe-react-native#2608, maplibre/maplibre-react-native#1490, or by dropping SPM as in getsentry/sentry-react-native#6381. ## Changelog: [IOS] [FIXED] - `spm_dependency` embeds the dynamic frameworks of Swift packages in the app, and takes `embed_frameworks` for frameworks named differently from their product Pull Request resolved: #58781 Test Plan: `ruby -Itest packages/react-native/scripts/cocoapods/__tests__/spm-test.rb` passes, with 5 new tests. It also fixes the existing tests, whose installer stub lacked `aggregate_targets` since #57602. Built https://github.com/mfazekas/rn-spm-dynamic-poc (a library using `AlamofireDynamic`, `RiveRuntime` and `Sentry-Dynamic`) with this `spm.rb` on RN 0.84 and Xcode 26.5: - Before: Debug simulator launch fails with `Library not loaded: rpath/RiveRuntime.framework`, and the Release archive fails on the duplicate signature. - After: the frameworks are embedded, the app launches and the archive succeeds, with dynamic frameworks (all three) and with static libraries (`RiveRuntime` and `Sentry`; `AlamofireDynamic` does not link statically, with or without this change). Its `more-packages` branch does the same with Agora, Mapbox, Stripe and Firebase. Reviewed By: cortinico Differential Revision: D122772893 Pulled By: cipolleschi fbshipit-source-id: c201a20388fe1c50cc1f7c3d06873535a2be73c7
1 parent b47915d commit d787826

3 files changed

Lines changed: 183 additions & 5 deletions

File tree

‎packages/react-native/scripts/cocoapods/__tests__/spm-test.rb‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,17 @@
1313
# from how `Pod::Project` hands out UUIDs, and cannot be observed against a mock.
1414
class SPMTests < Test::Unit::TestCase
1515
PodSpecStub = Struct.new(:name)
16-
InstallerStub = Struct.new(:pods_project)
16+
InstallerStub = Struct.new(:pods_project, :aggregate_targets) do
17+
def initialize(pods_project, aggregate_targets = [])
18+
super
19+
end
20+
end
21+
PodTargetStub = Struct.new(:name)
22+
AggregateTargetStub = Struct.new(:name, :pod_targets, :embed_frameworks_script_path) do
23+
def xcconfigs
24+
{}
25+
end
26+
end
1727

1828
POD_NAME = "ReactNativeEnrichedMarkdown"
1929
TMP_DIR = File.join(Dir.tmpdir, "rn-spm-test")
@@ -58,6 +68,46 @@ def simulate_reload(project)
5868
project.instance_variable_set(:@available_uuids, [])
5969
end
6070

71+
EMBED_SCRIPT = <<~'SH'
72+
#!/bin/sh
73+
install_framework()
74+
{
75+
echo "$1"
76+
}
77+
if [[ "$CONFIGURATION" == "Debug" ]]; then
78+
install_framework "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework"
79+
fi
80+
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
81+
wait
82+
fi
83+
SH
84+
85+
def apply_with_embed_script(manager, project)
86+
script_path = File.join(TMP_DIR, "Pods-App-frameworks.sh")
87+
File.write(script_path, EMBED_SCRIPT) unless File.exist?(script_path)
88+
aggregate_target = AggregateTargetStub.new("Pods-App", [PodTargetStub.new(POD_NAME)], script_path)
89+
manager.apply_on_post_install(InstallerStub.new(project, [aggregate_target]))
90+
File.read(script_path)
91+
end
92+
93+
def spm_manager(**embed_frameworks)
94+
manager = SPMManager.new
95+
manager.dependency(
96+
PodSpecStub.new(POD_NAME),
97+
url: "https://github.com/rive-app/rive-ios.git",
98+
requirement: { kind: "exactVersion", version: "6.26.0" },
99+
products: ["RiveRuntime"]
100+
)
101+
manager.dependency(
102+
PodSpecStub.new(POD_NAME),
103+
url: "https://github.com/getsentry/sentry-cocoa.git",
104+
requirement: { kind: "exactVersion", version: "9.29.2" },
105+
products: ["Sentry-Dynamic"],
106+
**embed_frameworks
107+
)
108+
manager
109+
end
110+
61111
def assert_loadable_project(path)
62112
reopened = nil
63113
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
97147
uuids = reopened.objects.map(&:uuid)
98148
assert_equal(uuids.length, uuids.uniq.length, "all object UUIDs must be unique")
99149
end
150+
151+
def test_embeds_frameworks_of_swift_packages_before_the_code_sign_wait
152+
script = apply_with_embed_script(spm_manager(embed_frameworks: ["Sentry"]), build_project(1))
153+
calls = script.lines.grep(/^install_spm_framework "/).map(&:strip)
154+
assert_equal(['install_spm_framework "RiveRuntime"', 'install_spm_framework "Sentry"'], calls)
155+
assert_operator(script.index("install_spm_framework \"Sentry\""), :<, script.index("COCOAPODS_PARALLEL_CODE_SIGN"))
156+
assert_includes(script, '# https://github.com/getsentry/sentry-cocoa.git {kind: "exactVersion", version: "9.29.2"}')
157+
end
158+
159+
def test_embed_frameworks_defaults_to_the_products
160+
script = apply_with_embed_script(spm_manager, build_project(1))
161+
assert_includes(script, 'install_spm_framework "Sentry-Dynamic"')
162+
end
163+
164+
def test_embed_script_is_patched_once
165+
manager = spm_manager
166+
project = build_project(1)
167+
apply_with_embed_script(manager, project)
168+
script = apply_with_embed_script(manager, project)
169+
assert_equal(1, script.scan("install_spm_framework()").length)
170+
assert_equal(1, script.scan('install_spm_framework "RiveRuntime"').length)
171+
end
172+
173+
def test_repeated_podspec_evaluation_records_a_dependency_once
174+
manager = spm_manager
175+
manager.dependency(
176+
PodSpecStub.new(POD_NAME),
177+
url: "https://github.com/rive-app/rive-ios.git",
178+
requirement: { kind: "exactVersion", version: "6.26.0" },
179+
products: ["RiveRuntime"]
180+
)
181+
script = apply_with_embed_script(manager, build_project(1))
182+
assert_equal(1, script.scan('install_spm_framework "RiveRuntime"').length)
183+
assert_equal(1, script.scan("# https://github.com/rive-app/rive-ios.git").length)
184+
end
185+
186+
def test_signature_cleanup_is_added_only_to_pods_not_built_into_the_shared_products_dir
187+
project = build_project(0)
188+
project.new_target(:framework, "DynamicPod", :ios)
189+
project.save
190+
manager = SPMManager.new
191+
["DynamicPod", POD_NAME].each do |pod_name|
192+
manager.dependency(
193+
PodSpecStub.new(pod_name),
194+
url: "https://github.com/rive-app/rive-ios.git",
195+
requirement: { kind: "exactVersion", version: "6.26.0" },
196+
products: ["RiveRuntime"]
197+
)
198+
end
199+
manager.apply_on_post_install(InstallerStub.new(project))
200+
phase_names = ->(name) { project.targets.find { |t| t.name == name }.shell_script_build_phases.map(&:name) }
201+
assert_equal([SPMManager::SIGNATURE_PHASE_NAME], phase_names.call("DynamicPod"))
202+
assert_empty(phase_names.call(POD_NAME))
203+
assert_loadable_project(project.path)
204+
end
100205
end

‎packages/react-native/scripts/cocoapods/spm.rb‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,36 @@
44
# LICENSE file in the root directory of this source tree.
55

66
class SPMManager
7+
EMBED_FUNCTION = 'install_spm_framework'
8+
SIGNATURE_PHASE_NAME = '[RN] Remove duplicate Swift package xcframework signatures'
9+
10+
# Embeds framework $1 from where Xcode builds binary targets (the shared products dir) and source packages
11+
# (PackageFrameworks, or UninstalledProducts when archiving); static frameworks are linked into their
12+
# consumer and a framework that is not found is skipped, so a misspelled name fails only at launch.
13+
EMBED_FUNCTION_SOURCE = <<~'SH'
14+
install_spm_framework()
15+
{
16+
local dir
17+
for dir in "${PODS_CONFIGURATION_BUILD_DIR}" "${PODS_CONFIGURATION_BUILD_DIR}/PackageFrameworks" "${OBJROOT}/UninstalledProducts/${PLATFORM_NAME}"; do
18+
if [ -d "$dir/$1.framework" ]; then
19+
if file -b "$dir/$1.framework/$1" | grep -q "dynamically linked"; then
20+
install_framework "$dir/$1.framework"
21+
fi
22+
return
23+
fi
24+
done
25+
}
26+
SH
27+
728
def initialize()
829
@dependencies_by_pod = {}
930
end
1031

11-
def dependency(pod_spec, url:, requirement:, products:)
32+
def dependency(pod_spec, url:, requirement:, products:, embed_frameworks: products)
1233
@dependencies_by_pod[pod_spec.name] ||= []
13-
@dependencies_by_pod[pod_spec.name] << { url: url, requirement: requirement, products: products}
34+
dependency = { url: url, requirement: requirement, products: products, embed_frameworks: embed_frameworks }
35+
# CocoaPods can evaluate a podspec several times during one install.
36+
@dependencies_by_pod[pod_spec.name] << dependency unless @dependencies_by_pod[pod_spec.name].include?(dependency)
1437
end
1538

1639
def apply_on_post_install(installer)
@@ -60,6 +83,10 @@ def apply_on_post_install(installer)
6083
rewrite_aggregate_modulemap_references(installer, flattened_pod_names) unless flattened_pod_names.empty?
6184

6285
unless @dependencies_by_pod.empty?
86+
log 'Embedding dynamic frameworks of Swift packages'
87+
add_embed_frameworks(installer)
88+
add_signature_cleanup(project, @dependencies_by_pod.keys - flattened_pod_names)
89+
6390
log_warning "If you're using Xcode 15 or earlier you might need to close and reopen the Xcode workspace"
6491
unless ENV["USE_FRAMEWORKS"] == "dynamic"
6592
@dependencies_by_pod.each do |pod_name, dependencies|
@@ -71,6 +98,49 @@ def apply_on_post_install(installer)
7198

7299
private
73100

101+
# CocoaPods' "[CP] Embed Pods Frameworks" script only embeds the frameworks of pods, so without these
102+
# calls the app fails at launch with dyld "Library not loaded" for a Swift package framework.
103+
def add_embed_frameworks(installer)
104+
installer.aggregate_targets.each do |aggregate_target|
105+
pod_names = aggregate_target.pod_targets.map(&:name) & @dependencies_by_pod.keys
106+
script_path = aggregate_target.embed_frameworks_script_path
107+
next if pod_names.empty? || !File.exist?(script_path)
108+
109+
script = File.read(script_path)
110+
next if script.include?("#{EMBED_FUNCTION}()")
111+
anchor = /^if \[ "\$\{COCOAPODS_PARALLEL_CODE_SIGN\}" == "true" \]; then$/
112+
unless script.match?(anchor)
113+
log_warning "Could not embed Swift package frameworks in #{script_path}, the app might fail to launch"
114+
next
115+
end
116+
117+
dependencies = pod_names.flat_map { |pod_name| @dependencies_by_pod[pod_name] }
118+
frameworks = dependencies.flat_map { |d| d[:embed_frameworks] }.uniq
119+
# Listing the requirements makes a version change rewrite this script, an input of the embed phase,
120+
# so the phase runs again and copies the new frameworks.
121+
requirements = dependencies.map { |d| "# #{d[:url]} #{d[:requirement]}\n" }.uniq.join
122+
calls = frameworks.map { |framework| "#{EMBED_FUNCTION} \"#{framework}\"\n" }.join
123+
File.write(script_path, script.sub(anchor) { "#{EMBED_FUNCTION_SOURCE}#{requirements}#{calls}#{$&}" })
124+
log " Embedding #{frameworks.join(', ')} in #{aggregate_target.name}"
125+
end
126+
end
127+
128+
# Xcode writes a binary target's xcframework signature both to the shared products dir and to the build dir
129+
# of the pod using it, and Xcode 26 archives fail on the duplicate ("couldn't be copied to Signatures because
130+
# an item with the same name already exists"), so the pod's copy is removed.
131+
def add_signature_cleanup(project, pod_names)
132+
pod_names.each do |pod_name|
133+
target = project.targets.find { |t| t.name == pod_name }
134+
next if target.nil? || target.shell_script_build_phases.any? { |phase| phase.name == SIGNATURE_PHASE_NAME }
135+
136+
phase = new_object(project, Xcodeproj::Project::Object::PBXShellScriptBuildPhase)
137+
phase.name = SIGNATURE_PHASE_NAME
138+
phase.shell_script = 'rm -f "${CONFIGURATION_BUILD_DIR}"/*.xcframework-*.signature'
139+
phase.always_out_of_date = '1'
140+
target.build_phases << phase
141+
end
142+
end
143+
74144
# Flattening a pod's build dir moves its generated modulemap from
75145
# "<Pod>/<Pod>.modulemap" to "<Pod>.modulemap"; the aggregate xcconfigs
76146
# reference the old path via -fmodule-map-file. Must mutate the in-memory

‎packages/react-native/scripts/react_native_pods.rb‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,11 @@ def podspec_sources(original_sources, sources_for_prebuilds)
397397
# - url: The URL of the Swift Package Manager dependency
398398
# - requirement: The version requirement of the Swift Package Manager dependency (eg. ` {kind: 'upToNextMajorVersion', minimumVersion: '5.9.1'},`)
399399
# - products: The product/target of the Swift Package Manager dependency (eg. AlamofireDynamic)
400-
def spm_dependency(spec, url:, requirement:, products:)
401-
SPM.dependency(spec, url: url, requirement: requirement, products: products)
400+
# - embed_frameworks: The names of the dynamic frameworks the products load, including those of the packages they depend
401+
# on, embedded in the app (eg. Sentry for the Sentry-Dynamic product; MapboxCommon, MapboxCoreMaps and Turf for
402+
# MapboxMaps). Defaults to the product names. A name that matches no framework is not reported
403+
def spm_dependency(spec, url:, requirement:, products:, embed_frameworks: products)
404+
SPM.dependency(spec, url: url, requirement: requirement, products: products, embed_frameworks: embed_frameworks)
402405
end
403406

404407
# It returns the default flags.

0 commit comments

Comments
 (0)