Skip to content

Commit 7883342

Browse files
chrfalchclaude
andcommitted
feat(ios-prebuild): embed React.framework's non-header resources for prebuilt/SwiftPM
Source builds get React-Core's non-header resources from the podspec resource_bundles, but in the prebuilt path the source pods aren't installed (CocoaPods facades) / not present (SwiftPM), so they were lost. Reproduce them in the artifact at compose time via scripts/ios-prebuild/framework-resources.js: - Privacy manifest: merge the PrivacyInfo.xcprivacy of the pods baked into React.framework into one manifest at the framework root, where Xcode's privacy-report aggregation picks it up (React.framework is dynamic; no runtime). - RCTI18nStrings: rebuild RCTI18nStrings.bundle from React/I18n/strings/*.lproj inside React.framework, resolved at runtime by the framework-aware loader. RCTLocalizedString.mm now resolves the strings bundle from the code's own bundle first (React.framework when prebuilt/SwiftPM) with a main-bundle fallback (static source builds), keeping the graceful nil -> default behaviour. React-Core.podspec declares RCTI18nStrings and React-Core_privacy in one resource_bundles map (a later resource_bundles= had silently overwritten the first), so source builds ship both again. The RNCore facade no longer carries React-Core_privacy: the prebuilt artifact owns these resources now. Red/green unit + integration tests in __tests__/framework-resources-test.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 30878fc commit 7883342

6 files changed

Lines changed: 527 additions & 68 deletions

File tree

packages/react-native/React-Core.podspec

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ Pod::Spec.new do |s|
5151
s.author = "Meta Platforms, Inc. and its affiliates"
5252
s.platforms = min_supported_versions
5353
s.source = source
54-
s.resource_bundle = { "RCTI18nStrings" => ["React/I18n/strings/*.lproj"]}
5554
s.compiler_flags = js_engine_flags()
5655
s.header_dir = "React"
5756
s.weak_framework = "JavaScriptCore"
@@ -122,7 +121,15 @@ Pod::Spec.new do |s|
122121
s.dependency "React-hermes"
123122
end
124123

125-
s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'}
124+
# Both bundles in one declaration: a second `resource_bundle(s) =` would replace
125+
# (not merge) the first. RCTI18nStrings holds React-Core's localized strings
126+
# (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest.
127+
# (Prebuilt/SwiftPM get both from inside React.xcframework instead — see
128+
# scripts/ios-prebuild/{i18n,privacy}.js — but source builds ship them here.)
129+
s.resource_bundles = {
130+
'RCTI18nStrings' => ['React/I18n/strings/*.lproj'],
131+
'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy',
132+
}
126133

127134
add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"])
128135
add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern')

packages/react-native/React/I18n/RCTLocalizedString.mm

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,29 @@
99

1010
#if !defined(WITH_FBI18N) || !(WITH_FBI18N)
1111

12+
// Anchors resource lookups to the bundle that contains this code: React.framework
13+
// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle
14+
// for static source builds.
15+
@interface RCTI18nStringsAnchor : NSObject
16+
@end
17+
@implementation RCTI18nStringsAnchor
18+
@end
19+
20+
// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first
21+
// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle
22+
// (source builds copy it there via the podspec resource_bundles). Returns nil
23+
// when absent, so the caller falls back to the untranslated default value.
24+
static NSBundle *RCTI18nStringsBundle(void)
25+
{
26+
NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]];
27+
NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"];
28+
if (url != nil) {
29+
return [NSBundle bundleWithURL:url];
30+
}
31+
NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"];
32+
return mainPath != nil ? [NSBundle bundleWithPath:mainPath] : nil;
33+
}
34+
1235
extern "C" {
1336

1437
static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number)
@@ -33,8 +56,7 @@
3356

3457
NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue)
3558
{
36-
static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings"
37-
ofType:@"bundle"]];
59+
static NSBundle *bundle = RCTI18nStringsBundle();
3860
if (bundle == nil) {
3961
return defaultValue;
4062
} else {

packages/react-native/scripts/cocoapods/rncore_facades.rb

Lines changed: 12 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,18 @@ def self.facade?(name)
103103
# call once per `pod install`.
104104
#
105105
# `react_native_path` locates the real podspecs we mirror. version + subspecs +
106-
# default_subspecs + resources are all DERIVED from the real spec so the facade
107-
# stays graph- and resource-equivalent to the source pod. A facaded pod whose
108-
# real podspec can't be read is a hard error (see load_real_spec) — silently
109-
# shipping an empty facade would hide exactly the drift this guards against.
106+
# default_subspecs are DERIVED from the real spec so the facade stays
107+
# graph-equivalent to the source pod (resources are NOT carried — they live in
108+
# the prebuilt artifact; see the note in the loop). A facaded pod whose real
109+
# podspec can't be read is a hard error (see load_real_spec) — silently shipping
110+
# an empty facade would hide exactly the drift this guards against.
110111
def self.generate(react_native_path, install_root, version, ios_version)
111112
@@install_root = install_root.to_s
112113
abs_base = File.join(@@install_root, FACADE_RELDIR)
113114
FileUtils.mkdir_p(abs_base)
114115
FACADE_PODS.each do |name, podspec_rel_path|
115116
podspec_path = File.join(react_native_path.to_s, podspec_rel_path)
116117
real = load_real_spec(podspec_path, name)
117-
podspec_dir = File.dirname(podspec_path)
118118
dir = File.join(abs_base, name)
119119
FileUtils.mkdir_p(dir)
120120

@@ -133,17 +133,11 @@ def self.generate(react_native_path, install_root, version, ios_version)
133133
"dependencies" => { "React-Core-prebuilt" => [] },
134134
}
135135

