Skip to content

Commit a9e8b06

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 1f6cdb2 commit a9e8b06

6 files changed

Lines changed: 527 additions & 52 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 & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,18 @@ def self.facade?(name)
7272
# call once per `pod install`.
7373
#
7474
# `react_native_path` locates the real podspecs we mirror. version + subspecs +
75-
# default_subspecs + resources are all DERIVED from the real spec so the facade
76-
# stays graph- and resource-equivalent to the source pod. A facaded pod whose
77-
# real podspec can't be read is a hard error (see load_real_spec) — silently
78-
# shipping an empty facade would hide exactly the drift this guards against.
75+
# default_subspecs are DERIVED from the real spec so the facade stays
76+
# graph-equivalent to the source pod (resources are NOT carried — they live in
77+
# the prebuilt artifact; see the note in the loop). A facaded pod whose real
78+
# podspec can't be read is a hard error (see load_real_spec) — silently shipping
79+
# an empty facade would hide exactly the drift this guards against.
7980
def self.generate(react_native_path, install_root, version, ios_version)
8081
@@install_root = install_root.to_s
8182
abs_base = File.join(@@install_root, FACADE_RELDIR)
8283
FileUtils.mkdir_p(abs_base)
8384
FACADE_PODS.each do |name, podspec_rel_path|
8485
podspec_path = File.join(react_native_path.to_s, podspec_rel_path)
8586
real = load_real_spec(podspec_path, name)
86-
podspec_dir = File.dirname(podspec_path)
8787
dir = File.join(abs_base, name)
8888
FileUtils.mkdir_p(dir)
8989

@@ -102,14 +102,11 @@ def self.generate(react_native_path, install_root, version, ios_version)
102102
"dependencies" => { "React-Core-prebuilt" => [] },
103103
}
104104

105-
# Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They
106-
# don't shadow headers, and React-Core-prebuilt doesn't vend them, so the
107-
# facade must carry them or prebuilt installs lose them. Globs are made
108-
# relative to the facade dir so they resolve back to the real source tree.
109-
resource_bundles = derive_resource_bundles(real, podspec_dir, dir)
110-
spec["resource_bundles"] = resource_bundles unless resource_bundles.empty?
111-
resources = derive_resources(real, podspec_dir, dir)
112-
spec["resources"] = resources unless resources.empty?
105+
# NOTE: the facade carries NO resources. The pods' non-code resources
106+
# (e.g. the privacy manifest) are embedded directly in the prebuilt
107+
# React.xcframework by the ios-prebuild compose (see ios-prebuild/privacy.js),
108+
# so they reach both CocoaPods-prebuilt and SwiftPM from the artifact —
109+
# the facade only needs to declare the React-Core-prebuilt dependency.
113110

114111
# Preserve default_subspec so a bare `pod '<Name>'` resolves to the SAME
115112
# subspec graph as the source pod (without it CocoaPods pulls every
@@ -138,8 +135,8 @@ def self.facade_path(name)
138135

139136
# Loads the real podspec so we can mirror its structure. A facaded pod MUST have
140137
# a readable real podspec — if it's missing or unparseable we raise rather than
141-
# ship an empty facade, since that would silently drop subspecs/resources (the
142-
# very drift this mechanism exists to prevent).
138+
# ship an empty facade, since that would silently drop subspecs (the very drift
139+
# this mechanism exists to prevent).
143140
def self.load_real_spec(path, name)
144141
unless File.exist?(path)
145142
raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \
@@ -159,36 +156,4 @@ def self.derive_subspecs(real)
159156
.map(&:base_name)
160157
end
161158
private_class_method :derive_subspecs
162-
163-
# Effective resource_bundles of the real spec (e.g. React-Core_privacy), with
164-
# globs rewritten relative to the facade dir so they point back at the real
165-
# source files. Unions the `resource_bundle` (singular) and `resource_bundles`
166-
# (plural) DSL forms.
167-
def self.derive_resource_bundles(real, podspec_dir, facade_dir)
168-
out = {}
169-
[real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb|
170-
next unless rb.is_a?(Hash)
171-
rb.each do |bundle, globs|
172-
out[bundle] = Array(globs).map { |g| rel_glob(g, podspec_dir, facade_dir) }
173-
end
174-
end
175-
out
176-
end
177-
private_class_method :derive_resource_bundles
178-
179-
# Loose `resources` of the real spec, rewritten relative to the facade dir.
180-
def self.derive_resources(real, podspec_dir, facade_dir)
181-
Array(real.attributes_hash["resources"]).map { |g| rel_glob(g, podspec_dir, facade_dir) }
182-
end
183-
private_class_method :derive_resources
184-
185-
# Rewrite a glob declared relative to `podspec_dir` into one relative to
186-
# `facade_dir`, so the generated facade (which lives under the app's build/)
187-
# still resolves the resource in the react-native source tree.
188-
def self.rel_glob(glob, podspec_dir, facade_dir)
189-
require "pathname"
190-
abs = File.expand_path(glob, podspec_dir)
191-
Pathname.new(abs).relative_path_from(Pathname.new(facade_dir)).to_s
192-
end
193-
private_class_method :rel_glob
194159
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)