136-
# Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They
137-
# don't shadow headers, and React-Core-prebuilt doesn't vend them, so the
138-
# facade must carry them or prebuilt installs lose them. The matched
139-
# files are COPIED into the facade dir (like the re-exposed headers):
140-
# CocoaPods file accessors only match globs against files under the pod
141-
# root, so a `..`-escaping glob back into the source tree would silently
142-
# match nothing and the bundles would ship empty.
143-
resource_bundles = derive_resource_bundles(real, podspec_dir, dir)
144-
spec["resource_bundles"] = resource_bundles unless resource_bundles.empty?
145-
resources = derive_resources(real, podspec_dir, dir)
146-
spec["resources"] = resources unless resources.empty?
136+
# NOTE: the facade carries NO resources. The pods' non-code resources
137+
# (e.g. the privacy manifest) are embedded directly in the prebuilt
138+
# React.xcframework by the ios-prebuild compose (see ios-prebuild/privacy.js),
139+
# so they reach both CocoaPods-prebuilt and SwiftPM from the artifact —
140+
# the facade only needs to declare the React-Core-prebuilt dependency.
147141

148142
# Re-vend the narrow set of angle-only framework headers that community
149143
# modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is
@@ -190,8 +184,8 @@ def self.facade_path(name)
190184

191185
# Loads the real podspec so we can mirror its structure. A facaded pod MUST have
192186
# a readable real podspec — if it's missing or unparseable we raise rather than
193-
# ship an empty facade, since that would silently drop subspecs/resources (the
194-
# very drift this mechanism exists to prevent).
187+
# ship an empty facade, since that would silently drop subspecs (the very drift
188+
# this mechanism exists to prevent).
195189
def self.load_real_spec(path, name)
196190
unless File.exist?(path)
197191
raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \
@@ -212,22 +206,6 @@ def self.derive_subspecs(real)
212206
end
213207
private_class_method :derive_subspecs
214208

215-
# Effective resource_bundles of the real spec (e.g. React-Core_privacy),
216-
# copied into the facade dir under resources/<bundle>/. Unions the
217-
# `resource_bundle` (singular) and `resource_bundles` (plural) DSL forms.
218-
def self.derive_resource_bundles(real, podspec_dir, facade_dir)
219-
out = {}
220-
[real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb|
221-
next unless rb.is_a?(Hash)
222-
rb.each do |bundle, globs|
223-
copied = copy_resources(Array(globs), podspec_dir, facade_dir, File.join("resources", bundle))
224-
out[bundle] = copied unless copied.empty?
225-
end
226-
end
227-
out
228-
end
229-
private_class_method :derive_resource_bundles
230-
231209
# Copy the re-exposed header(s) into the facade dir (flat) and return the
232210
# facade-relative source_files entries. `globs` are resolved against the real
233211
# podspec dir. A glob that matches nothing is a hard error: the whole point is
@@ -250,33 +228,4 @@ def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name)
250228
copied.uniq
251229
end
252230
private_class_method :copy_reexposed_headers
253-
254-
# Loose `resources` of the real spec, copied into the facade dir.
255-
def self.derive_resources(real, podspec_dir, facade_dir)
256-
copy_resources(Array(real.attributes_hash["resources"]), podspec_dir, facade_dir, "resources")
257-
end
258-
private_class_method :derive_resources
259-
260-
# Copies everything the resource globs match (files or whole directories,
261-
# e.g. *.lproj bundles) into `<facade_dir>/<subdir>/` and returns
262-
# facade-relative paths for the copies. Globs resolve against the real
263-
# podspec dir. A glob that matches nothing is tolerated — the source pod
264-
# would ship nothing for it either, so the facade stays equivalent.
265-
# rm_rf-before-cp_r keeps the snapshot fresh across repeated `pod install`s.
266-
def self.copy_resources(globs, podspec_dir, facade_dir, subdir)
267-
copied = []
268-
globs.each do |g|
269-
matches = Dir.glob(File.expand_path(g, podspec_dir))
270-
next if matches.empty?
271-
dest_dir = File.join(facade_dir, subdir)
272-
FileUtils.mkdir_p(dest_dir)
273-
matches.each do |src|
274-
FileUtils.rm_rf(File.join(dest_dir, File.basename(src)))
275-
FileUtils.cp_r(src, dest_dir)
276-
copied << File.join(subdir, File.basename(src))
277-
end
278-
end
279-
copied.uniq
280-
end
281-
private_class_method :copy_resources
282231
end
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @noflow
9+
*/
10+
11+
'use strict';
12+
13+
const {
14+
buildI18nStringsBundle,
15+
buildReactPrivacyManifest,
16+
collectLprojDirs,
17+
collectReactPrivacyManifestPaths,
18+
i18nBundleInfoPlist,
19+
mergePrivacyManifests,
20+
} = require('../framework-resources');
21+
const fs = require('fs');
22+
const os = require('os');
23+
const path = require('path');
24+
25+
// react-native package root (…/scripts/ios-prebuild/__tests__ -> …)
26+
const RN_PATH = path.resolve(__dirname, '..', '..', '..');
27+
28+
// Apple privacy manifest fixtures mirroring the real ones shipped by the pods
29+
// baked into React.framework.
30+
const reactCore = {
31+
NSPrivacyAccessedAPITypes: [
32+
{
33+
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp',
34+
NSPrivacyAccessedAPITypeReasons: ['C617.1'],
35+
},
36+
{
37+
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
38+
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
39+
},
40+
],
41+
NSPrivacyCollectedDataTypes: [],
42+
NSPrivacyTracking: false,
43+
};
44+
45+
const cxxreact = {
46+
NSPrivacyAccessedAPITypes: [
47+
{
48+
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp',
49+
NSPrivacyAccessedAPITypeReasons: ['C617.1'],
50+
},
51+
],
52+
NSPrivacyCollectedDataTypes: [],
53+
NSPrivacyTracking: false,
54+
};
55+
56+
describe('mergePrivacyManifests', () => {
57+
it('returns a valid empty manifest for no inputs', () => {
58+
expect(mergePrivacyManifests([])).toEqual({
59+
NSPrivacyAccessedAPITypes: [],
60+
NSPrivacyCollectedDataTypes: [],
61+
NSPrivacyTracking: false,
62+
});
63+
});
64+
65+
it('passes a single manifest through unchanged (by value)', () => {
66+
expect(mergePrivacyManifests([reactCore])).toEqual(reactCore);
67+
});
68+
69+
it('unions accessed-API categories, deduping reasons per category', () => {
70+
const merged = mergePrivacyManifests([reactCore, cxxreact]);
71+
const byType = Object.fromEntries(
72+
merged.NSPrivacyAccessedAPITypes.map(e => [
73+
e.NSPrivacyAccessedAPIType,
74+
e.NSPrivacyAccessedAPITypeReasons,
75+
]),
76+
);
77+
// FileTimestamp appears in both -> single entry, reason deduped.
78+
expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(2);
79+
expect(byType.NSPrivacyAccessedAPICategoryFileTimestamp).toEqual([
80+
'C617.1',
81+
]);
82+
expect(byType.NSPrivacyAccessedAPICategoryUserDefaults).toEqual(['CA92.1']);
83+
});
84+
85+
it('unions reasons across manifests for the same category', () => {
86+
const a = {
87+
NSPrivacyAccessedAPITypes: [
88+
{
89+
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
90+
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
91+
},
92+
],
93+
};
94+
const b = {
95+
NSPrivacyAccessedAPITypes: [
96+
{
97+
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
98+
NSPrivacyAccessedAPITypeReasons: ['1C8F.1'],
99+
},
100+
],
101+
};
102+
const merged = mergePrivacyManifests([a, b]);
103+
expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(1);
104+
expect(
105+
merged.NSPrivacyAccessedAPITypes[0].NSPrivacyAccessedAPITypeReasons.sort(),
106+
).toEqual(['1C8F.1', 'CA92.1']);
107+
});
108+
109+
it('ORs NSPrivacyTracking and unions tracking domains', () => {
110+
const a = {NSPrivacyTracking: false, NSPrivacyTrackingDomains: ['a.com']};
111+
const b = {
112+
NSPrivacyTracking: true,
113+
NSPrivacyTrackingDomains: ['a.com', 'b.com'],
114+
};
115+
const merged = mergePrivacyManifests([a, b]);
116+
expect(merged.NSPrivacyTracking).toBe(true);
117+
expect(merged.NSPrivacyTrackingDomains.sort()).toEqual(['a.com', 'b.com']);
118+
});
119+
120+
it('unions collected data types, deduping structurally-equal entries', () => {
121+
const entry = {
122+
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData',
123+
NSPrivacyCollectedDataTypeLinked: false,
124+
};
125+
const merged = mergePrivacyManifests([
126+
{NSPrivacyCollectedDataTypes: [entry]},
127+
{NSPrivacyCollectedDataTypes: [{...entry}]},
128+
]);
129+
expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1);
130+
});
131+
});
132+
133+
describe('buildReactPrivacyManifest (against the real source tree)', () => {
134+
it('discovers React-core PrivacyInfo.xcprivacy files (not third-party deps)', () => {
135+
const paths = collectReactPrivacyManifestPaths(RN_PATH);
136+
expect(paths.length).toBeGreaterThan(0);
137+
// third-party-podspecs manifests belong to ReactNativeDependencies, not React.framework
138+
expect(paths.some(p => p.includes('third-party-podspecs'))).toBe(false);
139+
// React-Core's manifest is the canonical one that must be present
140+
expect(
141+
paths.some(p => p.endsWith('React/Resources/PrivacyInfo.xcprivacy')),
142+
).toBe(true);
143+
});
144+
145+
it('merges them into one manifest covering the known React-core API usages', () => {
146+
const merged = buildReactPrivacyManifest(RN_PATH);
147+
expect(merged).not.toBeNull();
148+
const categories = (merged?.NSPrivacyAccessedAPITypes ?? []).map(
149+
e => e.NSPrivacyAccessedAPIType,
150+
);
151+
// FileTimestamp + UserDefaults are declared by React-Core; both must survive the merge.
152+
expect(categories).toContain('NSPrivacyAccessedAPICategoryFileTimestamp');
153+
expect(categories).toContain('NSPrivacyAccessedAPICategoryUserDefaults');
154+
// No category should be duplicated after merging.
155+
expect(new Set(categories).size).toBe(categories.length);
156+
});
157+
});
158+
159+
describe('i18nBundleInfoPlist', () => {
160+
it('is a valid resource-bundle Info.plist dict', () => {
161+
const info = i18nBundleInfoPlist();
162+
expect(info.CFBundlePackageType).toBe('BNDL');
163+
expect(info.CFBundleName).toBe('RCTI18nStrings');
164+
expect(typeof info.CFBundleIdentifier).toBe('string');
165+
expect(info.CFBundleIdentifier.length).toBeGreaterThan(0);
166+
expect(typeof info.CFBundleDevelopmentRegion).toBe('string');
167+
});
168+
});
169+
170+
describe('collectLprojDirs (against the real source tree)', () => {
171+
it('finds the React i18n .lproj locale dirs', () => {
172+
const dirs = collectLprojDirs(RN_PATH);
173+
expect(dirs.length).toBeGreaterThan(0);
174+
expect(dirs.every(d => d.endsWith('.lproj'))).toBe(true);
175+
// English is the canonical base locale and must be present.
176+
expect(dirs.some(d => path.basename(d) === 'en.lproj')).toBe(true);
177+
});
178+
});
179+
180+
describe('buildI18nStringsBundle', () => {
181+
let tmp;
182+
183+
beforeEach(() => {
184+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-bundle-'));
185+
});
186+
187+
afterEach(() => {
188+
fs.rmSync(tmp, {recursive: true, force: true});
189+
});
190+
191+
it('builds RCTI18nStrings.bundle with the .lproj dirs and an Info.plist', () => {
192+
const out = path.join(tmp, 'RCTI18nStrings.bundle');
193+
const count = buildI18nStringsBundle(RN_PATH, out);
194+
195+
expect(count).toBeGreaterThan(0);
196+
expect(fs.existsSync(path.join(out, 'Info.plist'))).toBe(true);
197+
expect(fs.existsSync(path.join(out, 'en.lproj'))).toBe(true);
198+
// the copied locale carries its actual strings file(s)
199+
expect(fs.readdirSync(path.join(out, 'en.lproj')).length).toBeGreaterThan(
200+
0,
201+
);
202+
// count matches the number of .lproj dirs copied
203+
const copied = fs.readdirSync(out).filter(e => e.endsWith('.lproj'));
204+
expect(copied.length).toBe(count);
205+
});
206+
207+
it('returns 0 and writes nothing when there are no .lproj dirs', () => {
208+
const emptyRn = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-rn-'));
209+
const out = path.join(tmp, 'RCTI18nStrings.bundle');
210+
const count = buildI18nStringsBundle(emptyRn, out);
211+
expect(count).toBe(0);
212+
expect(fs.existsSync(out)).toBe(false);
213+
fs.rmSync(emptyRn, {recursive: true, force: true});
214+
});
215+
});

0 commit comments

Comments
 (0)