From 5ad6306d0a1027dd05ef85701aa0865b59a6be1a Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Sun, 4 Oct 2026 21:56:09 +0200 Subject: [PATCH 1/4] SwiftPM: move injected build settings and script bodies out of project.pbxproj `spm add` and `spm update` now write React Native's build settings to ReactNativeSPM/ReactNativeSPM.xcconfig and the two build-script bodies to ReactNativeSPM/Scripts/. The project's build configurations use the xcconfig as their base, and the build phases only run the script files. A version bump then leaves project.pbxproj unchanged (local-path mode). REACT_NATIVE_PATH is now $(SRCROOT)-relative through node_modules/react-native, not a resolved pnpm store path. --- .../__tests__/generate-spm-xcodeproj-test.js | 74 ++ .../__tests__/inject-spm-xcodeproj-test.js | 441 +++++++++--- .../__tests__/remove-spm-injection-test.js | 436 ++++++++--- .../scripts/spm/__tests__/spm-pbxproj-test.js | 37 + .../scripts/spm/generate-spm-xcodeproj.js | 680 +++++++++++------- .../react-native/scripts/spm/spm-pbxproj.js | 11 +- 6 files changed, 1217 insertions(+), 462 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index f5b1145edf3a..e3a12d771e8c 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -17,6 +17,7 @@ const { buildSyncAutolinkingScript, flavorForBuildConfiguration, frameworkConditionalSettings, + generateReactNativeXcconfig, generateXcscheme, readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); @@ -358,6 +359,79 @@ describe('configuration-driven framework settings', () => { }); }); +describe('generateReactNativeXcconfig', () => { + const DEVICE_ONLY = {...FRAMEWORK, slices: [FRAMEWORK.slices[0]]}; + const configs = names => + names.map((name, i) => ({uuid: `AA00000000000000000009${i}0`, name})); + + it('writes every setting the app target needs, extending inherited lists', () => { + expect( + generateReactNativeXcconfig( + configs(['Debug', 'Release']), + '../node_modules/react-native', + [DEVICE_ONLY], + ), + ).toBe( + `// Generated by React Native on every \`spm add\` and \`spm update\`. +// Do not edit: changes are overwritten. + +CLANG_CXX_LANGUAGE_STANDARD = c++20 +REACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native + +HEADER_SEARCH_PATHS = $(inherited) "$(SRCROOT)/build/generated/autolinking/headers" +OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)" +FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)" +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks + +RN_SPM_FLAVOR[config=Debug] = debug +SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG +RN_SPM_FLAVOR[config=Release] = release + +RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework +RN_SPM_REACT_BINARY[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework/React +RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64 +`, + ); + }); + + it('anchors REACT_NATIVE_PATH on SRCROOT itself when react-native sits there', () => { + expect( + generateReactNativeXcconfig(configs(['Debug']), '', [FRAMEWORK]), + ).toContain('\nREACT_NATIVE_PATH = $(SRCROOT)\n'); + }); + + it("conditions the flavor on the project's own configuration names", () => { + const xcconfig = generateReactNativeXcconfig( + configs(['Development', 'Staging']), + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Development] = debug\n'); + expect(xcconfig).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Development] = $(inherited) DEBUG\n', + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Staging] = release\n'); + expect(xcconfig).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Staging]', + ); + expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/); + }); + + it('writes no plist quoting around conditional keys or path values', () => { + const xcconfig = generateReactNativeXcconfig( + configs(['Debug']), + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain( + '\nRN_SPM_REACT_BINARY[sdk=iphonesimulator*] = $(SRCROOT)/', + ); + expect(xcconfig).toContain('\nRN_SPM_REACT_SEARCH_PATH[sdk=macosx*] = '); + expect(xcconfig).not.toMatch(/^"/m); + expect(xcconfig).not.toContain('= "$(SRCROOT)/build/xcframeworks'); + }); +}); + describe('embed framework phase script', () => { const script = buildEmbedFrameworksScript([FRAMEWORK]); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 3d4093c9334c..57620c302f2b 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -12,9 +12,12 @@ const { buildPhaseOrder, + buildSyncAutolinkingScript, + generateReactNativeXcconfig, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {findField, findObjectByUuid, quoteIfNeeded} = require('../spm-pbxproj'); const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); const path = require('node:path'); @@ -56,17 +59,41 @@ function buildSettingsOf(text, configUuid) { ); return text.slice(open, text.indexOf('};', open)); } -// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, -// set to any valid pbxproj value: a plain scalar (which injection promotes to an -// array) or an array injection appends to. -function withHeaderSearchPaths(value) { +// Seed a whole `KEY = value;` field into both app-target configs. +function withAppSetting(field) { return PLAIN.replaceAll( 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', - `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, ); } +function withHeaderSearchPaths(value) { + return withAppSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +// The app target's Debug config already based on an xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + DEBUG_CONFIG_HEAD, + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n\t\t\tbuildSettings = {', +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +const XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_SCRIPT_PATH = 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_SCRIPT_PATH = 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + +// The raw (still plist-quoted) shellScript value of the phase labelled `label`. +function shellScriptOf(text, label) { + const uuid = new RegExp(`([0-9A-F]{24}) /\\* ${label} \\*/ = \\{`).exec( + text, + )[1]; + return findField(text, findObjectByUuid(text, uuid), 'shellScript').value; +} + const RN_PATH = '../node_modules/react-native'; +const RN_PATHS = {fromAppRoot: RN_PATH, fromSrcRoot: RN_PATH}; const TEST_FRAMEWORKS = [ { id: 'react', @@ -117,7 +144,7 @@ function inject( frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, remote, generatedSources, TEST_FRAMEWORKS, @@ -179,6 +206,68 @@ describe('planInjection', () => { expect(plan.ok).toBe(false); expect(plan.reason).toMatch(/no application target/); }); + + // The generated xcconfig sits below the target's own settings, so a list + // setting the target sets without $(inherited) hides React Native's. + it.each([ + ['HEADER_SEARCH_PATHS', '"$(SRCROOT)/Vendor"'], + ['OTHER_LDFLAGS', '(\n\t\t\t\t\t"-lc++",\n\t\t\t\t)'], + ['FRAMEWORK_SEARCH_PATHS', '""'], + ['LD_RUNPATH_SEARCH_PATHS', '"@executable_path/Frameworks"'], + ])('refuses %s set without $(inherited), naming it', (key, value) => { + const plan = planInjection(withAppSetting(`${key} = ${value};`), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain(`${key} (Debug, Release)`); + expect(plan.reason).toContain('$(inherited)'); + }); + + it('refuses SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a debug configuration', () => { + const plan = planInjection(withDebugCondition(PLAIN, 'MY_FLAG'), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS (Debug)', + ); + }); + + it('accepts SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a release configuration, which gets none', () => { + const releaseHead = DEBUG_CONFIG_HEAD.replace( + 'AA0000000000000000000901 /* Debug */', + 'AA00000000000000000000A2 /* Release */', + ); + const text = PLAIN.replace( + releaseHead, + `${releaseHead}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = MY_FLAG;`, + ); + expect(text).not.toBe(PLAIN); + expect(planInjection(text, {}).ok).toBe(true); + }); + + it.each([ + ['a scalar', '"$(inherited) $(SRCROOT)/Vendor"'], + [ + 'an array', + '(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"$(SRCROOT)/Vendor",\n\t\t\t\t)', + ], + ['the brace form', '"${inherited} $(SRCROOT)/Vendor"'], + ])( + 'accepts a list setting that keeps $(inherited) in %s', + (_label, value) => { + expect(planInjection(withHeaderSearchPaths(value), {}).ok).toBe(true); + }, + ); + + it("refuses a configuration already based on the user's own xcconfig", () => { + const plan = planInjection(FOREIGN_XCCONFIG, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain('Debug'); + expect(plan.reason).toContain('Config/App.xcconfig'); + expect(plan.reason).toContain('#include'); + expect(plan.reason).toContain(XCCONFIG_PATH); + }); + + it('accepts a project it already based on the generated xcconfig', () => { + expect(planInjection(inject(PLAIN).text, {}).ok).toBe(true); + }); }); describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { @@ -225,71 +314,145 @@ describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { }); describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { - it('merges React build settings into BOTH build configurations', () => { + const PROJECT_DEBUG_CONFIG = 'AA0000000000000000000701'; + const PROJECT_RELEASE_CONFIG = 'AA0000000000000000000801'; + const ALL_CONFIGS = [ + PROJECT_DEBUG_CONFIG, + PROJECT_RELEASE_CONFIG, + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]; + const xcconfigRefOf = text => + /baseConfigurationReference = ([0-9A-F]{24}) \/\* ReactNativeSPM\.xcconfig \*\/;/.exec( + text, + )[1]; + + // A routine `spm update` must not touch project.pbxproj, so none of these may + // live in it — they are the ones whose values follow the installed frameworks. + it('writes no React build setting into any configuration', () => { const {text} = inject(PLAIN); - expect(text.match(/-ObjC/g)).toHaveLength(2); - expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); - expect(text).not.toContain('fmodule-map-file='); - expect(text).toContain('build/generated/autolinking/headers'); - expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( - 2, + for (const config of ALL_CONFIGS) { + const settings = buildSettingsOf(text, config); + expect(settings).toBe(buildSettingsOf(PLAIN, config)); + expect(settings).not.toMatch( + /RN_SPM_|OTHER_LDFLAGS|FRAMEWORK_SEARCH_PATHS|HEADER_SEARCH_PATHS|LD_RUNPATH_SEARCH_PATHS|SWIFT_ACTIVE_COMPILATION_CONDITIONS|CLANG_CXX_LANGUAGE_STANDARD|REACT_NATIVE_PATH/, + ); + } + }); + + it('bases every app configuration on the generated xcconfig, next to isa', () => { + const {text, injectedUuids, xcconfig} = inject(PLAIN); + const ref = xcconfigRefOf(text); + for (const [config, name] of [ + [APP_DEBUG_CONFIG, 'Debug'], + [APP_RELEASE_CONFIG, 'Release'], + ]) { + expect(text).toContain( + `${config} /* ${name} */ = {\n\t\t\tisa = XCBuildConfiguration;\n` + + `\t\t\tbaseConfigurationReference = ${ref} /* ReactNativeSPM.xcconfig */;\n` + + '\t\t\tbuildSettings = {', + ); + } + expect(text.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(xcconfig.configUuids).toEqual([ + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]); + + const fileRef = findObjectByUuid(text, ref); + const field = key => findField(text, fileRef, key)?.value; + expect(field('isa')).toBe('PBXFileReference'); + expect(field('lastKnownFileType')).toBe('text.xcconfig'); + expect(field('name')).toBe('ReactNativeSPM.xcconfig'); + expect(field('path')).toBe(XCCONFIG_PATH); + expect(field('sourceTree')).toBe('SOURCE_ROOT'); + expect(injectedUuids).toContain(ref); + // Visible in the navigator, at the top of the project. + const mainGroup = findObjectByUuid(text, 'AA00000000000000000000E1'); + expect(findField(text, mainGroup, 'children').value).toContain( + `${ref} /* ReactNativeSPM.xcconfig */,`, + ); + }); + + it('returns the xcconfig generated for the configurations based on it', () => { + const {generatedFiles} = inject(PLAIN); + expect(generatedFiles[XCCONFIG_PATH]).toBe( + generateReactNativeXcconfig( + [ + {uuid: APP_DEBUG_CONFIG, name: 'Debug'}, + {uuid: APP_RELEASE_CONFIG, name: 'Release'}, + ], + RN_PATH, + TEST_FRAMEWORKS, + ), ); - expect(text).toContain('RN_SPM_FLAVOR = debug'); - expect(text).toContain('RN_SPM_FLAVOR = release'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); - expect(text).toContain('$(RN_SPM_REACT_BINARY)'); }); // An absolute hermesc path is machine-specific, and the app commits its // project.pbxproj. react-native-xcode.sh resolves hermesc through // react-native's own dependency graph at build time instead. - it('never writes HERMES_CLI_PATH into either configuration', () => { - const {text, buildSettingChanges} = inject(PLAIN); + it('never writes HERMES_CLI_PATH, into the project or a generated file', () => { + const {text, generatedFiles} = inject(PLAIN); expect(text).not.toContain('HERMES_CLI_PATH'); - expect( - buildSettingChanges.flatMap(change => change.createdScalars ?? []), - ).not.toContain('HERMES_CLI_PATH'); + for (const content of Object.values(generatedFiles)) { + expect(content).not.toContain('HERMES_CLI_PATH'); + } }); - // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the - // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod - // install`; an SPM app has to get it here or a Debug build looks for a - // main.jsbundle it never built. - it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { - const {text} = inject(PLAIN); - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toMatch( - /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, - ); - expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + it.each([ + ['HEADER_SEARCH_PATHS', withHeaderSearchPaths('"$(inherited)"')], + [ + 'HEADER_SEARCH_PATHS', + withHeaderSearchPaths('(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t)'), + ], + [ 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', - ); + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ], + ])("leaves the configuration's own %s untouched", (_key, before) => { + const {text} = inject(before); + for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { + expect(buildSettingsOf(text, config)).toBe( + buildSettingsOf(before, config), + ); + } }); - it('leaves a config that already sets DEBUG (scalar form) untouched', () => { - const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); - // Not promoted to an array, not re-appended — DEBUG is already there. - expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( - 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + // The CocoaPods template anchors REACT_NATIVE_PATH on ${PODS_ROOT}, which + // resolves empty once CocoaPods is gone, and a target's own value outranks + // any xcconfig — so it has to leave the project for the xcconfig's to apply. + it('removes a ${PODS_ROOT}-anchored REACT_NATIVE_PATH so the xcconfig value applies', () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ); + const {text, xcconfig, generatedFiles} = inject(before); + for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { + expect(buildSettingsOf(text, config)).toBe( + buildSettingsOf(PLAIN, config), + ); + } + expect(xcconfig.removedPodsRootReactNativePath).toBe(true); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + `\nREACT_NATIVE_PATH = $(SRCROOT)/${RN_PATH}\n`, ); - expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); }); - it("adds DEBUG alongside the user's own compilation conditions", () => { - const {text} = inject( - withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + it("keeps a REACT_NATIVE_PATH of the project's own", () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = ../../node_modules/react-native;', ); - // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); - expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + const {text, xcconfig} = inject(before); + for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { + expect(buildSettingsOf(text, config)).toBe( + buildSettingsOf(before, config), + ); + } + expect(xcconfig.removedPodsRootReactNativePath).toBe(false); }); it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); - expect(text).toContain('npx react-native spm sync'); // It runs before Sources. const syncIdx = text.indexOf('Sync SPM Autolinking */,'); const sourcesIdx = text.indexOf('Sources */,'); @@ -297,6 +460,109 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + // The script bodies change whenever React Native's do, so they live in + // generated files; the phase only carries a wrapper that never changes. + it.each([ + ['Sync SPM Autolinking', SYNC_SCRIPT_PATH, 'npx react-native spm sync'], + [ + 'Embed React Native Flavored Frameworks', + EMBED_SCRIPT_PATH, + 'copy_and_sign "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ], + ])('runs the %s script from %s', (label, scriptPath, bodyLine) => { + const {text, generatedFiles} = inject(PLAIN); + const wrapper = shellScriptOf(text, label); + expect(wrapper).toContain(`$SRCROOT/${scriptPath}`); + expect(wrapper).toContain('spm update'); + expect(text).not.toContain(bodyLine.replace(/"/g, '\\"')); + expect(text).not.toContain('set -euo pipefail'); + expect(generatedFiles[scriptPath]).toContain(bodyLine); + }); + + it('keeps the script wrappers identical across React Native versions and frameworks', () => { + const {text} = inject(PLAIN); + const plan = planInjection(PLAIN, {}); + const {text: other} = injectSpmIntoPbxproj( + PLAIN, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + { + fromAppRoot: '../../elsewhere/react-native', + fromSrcRoot: '../../elsewhere/react-native', + }, + null, + [], + [], + ); + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(shellScriptOf(other, label)).toBe(shellScriptOf(text, label)); + } + }); + + // The actual goal of moving settings into the generated xcconfig: a routine + // `spm update` (a new React Native version, new framework artifact slices) + // must not touch project.pbxproj AT ALL when the set of frameworks (their + // ids/names) doesn't change shape — not just the two script phases checked + // above. Only the id/frameworkName/artifactRelativePath drive anything that + // lands in the pbxproj (variable names in the script bodies); slice paths + // and architectures land only in the generated xcconfig. + it('produces a byte-identical project.pbxproj across a version bump (path + slice content change)', () => { + const {text} = inject(PLAIN); + const plan = planInjection(PLAIN, {}); + const BUMPED_FRAMEWORKS = [ + { + ...TEST_FRAMEWORKS[0], + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64', 'arm64e'], + libraryIdentifier: 'ios-arm64_arm64e', + libraryPath: 'React.framework', + binaryPath: 'React.framework/Versions/Current/React', + }, + { + sdk: 'iphonesimulator*', + platform: 'ios', + variant: 'simulator', + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64-simulator', + libraryPath: 'React.framework', + binaryPath: 'React.framework/Versions/Current/React', + }, + ], + }, + ]; + const {text: other} = injectSpmIntoPbxproj( + PLAIN, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + { + fromAppRoot: '../../elsewhere/react-native', + fromSrcRoot: '../../elsewhere/react-native', + }, + null, + [], + BUMPED_FRAMEWORKS, + [], + ); + expect(other).toBe(text); + }); + it('runs every injected shell-script build phase under bash, not /bin/sh', () => { // Their bodies start with `set -euo pipefail`, which a non-bash /bin/sh // (e.g. dash) rejects at runtime. @@ -326,50 +592,6 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { ); }); - it.each([ - [ - '"$(inherited)"', - ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], - ], - [ - '"$(inherited) $(SRCROOT)/vendor/include"', - [ - '"$(inherited)"', - '"$(inherited) $(SRCROOT)/vendor/include"', - '"$(SRCROOT)/build/generated/autolinking/headers"', - ], - ], - ])( - 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', - (scalar, expectedMembers) => { - const {text} = inject(withHeaderSearchPaths(scalar)); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), - ].map(m => - m[1] - .split('\n') - .map(line => line.trim().replace(/,$/, '')) - .filter(member => member.length > 0), - ); - // Both app-target configs (Debug + Release). - expect(arrays).toEqual([expectedMembers, expectedMembers]); - }, - ); - - it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { - const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); - expect(isBalanced(text)).toBe(true); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), - ].map(m => m[1]); - // Both app-target configs, each keeping the one-line shape it was written in. - expect(arrays).toEqual( - Array(2).fill( - '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', - ), - ); - }); - it('adds one generated embed phase immediately after Frameworks', () => { const {text} = inject(PLAIN); expect(text).not.toContain('Fix SPM Embedded Flavor'); @@ -445,7 +667,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -484,7 +706,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -1118,22 +1340,24 @@ describe('injectSpmIntoPbxproj — invariants', () => { }); it('is idempotent — a second injection is a byte-for-byte no-op', () => { - const first = inject(PLAIN).text; - const plan = planInjection(first, {}); + const first = inject(PLAIN); + const plan = planInjection(first.text, {}); const second = injectSpmIntoPbxproj( - first, + first.text, { rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, - ).text; - expect(second).toBe(first); + ); + expect(second.text).toBe(first.text); + expect(second.generatedFiles).toEqual(first.generatedFiles); + expect(second.xcconfig).toEqual(first.xcconfig); }); it('keeps the diff small — only adds lines, never removes original ones', () => { @@ -1150,15 +1374,13 @@ describe('injectSpmIntoPbxproj — invariants', () => { expect(added).toBeLessThan(220); }); - it('refreshes a stale shellScript on re-injection', () => { + it('replaces an inline script body from an earlier version with the wrapper', () => { const first = inject(PLAIN).text; - // Simulate an earlier run whose generated script has since changed (e.g. - // fixed dispatch logic) by corrupting a substring of the baked-in script. const stale = first.replace( - 'npx react-native spm sync', - 'STALE_OLD_SYNC_COMMAND', + shellScriptOf(first, 'Sync SPM Autolinking'), + quoteIfNeeded(buildSyncAutolinkingScript(RN_PATH)), ); - expect(stale).not.toBe(first); + expect(stale).toContain('npx react-native spm sync'); const plan = planInjection(stale, {}); const second = injectSpmIntoPbxproj( stale, @@ -1168,14 +1390,11 @@ describe('injectSpmIntoPbxproj — invariants', () => { configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, ).text; - // The stale marker is gone and the current script is restored. - expect(second).not.toContain('STALE_OLD_SYNC_COMMAND'); - expect(second).toContain('npx react-native spm sync'); expect(second).toBe(first); }); diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 2974e310af4e..f6931f17de22 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -76,6 +76,11 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { ); const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); fs.mkdirSync(rnRoot, {recursive: true}); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function seedArtifacts(appRoot /*: string */) { const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); fs.mkdirSync(artifactRoot, {recursive: true}); fs.writeFileSync( @@ -83,7 +88,6 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { JSON.stringify({version: 1, frameworks: []}), ); fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); - return {appRoot, xcodeprojPath, rnRoot}; } // The hoisted `hermes-compiler` layout a real installed app has: the package @@ -167,6 +171,21 @@ function readMarker(xcodeprojPath) { return JSON.parse(markerTextOf(xcodeprojPath)); } +const XCCONFIG_REL = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const GENERATED_FILES = [ + XCCONFIG_REL, + 'ReactNativeSPM/Scripts/sync-autolinking.sh', + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh', +]; + +function generatedFileContents(appRoot) { + return GENERATED_FILES.map(file => + fs.existsSync(path.join(appRoot, file)) + ? fs.readFileSync(path.join(appRoot, file), 'utf8') + : null, + ); +} + function schemePathOf(xcodeprojPath) { return path.join( xcodeprojPath, @@ -204,11 +223,125 @@ describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => { expect(pbxprojOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); expect(pbxprojOf(xcodeprojPath)).not.toContain(hermesc); + for (const file of GENERATED_FILES) { + expect(fs.readFileSync(path.join(appRoot, file), 'utf8')).not.toContain( + 'HERMES_CLI_PATH', + ); + } + expect(markerTextOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); + }); +}); + +// pnpm's layout: the project's node_modules/react-native links into a store +// directory named after the version and dependency hash, and the Xcode project +// sits one level down, in ios/. Config commands report the store path. +function scaffoldPnpmApp() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pnpm-')); + scaffoldedAppRoots.push(projectRoot); + const nodeModules = path.join(projectRoot, 'node_modules'); + const store = path.join( + nodeModules, + '.pnpm', + 'react-native@0.87.0_abc123', + 'node_modules', + 'react-native', + ); + fs.mkdirSync(store, {recursive: true}); + fs.symlinkSync( + path.relative(nodeModules, store), + path.join(nodeModules, 'react-native'), + ); + const appRoot = path.join(projectRoot, 'ios'); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot: fs.realpathSync(store)}; +} + +function bakedFilesOf(appRoot, xcodeprojPath) { + return [ + ...generatedFileContents(appRoot), + fs.readFileSync(schemePathOf(xcodeprojPath), 'utf8'), + ]; +} + +// The xcconfig, scripts and scheme are committed, so they must name +// react-native the way the app resolves it, not by its store location. +describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { + it('writes the node_modules link, not the pnpm store path', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + expect(rnRoot).toContain('.pnpm'); + expect( - readMarker(xcodeprojPath).buildSettingChanges.flatMap( - change => change.createdScalars ?? [], - ), - ).not.toContain('HERMES_CLI_PATH'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + for (const content of bakedFilesOf(appRoot, xcodeprojPath)) { + expect(content).not.toContain('.pnpm'); + } + expect( + fs.readFileSync(path.join(appRoot, GENERATED_FILES[1]), 'utf8'), + ).toContain('RN_DIR="../node_modules/react-native"'); + }); + + it('prefers the nearest node_modules link to react-native', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules')); + fs.symlinkSync(rnRoot, path.join(appRoot, 'node_modules', 'react-native')); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/node_modules/react-native\n', + ); + }); + + it('skips a nearer node_modules/react-native that is another install', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules', 'react-native'), { + recursive: true, + }); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + }); + + it('falls back to the given path when no node_modules link resolves to it', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const vendored = path.join(appRoot, 'vendor', 'react-native'); + fs.mkdirSync(vendored, {recursive: true}); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: vendored, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/vendor/react-native\n', + ); + expect( + fs.readFileSync(path.join(appRoot, GENERATED_FILES[1]), 'utf8'), + ).toContain('RN_DIR="vendor/react-native"'); }); }); @@ -228,11 +361,15 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( true, ); + expect(generatedFileContents(appRoot)).not.toContain(null); const removed = removeSpmInjection({appRoot, xcodeprojPath}); expect(removed.status).toBe('removed'); // Byte-identical to the pre-add pbxproj. expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); // Marker is gone. expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( false, @@ -258,12 +395,14 @@ describe('removeSpmInjection — the surgical inverse of add', () => { sync(); const injected = pbxprojOf(xcodeprojPath); const marker = markerTextOf(xcodeprojPath); + const files = generatedFileContents(appRoot); const schemePath = schemePathOf(xcodeprojPath); expect(fs.existsSync(schemePath)).toBe(true); sync(); - // The re-sync changed neither the project… + // The re-sync changed neither the project, nor the files it generates… expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(generatedFileContents(appRoot)).toEqual(files); // …nor the record of what has to be undone. expect(markerTextOf(xcodeprojPath)).toBe(marker); @@ -374,6 +513,178 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(removed.status).toBe('removed'); expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + + it('keeps a base configuration the user re-pointed after add, and the xcconfig it may include', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const repointed = pbxprojOf(xcodeprojPath).replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//g, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + repointed, + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect( + pbxprojOf(xcodeprojPath).match( + /baseConfigurationReference = CC0000000000000000000001 \/\* App\.xcconfig \*\/;/g, + ), + ).toHaveLength(2); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + // A ${PODS_ROOT}-anchored value resolves empty without CocoaPods, so putting + // it back would only restore a broken setting. + it('does not restore the ${PODS_ROOT}-anchored REACT_NATIVE_PATH it removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toContain('PODS_ROOT'); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + // Sticky: the update that follows no longer sees the value it removed. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + it('refuses a setting without $(inherited) and leaves the project untouched', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting('HEADER_SEARCH_PATHS = ;'), + ); + const before = pbxprojOf(xcodeprojPath); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); +}); + +// --------------------------------------------------------------------------- +// A project injected before the settings moved to the generated xcconfig +// carries them in project.pbxproj, recorded per configuration in the marker's +// `buildSettingChanges`. The next sync reverses that record first, so the +// project moves over on its own. +// --------------------------------------------------------------------------- +describe('a project injected with settings in project.pbxproj', () => { + const DEBUG = 'AA0000000000000000000901'; + const RELEASE = 'AA00000000000000000000A2'; + + function scaffoldLegacyApp(fields, change) { + const app = scaffoldApp( + fields.length > 0 ? withSetting(fields.join('\n\t\t\t\t')) : PLAIN, + ); + fs.writeFileSync( + path.join(app.xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify({ + target: 'MyApp', + targetUuid: 'AA0000000000000000000301', + injectedUuids: [], + createdArrayFields: [], + buildSettingChanges: [DEBUG, RELEASE].map(configUuid => ({ + configUuid, + createdArrayKeys: [], + appendedArrayValues: {}, + createdScalars: [], + ...change, + })), + }), + ); + return app; + } + + it('moves them into the generated xcconfig on the next sync', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + 'REACT_NATIVE_PATH = ../node_modules/react-native;', + 'RN_SPM_FLAVOR = debug;', + ], + { + createdArrayKeys: ['OTHER_LDFLAGS'], + createdScalars: ['REACT_NATIVE_PATH', 'RN_SPM_FLAVOR'], + }, + ); + expect( + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + const migrated = pbxprojOf(xcodeprojPath); + expect(migrated).not.toMatch( + /OTHER_LDFLAGS|REACT_NATIVE_PATH|RN_SPM_FLAVOR/, + ); + expect(migrated.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('buildSettingChanges'); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + // The setting the user had before the promotion lacks $(inherited), which + // only shows once the promotion is reversed. + it('refuses when reversing a promotion leaves a setting without $(inherited)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-lc++",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + ], + {promotedArrayScalars: {OTHER_LDFLAGS: '"-lc++"'}}, + ); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('OTHER_LDFLAGS (Debug, Release)'); + }); + + it('does not resurrect a promoted setting the user has since deleted', () => { + const {appRoot, xcodeprojPath} = scaffoldLegacyApp([], { + promotedArrayScalars: { + HEADER_SEARCH_PATHS: '"$(inherited) $(SRCROOT)/vendor/include"', + }, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); }); // --------------------------------------------------------------------------- @@ -1310,32 +1621,32 @@ describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( // findField's token for a BARE scalar ends AT the `;`, so it includes any // whitespace before it. Deinit must put those bytes back exactly, not a // tidied-up version of them. -describe.each([ - 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', - 'HEADER_SEARCH_PATHS = ;', -])('removeSpmInjection with the untrimmed scalar `%s`', field => { - it('restores it byte-for-byte', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); - const before = pbxprojOf(xcodeprojPath); +describe.each(['HEADER_SEARCH_PATHS = $(inherited) ; /* note */'])( + 'removeSpmInjection with the untrimmed scalar `%s`', + field => { + it('restores it byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); + const before = pbxprojOf(xcodeprojPath); - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); - expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); - expect(pbxprojOf(xcodeprojPath)).toBe(before); - }); -}); + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + }, +); -describe('a scalar array setting injection has nothing to add to', () => { +describe("a list setting of the project's own that keeps $(inherited)", () => { const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; - // The fixture's flavored-frameworks manifest is empty, so - // FRAMEWORK_SEARCH_PATHS is injected with no values at all. it('is left untouched, unrecorded, and survives a later user edit', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); @@ -1348,11 +1659,7 @@ describe('a scalar array setting injection has nothing to add to', () => { const injected = pbxprojOf(xcodeprojPath); expect(injected).toContain(SCALAR); expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); - for (const change of readMarker(xcodeprojPath).buildSettingChanges) { - expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( - 'FRAMEWORK_SEARCH_PATHS', - ); - } + expect(markerTextOf(xcodeprojPath)).not.toContain('FRAMEWORK_SEARCH_PATHS'); fs.writeFileSync( path.join(xcodeprojPath, 'project.pbxproj'), @@ -1366,70 +1673,3 @@ describe('a scalar array setting injection has nothing to add to', () => { expect(after).not.toContain(SCALAR); }); }); - -describe('a promoted array setting the user deleted after add', () => { - const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; - - it('is not resurrected by deinit', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths(SCALAR), - ); - const before = pbxprojOf(xcodeprojPath); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const deleted = pbxprojOf(xcodeprojPath).replace( - /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, - '', - ); - expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); - fs.writeFileSync( - path.join(xcodeprojPath, 'project.pbxproj'), - deleted, - 'utf8', - ); - - removeSpmInjection({appRoot, xcodeprojPath}); - - // Everything else is back to its pre-injection bytes; only the setting the - // user deleted stays gone. - expect(pbxprojOf(xcodeprojPath)).toBe( - before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), - ); - }); -}); - -// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, -// so recording a key under both happens to come out right today: the scalar -// restore rewrites the whole value last. That makes the exclusivity below -// invisible to a round-trip test, which is why it is asserted on the marker -// directly — reversing those two loops would otherwise silently start removing -// array members from an already-restored scalar. -describe('a promoted scalar is recorded once, not twice', () => { - it('records promotedArrayScalars and not appendedArrayValues for the key', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), - ); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const changes = readMarker(xcodeprojPath).buildSettingChanges; - expect(changes.length).toBeGreaterThan(0); - for (const change of changes) { - expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( - 'HEADER_SEARCH_PATHS', - ); - expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( - 'HEADER_SEARCH_PATHS', - ); - } - }); -}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index 755d808f7949..16db46f653ab 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -424,6 +424,43 @@ describe('ensureScalarField', () => { out, ); }); + + // Xcode writes an object's fields `isa` first, then alphabetically — landing + // anywhere else plants a diff the next time Xcode saves the project. + it('adds the field directly after `after` when that field exists', () => { + const config = findObjectByUuid(PLAIN_PBXPROJ, 'AA0000000000000000000901'); + const out = ensureScalarField( + PLAIN_PBXPROJ, + config, + 'baseConfigurationReference', + 'DD0000000000000000000101 /* App.xcconfig */', + {after: 'isa'}, + ); + expect(out).toContain( + 'AA0000000000000000000901 /* Debug */ = {\n' + + '\t\t\tisa = XCBuildConfiguration;\n' + + '\t\t\tbaseConfigurationReference = DD0000000000000000000101 /* App.xcconfig */;\n' + + '\t\t\tbuildSettings = {', + ); + expect( + removeField( + out, + findObjectByUuid(out, 'AA0000000000000000000901'), + 'baseConfigurationReference', + ), + ).toBe(PLAIN_PBXPROJ); + }); + + it('falls back to the top of the body when `after` is absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme', { + after: 'NO_SUCH_FIELD', + }), + ).toBe( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme'), + ); + }); }); describe('setScalarField', () => { diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 349f366b2fa1..66fa2c33511e 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -14,18 +14,18 @@ * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager * integration toolkit for an existing `.xcodeproj`. * - * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build - * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to - * a user's existing project — purely additively, recording every edit in a - * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used - * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, a generated + * `.xcconfig` carrying the React build settings, the "Sync SPM Autolinking" build + * phase, and a scheme pre-action to a user's existing project — purely + * additively, recording every edit in a `.spm-injected.json` marker. + * `removeSpmInjection` is the exact inverse (used by `spm deinit`). Consumed as a + * library by setup-apple-spm.js; not a CLI. */ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, - addArrayStringValues, commentSafe, ensureScalarField, findApplicationTargets, @@ -97,6 +97,23 @@ const SPM_SCRIPT_PHASES_MANIFEST = path.join( '.spm-plugin-script-phases.json', ); +// Everything that follows the installed React Native version and frameworks is +// written to these files on every `spm add`/`update`, so that a routine update +// leaves project.pbxproj alone — it only references them. Relative to the app +// root (== SRCROOT). Deliberately NOT under `build/`: that tree holds the SPM +// package roots (build/xcframeworks, build/generated/autolinking, +// build/generated/ios) and is gitignored + regenerated by the in-build "Sync +// SPM Autolinking" phase. These three files are read while Xcode resolves +// build settings and build-phase scripts — before any build phase has run — +// so unlike the package roots they cannot be lazily regenerated at build +// time. They must exist on a fresh clone, so they are committed like +// project.pbxproj itself. +const GENERATED_XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_AUTOLINKING_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_FRAMEWORKS_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + // The single navigator group all injected generated sources are parented under // (created on first use). Its namespacedUUID id + display name. const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; @@ -136,7 +153,9 @@ const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, pack /*:: type RemoteCfg = {url: string, version: string, identity: string}; // Precise record of the build-setting edits injection made to ONE build config, -// so deinit can reverse exactly those (and nothing the user already had). +// written by versions that put the React build settings into project.pbxproj +// directly. Now only read back, so the next sync or deinit can reverse exactly +// those edits (and nothing the user already had). type BuildSettingChange = { configUuid: string, createdArrayKeys: Array, @@ -155,6 +174,16 @@ type BuildSettingChange = { // An array field injection CREATED (rather than appended to a pre-existing // one), so deinit removes the whole field and lands byte-identical. type CreatedArrayField = {container: 'project' | 'target', key: string}; +// How the app configurations were based on the generated xcconfig. Deinit +// clears `baseConfigurationReference` only on these, and only while it still +// points at `fileRefUuid`. `removedPodsRootReactNativePath` is sticky and +// informational: deinit does not put that value back, because it resolves +// empty without CocoaPods. +type XcconfigRecord = { + fileRefUuid: string, + configUuids: Array, + removedPodsRootReactNativePath: boolean, +}; // A plugin-contributed source, normalized for pbxproj emission. `path` is // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the // matching pbxproj token ('SOURCE_ROOT' or '""'). @@ -354,6 +383,7 @@ function buildConfigurationName( return name.value.replace(/^"|"$/g, ''); } +/** The per-slice framework locations, as xcconfig keys and values. */ function frameworkConditionalSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, value: string}> */ { @@ -379,17 +409,14 @@ function frameworkConditionalSettings( `${framework.artifactRelativePath}/${slice.libraryIdentifier}`; settings.push( { - key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`), - value: quoteIfNeeded(`${root}/${slice.libraryPath}`), - }, - { - key: quoteIfNeeded(`${prefix}_BINARY${condition}`), - value: quoteIfNeeded(`${root}/${slice.binaryPath}`), + key: `${prefix}_FRAMEWORK${condition}`, + value: `${root}/${slice.libraryPath}`, }, { - key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`), - value: quoteIfNeeded(root), + key: `${prefix}_BINARY${condition}`, + value: `${root}/${slice.binaryPath}`, }, + {key: `${prefix}_SEARCH_PATH${condition}`, value: root}, ); }; if (slices.length === 1) { @@ -413,14 +440,16 @@ function frameworkConditionalSettings( return settings; } -function frameworkArrayBuildSettings( +// List-valued settings, in xcconfig syntax: quotes group a value whose expansion +// may contain spaces into one list element. +function frameworkListSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, values: Array}> */ { return [ { key: 'OTHER_LDFLAGS', values: [ - '"-ObjC"', + '-ObjC', ...frameworks.map( framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`, ), @@ -434,11 +463,33 @@ function frameworkArrayBuildSettings( }, { key: 'LD_RUNPATH_SEARCH_PATHS', - values: ['"@executable_path/Frameworks"'], + values: ['@executable_path/Frameworks'], }, ]; } +function generatedScript(body /*: string */) /*: string */ { + return `#!/bin/bash +# Generated by React Native on every \`spm add\` and \`spm update\`. +# Do not edit: changes are overwritten. + +${body}`; +} + +// What a build phase runs in place of its generated script, which lives in a +// file so that the phase itself never changes. The file is committed (see +// GENERATED_XCCONFIG_PATH's comment) so a fresh clone always has it; the +// guard below is for the rarer case of a checkout that predates this +// mechanism, or the file having been deleted by hand. +function generatedScriptWrapper(scriptPath /*: string */) /*: string */ { + return `SCRIPT="$SRCROOT/${scriptPath}" +if [ ! -f "$SCRIPT" ]; then + echo "error: $SCRIPT is missing. Run 'npx react-native spm update' to generate it." + exit 1 +fi +exec /bin/bash "$SCRIPT"`; +} + function pbxPathList(paths /*: ReadonlyArray */) /*: string */ { if (paths.length === 0) { return '(\n\t\t\t)'; @@ -1078,24 +1129,25 @@ function ensureStubPackages(appRoot /*: string */) /*: void */ { // In-place injection: add SPM packages to a user's EXISTING xcodeproj. // // This never creates a target or scans sources — it splices the SPM dependency -// graph, the React build settings, and the sync build phase / scheme pre-action -// into the project the user already owns, leaving everything else +// graph, a reference to the generated xcconfig carrying the React build +// settings, and the sync build phase / scheme pre-action into the project the +// user already owns, leaving everything else // byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so // hand-tuned signing / capabilities / extra targets survive. Fails loud (the // caller surfaces the error) when the project is CocoaPods-integrated or its // shape can't be safely anchored. // --------------------------------------------------------------------------- -// The React build settings the app target needs to compile against the SPM -// products. -const INJECTED_ARRAY_SETTINGS = [ +// The React list settings the app target needs to compile against the SPM +// products, in xcconfig syntax. +const REACT_LIST_SETTINGS = [ { key: 'HEADER_SEARCH_PATHS', values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], }, ]; -// Array build settings injected only into debug-flavored configurations. +// List settings given only to debug-flavored configurations. // // Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by // GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app @@ -1110,10 +1162,148 @@ const INJECTED_ARRAY_SETTINGS = [ // // Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that // links the debug xcframeworks also compiles its Swift with DEBUG. -const DEBUG_ARRAY_SETTINGS = [ +const DEBUG_LIST_SETTINGS = [ {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, ]; +/** + * The generated xcconfig every app configuration in `configs` is based on: all + * of the React build settings, shared by every configuration, with the + * flavor-specific ones conditioned on each configuration's own name. List + * settings extend `$(inherited)`, the project-level value; a target's own + * value still outranks all of them (see listSettingsHidingXcconfig). + */ +function generateReactNativeXcconfig( + configs /*: ReadonlyArray<{uuid: string, name: string}> */, + reactNativePathFromSrcRoot /*: string */, + flavoredFrameworks /*: ReadonlyArray */, +) /*: string */ { + const listLine = (key /*: string */, values /*: ReadonlyArray */) => + `${key} = ${['$(inherited)', ...values].join(' ')}`; + const sections /*: Array> */ = [ + [ + '// Generated by React Native on every `spm add` and `spm update`.', + '// Do not edit: changes are overwritten.', + ], + [ + 'CLANG_CXX_LANGUAGE_STANDARD = c++20', + `REACT_NATIVE_PATH = ${['$(SRCROOT)', reactNativePathFromSrcRoot].filter(Boolean).join('/')}`, + ], + [...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)] + .filter(({values}) => values.length > 0) + .map(({key, values}) => listLine(key, values)), + configs.flatMap(({name}) => { + const flavor = flavorForBuildConfiguration(name); + return [ + `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, + ...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map( + ({key, values}) => listLine(`${key}[config=${name}]`, values), + ), + ]; + }), + frameworkConditionalSettings(flavoredFrameworks).map( + ({key, value}) => `${key} = ${value}`, + ), + ]; + return ( + sections + .filter(lines => lines.length > 0) + .map(lines => lines.join('\n')) + .join('\n\n') + '\n' + ); +} + +function xcconfigFileRefUuid(rootUuid /*: string */) /*: string */ { + return namespacedUUID(rootUuid, 'PBXFileReference', GENERATED_XCCONFIG_PATH); +} + +/** A build configuration's `buildSettings` dict, as a body range. */ +function buildSettingsRange( + text /*: string */, + configUuid /*: string */, +) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { + const config = findObjectByUuid(text, configUuid); + const settings = + config != null ? findField(text, config, 'buildSettings') : null; + return settings != null + ? { + uuid: configUuid, + bodyOpen: settings.valueStart, + bodyClose: settings.tokenEnd - 1, + } + : null; +} + +/** + * The list settings the generated xcconfig sets that a configuration of the + * target sets itself without `$(inherited)` — setting → configuration names. A + * target's own value outranks the xcconfig it is based on, so each of these + * would silently drop the value React Native provides. + */ +function listSettingsHidingXcconfig( + text /*: string */, + configUuids /*: ReadonlyArray */, +) /*: Map> */ { + const hiding /*: Map> */ = new Map(); + for (const configUuid of configUuids) { + const name = buildConfigurationName(text, configUuid); + const settings = buildSettingsRange(text, configUuid); + if (settings == null) { + continue; + } + const keys = [ + ...REACT_LIST_SETTINGS, + ...frameworkListSettings([]), + ...(flavorForBuildConfiguration(name) === 'debug' + ? DEBUG_LIST_SETTINGS + : []), + ].map(({key}) => key); + for (const key of keys) { + const field = findField(text, settings, key); + if (field != null && !/\$[({]inherited[)}]/.test(field.value)) { + hiding.set(key, [...(hiding.get(key) ?? []), name]); + } + } + } + return hiding; +} + +/** + * The first app configuration based on an xcconfig other than the generated + * one, with that file's path — React Native never edits an xcconfig it does not + * own, so it cannot base that configuration on its own. + */ +function foreignBaseConfiguration( + text /*: string */, + configUuids /*: ReadonlyArray */, + ownFileRefUuid /*: string */, +) /*: ?{config: string, file: string} */ { + for (const configUuid of configUuids) { + const config = findObjectByUuid(text, configUuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + if (refUuid == null || refUuid === ownFileRefUuid) { + continue; + } + // Xcode writes a PBXFileReference on one line, which findField can't read. + const ref = findObjectByUuid(text, refUuid); + const refPath = + ref != null + ? /[\s{;]path = ("(?:[^"\\]|\\.)*"|[^;]*);/.exec( + text.slice(ref.bodyOpen, ref.bodyClose), + )?.[1] + : null; + return { + config: buildConfigurationName(text, configUuid), + file: refPath != null ? unquotePlist(refPath) : refUuid, + }; + } + return null; +} + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1201,6 +1391,34 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: 'supports SPM-only targets', }; } + const foreign = foreignBaseConfiguration( + text, + configUuids, + xcconfigFileRefUuid(project.uuid), + ); + if (foreign != null) { + return { + ok: false, + reason: + `the ${foreign.config} configuration is already based on ` + + `${foreign.file}, and React Native does not edit xcconfig files it ` + + `does not own — #include ${GENERATED_XCCONFIG_PATH} (relative to the ` + + `project directory) from ${foreign.file} instead`, + }; + } + const hiding = listSettingsHidingXcconfig(text, configUuids); + if (hiding.size > 0) { + const listed = [...hiding] + .map(([key, configs]) => `${key} (${configs.join(', ')})`) + .join(', '); + return { + ok: false, + reason: + `the target sets ${listed} without $(inherited), which would hide ` + + `the values React Native provides in ${GENERATED_XCCONFIG_PATH} — ` + + 'add $(inherited) to each and re-run', + }; + } // The target's own Frameworks build phase (where product build files link). const buildPhases = findField(text, target, 'buildPhases'); const phaseUuids = @@ -1247,20 +1465,22 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: } /** - * Splice the SPM dependency graph + React build settings + sync build phase - * into `text` and return the modified pbxproj. Pure string transform (no I/O), - * idempotent: objects already present (by UUID) and array members / settings - * already applied are skipped, so a second run is a no-op. + * Splice the SPM dependency graph, the generated xcconfig reference and the + * build phases into `text`, returning the modified pbxproj together with the + * files it references (`generatedFiles`, app-root-relative path → content) for + * the caller to write. Pure string transform (no I/O), idempotent: objects + * already present (by UUID) and array members / fields already applied are + * skipped, so a second run is a no-op. */ function injectSpmIntoPbxproj( input /*: string */, plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, - reactNativePath /*: string */, + reactNativePaths /*: {fromAppRoot: string, fromSrcRoot: string} */, remote /*: ?RemoteCfg */, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], scriptPhases /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, xcconfig: XcconfigRecord, generatedFiles: {[string]: string}, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1348,30 +1568,82 @@ function injectSpmIntoPbxproj( ); } - // 5. React build settings into every build config (Debug + Release). - const buildSettingChanges /*: Array */ = []; + // 5. Every app configuration is based on the generated xcconfig, which + // carries all React build settings. planInjection refused any other + // base configuration, so an existing one here is already ours. + const xcconfigName = path.basename(GENERATED_XCCONFIG_PATH); + const xcconfigRefUuid = xcconfigFileRefUuid(plan.rootUuid); + insertObjects('PBXFileReference', [ + { + uuid: xcconfigRefUuid, + comment: xcconfigName, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: 'text.xcconfig', + name: xcconfigName, + path: GENERATED_XCCONFIG_PATH, + sourceTree: 'SOURCE_ROOT', + }, + }, + ]); + const xcconfigGroup = mainGroupObject(text); + if (xcconfigGroup != null) { + text = addArrayMembers(text, xcconfigGroup, 'children', [ + {uuid: xcconfigRefUuid, comment: xcconfigName}, + ]); + } + let removedPodsRootReactNativePath = false; for (const configUuid of plan.configUuids) { - const merged = mergeReactBuildSettings( - text, - configUuid, - buildConfigurationName(text, configUuid), - reactNativePath, - flavoredFrameworks, - ); - text = merged.text; - buildSettingChanges.push(merged.change); + const config = findObjectByUuid(text, configUuid); + if (config != null) { + text = ensureScalarField( + text, + config, + 'baseConfigurationReference', + `${xcconfigRefUuid}${uuidComment(xcconfigName)}`, + {after: 'isa'}, + ); + } + // The CocoaPods template's REACT_NATIVE_PATH is ${PODS_ROOT}-anchored, which + // resolves empty once CocoaPods is gone — and a target's own value outranks + // the xcconfig's, so it has to go for the working one to apply. + const settings = buildSettingsRange(text, configUuid); + if ( + settings != null && + findField(text, settings, 'REACT_NATIVE_PATH')?.value.includes( + 'PODS_ROOT', + ) === true + ) { + text = removeField(text, settings, 'REACT_NATIVE_PATH'); + removedPodsRootReactNativePath = true; + } } + const generatedFiles /*: {[string]: string} */ = { + [GENERATED_XCCONFIG_PATH]: generateReactNativeXcconfig( + plan.configUuids.map(uuid => ({ + uuid, + name: buildConfigurationName(text, uuid), + })), + reactNativePaths.fromSrcRoot, + flavoredFrameworks, + ), + [SYNC_AUTOLINKING_SCRIPT_PATH]: generatedScript( + buildSyncAutolinkingScript(reactNativePaths.fromAppRoot), + ), + [EMBED_FRAMEWORKS_SCRIPT_PATH]: generatedScript( + buildEmbedFrameworksScript(flavoredFrameworks), + ), + }; // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action // is what fires before SPM resolution). Prepended so it runs before // Sources. We do NOT add a JS-bundle phase — an existing app already // bundles JS via its own phase. - const syncScript = buildSyncAutolinkingScript(reactNativePath); const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); const syncEntry = shellScriptPhase( syncPhaseUuid, 'Sync SPM Autolinking', - syncScript, + generatedScriptWrapper(SYNC_AUTOLINKING_SCRIPT_PATH), ); if (!text.includes(syncPhaseUuid)) { text = insertObjectsIntoSection( @@ -1381,11 +1653,11 @@ function injectSpmIntoPbxproj( ); } else { // Already injected on a prior run — the phase object owns these fields, - // so refresh them in place (same quoting used at creation) in case the - // generated script changed, or a project injected before shellPath moved - // to bash (see shellScriptPhase) still has the stale value. Byte-identical - // when neither did; field order and every other byte of the phase are - // untouched. + // so refresh them in place (same quoting used at creation) in case a + // project injected before the script moved to its own file still carries + // it inline, or one injected before shellPath moved to bash (see + // shellScriptPhase) still has the stale value. Byte-identical when neither + // did; field order and every other byte of the phase are untouched. for (const key of ['shellScript', 'shellPath']) { const current = findObjectByUuid(text, syncPhaseUuid); if (current != null) { @@ -1409,7 +1681,6 @@ function injectSpmIntoPbxproj( 'PBXShellScriptBuildPhase', 'EmbedFlavoredFrameworks', ); - const embedScript = buildEmbedFrameworksScript(flavoredFrameworks); const embedInputs = [ '$(SRCROOT)/build/xcframeworks/.artifact-stamp', ...flavoredFrameworks.map( @@ -1423,7 +1694,7 @@ function injectSpmIntoPbxproj( const embedEntry = shellScriptPhase( embedPhaseUuid, 'Embed React Native Flavored Frameworks', - embedScript, + generatedScriptWrapper(EMBED_FRAMEWORKS_SCRIPT_PATH), { inputPaths: pbxPathList(embedInputs), outputPaths: pbxPathList(embedOutputs), @@ -1556,17 +1827,7 @@ function injectSpmIntoPbxproj( // to a pre-existing children array, so no createdArrayField is recorded — // deinit removes the group's membership via removeArrayMembersByUuid and // the group object itself via removeObjectByUuid (groupUuid is injected). - const proj = findProjectObject(text); - const mainGroupField = - proj != null ? findField(text, proj, 'mainGroup') : null; - const mainGroupMatch = - mainGroupField != null - ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/) - : null; - const mainGroupObj = - mainGroupMatch != null - ? findObjectByUuid(text, mainGroupMatch[0]) - : null; + const mainGroupObj = mainGroupObject(text); if (mainGroupObj != null) { text = addArrayMembers(text, mainGroupObj, 'children', [ {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME}, @@ -1654,12 +1915,28 @@ function injectSpmIntoPbxproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig: { + fileRefUuid: xcconfigRefUuid, + configUuids: [...plan.configUuids], + removedPodsRootReactNativePath, + }, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, }; } +/** The project's main (root navigator) group. */ +function mainGroupObject( + text /*: string */, +) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { + const project = findProjectObject(text); + const mainGroup = + project != null ? findField(text, project, 'mainGroup') : null; + const uuid = mainGroup?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + return uuid != null ? findObjectByUuid(text, uuid) : null; +} + /** Re-locate an application target by UUID against the current text. */ function findApplicationTargetByUuid( text /*: string */, @@ -1672,180 +1949,11 @@ function findApplicationTargetByUuid( return obj; } -/** Strip the surrounding plist quotes from a build-setting token, if any. */ +/** Strip the surrounding plist quotes from a token, if any. */ function unquotePlist(s /*: string */) /*: string */ { return s.replace(/^"/, '').replace(/"$/, ''); } -/** - * The individual values a build setting already carries, unquoted — for both - * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting - * (`("$(inherited)", DEBUG)`) and the scalar form the app template and - * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the - * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently - * skip the injection. - */ -function buildSettingValueTokens(value /*: string */) /*: Set */ { - return new Set( - value - .split(/[\s,()]+/) - .filter(Boolean) - .map(unquotePlist), - ); -} - -/** - * Merge the React build settings into one XCBuildConfiguration's dict. Returns - * the modified text plus a precise record of what was actually added — so - * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching - * a value the user already had (key insight: ensureScalarField/ - * addArrayStringValues are no-ops / dedupe when a value is already present). - */ -function mergeReactBuildSettings( - input /*: string */, - configUuid /*: string */, - configurationName /*: string */, - reactNativePath /*: string */, - flavoredFrameworks /*: ReadonlyArray */ = [], -) /*: {text: string, change: BuildSettingChange} */ { - let text = input; - const scalars = [ - {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, - {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, - ]; - // Re-locate the buildSettings dict before each edit (offsets shift). - const dict = () => { - const cfg = findObjectByUuid(text, configUuid); - if (cfg == null) { - return null; - } - const bs = findField(text, cfg, 'buildSettings'); - if (bs == null) { - return null; - } - return { - uuid: configUuid, - bodyOpen: bs.valueStart, - bodyClose: bs.tokenEnd - 1, - }; - }; - const createdArrayKeys /*: Array */ = []; - const appendedArrayValues /*: {[string]: Array} */ = {}; - const promotedArrayScalars /*: {[string]: string} */ = {}; - const createdScalars /*: Array */ = []; - const arraySettings = [ - ...INJECTED_ARRAY_SETTINGS, - ...(flavorForBuildConfiguration(configurationName) === 'debug' - ? DEBUG_ARRAY_SETTINGS - : []), - ...frameworkArrayBuildSettings(flavoredFrameworks), - ]; - for (const {key, values} of arraySettings) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - // Non-null only for a scalar addArrayStringValues would promote to an array - // (same array-vs-scalar test it uses). Kept RAW: findField's token for a - // bare scalar ends at the `;`, so it carries any whitespace before it, and - // deinit has to write those bytes back verbatim. - const priorScalar = - existing != null && !existing.value.trimStart().startsWith('(') - ? existing.value - : null; - if (existing == null) { - createdArrayKeys.push(key); - } else { - const present = buildSettingValueTokens(existing.value); - const fresh = values.filter(v => !present.has(unquotePlist(v))); - if (fresh.length === 0) { - // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by - // EXACT array member, so a value the user carries in the scalar form - // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would - // otherwise be promoted to an array and re-appended — an edit `deinit` - // has no record of and so could never reverse. - continue; - } - if (priorScalar == null) { - appendedArrayValues[key] = fresh; - } - } - const beforeAdd = text; - text = addArrayStringValues(text, d, key, values); - // Record only a promotion that actually happened: addArrayStringValues - // no-ops when `values` is empty or every value is already a member, and a - // recorded-but-untouched field would have deinit clobber whatever the user - // has there by then. Restoring the scalar subsumes removing the injected - // members, so the two records stay mutually exclusive per key. - if (priorScalar != null && text !== beforeAdd) { - promotedArrayScalars[key] = priorScalar; - } - } - const replacedScalars /*: {[string]: string} */ = {}; - for (const {key, value} of scalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if ( - key === 'REACT_NATIVE_PATH' && - existing.value.includes('PODS_ROOT') - ) { - // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template - // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves - // empty at build time, so the Bundle React Native code and images - // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace - // it with the SPM-computed path, recording the original for deinit. - replacedScalars[key] = existing.value; - text = removeField(text, d, key); - const d2 = dict(); - if (d2 == null) { - continue; - } - text = ensureScalarField(text, d2, key, value); - continue; - } - text = ensureScalarField(text, d, key, value); - } - const ownedScalars = [ - { - key: 'RN_SPM_FLAVOR', - value: flavorForBuildConfiguration(configurationName), - }, - ...frameworkConditionalSettings(flavoredFrameworks), - ]; - for (const {key, value} of ownedScalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if (existing.value !== value) { - replacedScalars[key] = existing.value; - } - text = setScalarField(text, d, key, value); - } - return { - text, - change: { - configUuid, - createdArrayKeys, - appendedArrayValues, - createdScalars, - replacedScalars, - ...(Object.keys(promotedArrayScalars).length > 0 - ? {promotedArrayScalars} - : {}), - }, - }; -} - // Write only when content changed (avoids spurious Xcode reloads / git churn). function writeIfChanged( filePath /*: string */, @@ -2226,7 +2334,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2341,6 +2449,32 @@ function mergeCreatedArrayFields( return merged; } +// The react-native dir the app reaches through `node_modules/react-native`, in +// the nearest ancestor of `appRoot` where that link resolves to +// `reactNativeRoot`. Config commands report the realpath, which under pnpm is a +// version- and hash-named store dir that must not end up in committed files. +function linkedReactNativeRoot( + appRoot /*: string */, + reactNativeRoot /*: string */, +) /*: string */ { + try { + const target = fs.realpathSync(reactNativeRoot); + let dir = path.resolve(appRoot); + while (true) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(candidate) && fs.realpathSync(candidate) === target) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } catch {} + return reactNativeRoot; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} @@ -2358,18 +2492,29 @@ function injectSpmIntoExistingXcodeproj( }; } const original = fs.readFileSync(pbxprojPath, 'utf8'); - const plan = planInjection(original, {appName: opts.appName}); + const prevMarker = readMarker(xcodeprojPath); + // A project injected before the React build settings moved to the generated + // xcconfig carries them in project.pbxproj. Reversing that record first moves + // it over, and has to precede planning: a setting the user had without + // $(inherited) only shows again once the promotion that added it is undone. + const withoutRecordedSettings = removeRecordedBuildSettings( + original, + prevMarker?.buildSettingChanges ?? [], + ); + const plan = planInjection(withoutRecordedSettings, {appName: opts.appName}); if (!plan.ok) { return {status: 'refused', reason: plan.reason}; } - const reactNativePath = path.relative(appRoot, reactNativeRoot); + const reactNativeDir = linkedReactNativeRoot(appRoot, reactNativeRoot); + const reactNativePaths = { + fromAppRoot: path.relative(appRoot, reactNativeDir), + fromSrcRoot: path.relative(path.dirname(xcodeprojPath), reactNativeDir), + }; const remote = remotePackageConfig(appRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; - const prevMarker = readMarker(xcodeprojPath); - // Reconcile generated sources injected on a PRIOR run that are no longer in // the manifest (a plugin's entry was dropped, or the plugin was removed). // Diff the marker's `generatedSources` map against the current manifest and @@ -2404,13 +2549,7 @@ function injectSpmIntoExistingXcodeproj( staleUuids.push(prevScriptPhases[id]); } } - // Re-apply generated settings from a clean recorded baseline. This removes - // linker entries for plugin frameworks that disappeared and keeps the new - // marker a complete inverse after an idempotent update. - let base = removeRecordedBuildSettings( - original, - prevMarker?.buildSettingChanges ?? [], - ); + let base = withoutRecordedSettings; if (staleUuids.length > 0) { base = removeArrayMembersByUuid(base, staleUuids); for (const u of staleUuids) { @@ -2422,7 +2561,8 @@ function injectSpmIntoExistingXcodeproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, } = injectSpmIntoPbxproj( @@ -2434,13 +2574,17 @@ function injectSpmIntoExistingXcodeproj( frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - reactNativePath, + reactNativePaths, remote, generatedSources, flavoredFrameworks, scriptPhases, ); + // Before the project, so an open Xcode that reloads it finds them in place. + for (const [relPath, content] of Object.entries(generatedFiles)) { + writeIfChanged(path.join(appRoot, relPath), content); + } const changed = writeIfChanged(pbxprojPath, text); log( changed @@ -2455,7 +2599,7 @@ function injectSpmIntoExistingXcodeproj( projName, // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap // could win its race and mask a mismatch from the in-target detector. - syncScript: buildSchemePreActionScript(reactNativePath), + syncScript: buildSchemePreActionScript(reactNativePaths.fromAppRoot), }); log(`Scheme sync pre-action: ${schemeResult.status}`); @@ -2498,7 +2642,13 @@ function injectSpmIntoExistingXcodeproj( prevMarker?.createdArrayFields ?? [], createdArrayFields, ), - buildSettingChanges, + xcconfig: { + ...xcconfig, + // Sticky: the next sync no longer sees the value this one removed. + removedPodsRootReactNativePath: + xcconfig.removedPodsRootReactNativePath || + prevMarker?.xcconfig?.removedPodsRootReactNativePath === true, + }, // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, @@ -2719,10 +2869,39 @@ function removeSpmInjection( '', ); - // 2. Reverse the per-config build-setting edits (only what we added). + // 2. Un-base the configurations still based on the generated xcconfig, and + // delete it only then — a configuration the user has since based on an + // xcconfig of their own may #include it. A ${PODS_ROOT}-anchored + // REACT_NATIVE_PATH injection removed is deliberately not put back: it + // resolves empty without CocoaPods. + const xcconfig /*: ?XcconfigRecord */ = marker.xcconfig; + let ownsXcconfig = false; + if (xcconfig != null) { + for (const configUuid of xcconfig.configUuids) { + const config = findObjectByUuid(text, configUuid); + if ( + config != null && + findField(text, config, 'baseConfigurationReference')?.value.includes( + xcconfig.fileRefUuid, + ) === true + ) { + text = removeField(text, config, 'baseConfigurationReference'); + ownsXcconfig = true; + } + } + } + // Settings a project injected before the generated xcconfig still carries + // in project.pbxproj (only what we added). text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); + for (const relPath of [ + ...(ownsXcconfig ? [GENERATED_XCCONFIG_PATH] : []), + SYNC_AUTOLINKING_SCRIPT_PATH, + EMBED_FRAMEWORKS_SCRIPT_PATH, + ]) { + fs.rmSync(path.join(appRoot, relPath), {force: true}); + } // 3. Scheme: delete it if injection created it AND still owns its contents, // else strip the pre-action and leave the file (see isGeneratedScheme). @@ -2765,6 +2944,7 @@ module.exports = { flavorForBuildConfiguration, targetBuildConfigUuids, frameworkConditionalSettings, + generateReactNativeXcconfig, ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 9cd40df867a1..066f50b0792a 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -530,21 +530,26 @@ function addArrayStringValues( } /** - * Add a scalar field after the object's `{` only when ABSENT (never clobbers a - * value the user already set). Returns text unchanged if the key exists. + * Add a scalar field only when ABSENT (never clobbers a value the user already + * set). Returns text unchanged if the key exists. Lands right after the field + * named `after` when the object has one, else after the object's `{`. */ function ensureScalarField( text /*: string */, obj /*: BodyRange */, key /*: string */, value /*: string */, + options /*: {after?: string} */ = {}, ) /*: string */ { if (findField(text, obj, key) != null) { return text; } const fieldIndent = detectFieldIndent(text, obj); const block = `\n${fieldIndent}${key} = ${value};`; - return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); + const anchor = + options.after != null ? findField(text, obj, options.after) : null; + const at = anchor != null ? anchor.tokenEnd + 1 : obj.bodyOpen + 1; + return text.slice(0, at) + block + text.slice(at); } /** From ab7105f41323aee24cb81dc91b917bf2fcfefc09 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Sun, 4 Oct 2026 22:07:39 +0200 Subject: [PATCH 2/4] Escape the expected script line with quoteIfNeeded in the wrapper test --- .../scripts/spm/__tests__/inject-spm-xcodeproj-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 57620c302f2b..4ae5ccf3d8d0 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -474,7 +474,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { const wrapper = shellScriptOf(text, label); expect(wrapper).toContain(`$SRCROOT/${scriptPath}`); expect(wrapper).toContain('spm update'); - expect(text).not.toContain(bodyLine.replace(/"/g, '\\"')); + expect(text).not.toContain(quoteIfNeeded(bodyLine).slice(1, -1)); expect(text).not.toContain('set -euo pipefail'); expect(generatedFiles[scriptPath]).toContain(bodyLine); }); From fa69d14f2a40c51943a9933d0de5f705ce5cd0f0 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Sun, 4 Oct 2026 22:29:41 +0200 Subject: [PATCH 3/4] Simplify the xcconfig change and its tests --- .../__tests__/generate-spm-xcodeproj-test.js | 14 +- .../__tests__/inject-spm-xcodeproj-test.js | 146 ++++++------------ .../__tests__/remove-spm-injection-test.js | 60 ++++--- .../scripts/spm/generate-spm-xcodeproj.js | 22 +-- 4 files changed, 90 insertions(+), 152 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index e3a12d771e8c..e96d35b8269b 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -361,13 +361,11 @@ describe('configuration-driven framework settings', () => { describe('generateReactNativeXcconfig', () => { const DEVICE_ONLY = {...FRAMEWORK, slices: [FRAMEWORK.slices[0]]}; - const configs = names => - names.map((name, i) => ({uuid: `AA00000000000000000009${i}0`, name})); it('writes every setting the app target needs, extending inherited lists', () => { expect( generateReactNativeXcconfig( - configs(['Debug', 'Release']), + ['Debug', 'Release'], '../node_modules/react-native', [DEVICE_ONLY], ), @@ -395,14 +393,14 @@ RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM }); it('anchors REACT_NATIVE_PATH on SRCROOT itself when react-native sits there', () => { - expect( - generateReactNativeXcconfig(configs(['Debug']), '', [FRAMEWORK]), - ).toContain('\nREACT_NATIVE_PATH = $(SRCROOT)\n'); + expect(generateReactNativeXcconfig(['Debug'], '', [FRAMEWORK])).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)\n', + ); }); it("conditions the flavor on the project's own configuration names", () => { const xcconfig = generateReactNativeXcconfig( - configs(['Development', 'Staging']), + ['Development', 'Staging'], '../node_modules/react-native', [FRAMEWORK], ); @@ -419,7 +417,7 @@ RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM it('writes no plist quoting around conditional keys or path values', () => { const xcconfig = generateReactNativeXcconfig( - configs(['Debug']), + ['Debug'], '../node_modules/react-native', [FRAMEWORK], ); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 4ae5ccf3d8d0..aaa200a22d79 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -322,10 +322,31 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { APP_DEBUG_CONFIG, APP_RELEASE_CONFIG, ]; - const xcconfigRefOf = text => - /baseConfigurationReference = ([0-9A-F]{24}) \/\* ReactNativeSPM\.xcconfig \*\/;/.exec( - text, - )[1]; + const appSettingsOf = text => + [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG].map(config => + buildSettingsOf(text, config), + ); + // React Native installed elsewhere, with `frameworks` in place of the + // default ones — what a later `spm update` sees. + const injectRelocated = frameworks => { + const plan = planInjection(PLAIN, {}); + const relocated = '../../elsewhere/react-native'; + return injectSpmIntoPbxproj( + PLAIN, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + {fromAppRoot: relocated, fromSrcRoot: relocated}, + null, + [], + frameworks, + [], + ); + }; // A routine `spm update` must not touch project.pbxproj, so none of these may // live in it — they are the ones whose values follow the installed frameworks. @@ -342,7 +363,10 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { it('bases every app configuration on the generated xcconfig, next to isa', () => { const {text, injectedUuids, xcconfig} = inject(PLAIN); - const ref = xcconfigRefOf(text); + const ref = + /baseConfigurationReference = ([0-9A-F]{24}) \/\* ReactNativeSPM\.xcconfig \*\/;/.exec( + text, + )[1]; for (const [config, name] of [ [APP_DEBUG_CONFIG, 'Debug'], [APP_RELEASE_CONFIG, 'Release'], @@ -378,10 +402,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { const {generatedFiles} = inject(PLAIN); expect(generatedFiles[XCCONFIG_PATH]).toBe( generateReactNativeXcconfig( - [ - {uuid: APP_DEBUG_CONFIG, name: 'Debug'}, - {uuid: APP_RELEASE_CONFIG, name: 'Release'}, - ], + ['Debug', 'Release'], RN_PATH, TEST_FRAMEWORKS, ), @@ -410,12 +431,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), ], ])("leaves the configuration's own %s untouched", (_key, before) => { - const {text} = inject(before); - for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { - expect(buildSettingsOf(text, config)).toBe( - buildSettingsOf(before, config), - ); - } + expect(appSettingsOf(inject(before).text)).toEqual(appSettingsOf(before)); }); // The CocoaPods template anchors REACT_NATIVE_PATH on ${PODS_ROOT}, which @@ -426,11 +442,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', ); const {text, xcconfig, generatedFiles} = inject(before); - for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { - expect(buildSettingsOf(text, config)).toBe( - buildSettingsOf(PLAIN, config), - ); - } + expect(appSettingsOf(text)).toEqual(appSettingsOf(PLAIN)); expect(xcconfig.removedPodsRootReactNativePath).toBe(true); expect(generatedFiles[XCCONFIG_PATH]).toContain( `\nREACT_NATIVE_PATH = $(SRCROOT)/${RN_PATH}\n`, @@ -442,11 +454,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { 'REACT_NATIVE_PATH = ../../node_modules/react-native;', ); const {text, xcconfig} = inject(before); - for (const config of [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG]) { - expect(buildSettingsOf(text, config)).toBe( - buildSettingsOf(before, config), - ); - } + expect(appSettingsOf(text)).toEqual(appSettingsOf(before)); expect(xcconfig.removedPodsRootReactNativePath).toBe(false); }); @@ -481,24 +489,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { it('keeps the script wrappers identical across React Native versions and frameworks', () => { const {text} = inject(PLAIN); - const plan = planInjection(PLAIN, {}); - const {text: other} = injectSpmIntoPbxproj( - PLAIN, - { - rootUuid: plan.rootUuid, - targetUuid: plan.target.uuid, - configUuids: plan.configUuids, - frameworksPhaseUuid: plan.frameworksPhaseUuid, - sourcesPhaseUuid: plan.sourcesPhaseUuid, - }, - { - fromAppRoot: '../../elsewhere/react-native', - fromSrcRoot: '../../elsewhere/react-native', - }, - null, - [], - [], - ); + const other = injectRelocated([]).text; for (const label of [ 'Sync SPM Autolinking', 'Embed React Native Flavored Frameworks', @@ -507,60 +498,25 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { } }); - // The actual goal of moving settings into the generated xcconfig: a routine - // `spm update` (a new React Native version, new framework artifact slices) - // must not touch project.pbxproj AT ALL when the set of frameworks (their - // ids/names) doesn't change shape — not just the two script phases checked - // above. Only the id/frameworkName/artifactRelativePath drive anything that - // lands in the pbxproj (variable names in the script bodies); slice paths - // and architectures land only in the generated xcconfig. + // A routine `spm update` must leave project.pbxproj alone while the + // frameworks keep their ids and names: slice paths and architectures land + // only in the generated xcconfig. it('produces a byte-identical project.pbxproj across a version bump (path + slice content change)', () => { - const {text} = inject(PLAIN); - const plan = planInjection(PLAIN, {}); - const BUMPED_FRAMEWORKS = [ - { - ...TEST_FRAMEWORKS[0], - slices: [ - { - sdk: 'iphoneos*', - platform: 'ios', - variant: null, - architectures: ['arm64', 'arm64e'], - libraryIdentifier: 'ios-arm64_arm64e', - libraryPath: 'React.framework', - binaryPath: 'React.framework/Versions/Current/React', - }, - { - sdk: 'iphonesimulator*', - platform: 'ios', - variant: 'simulator', - architectures: ['arm64'], - libraryIdentifier: 'ios-arm64-simulator', - libraryPath: 'React.framework', - binaryPath: 'React.framework/Versions/Current/React', - }, - ], - }, - ]; - const {text: other} = injectSpmIntoPbxproj( - PLAIN, - { - rootUuid: plan.rootUuid, - targetUuid: plan.target.uuid, - configUuids: plan.configUuids, - frameworksPhaseUuid: plan.frameworksPhaseUuid, - sourcesPhaseUuid: plan.sourcesPhaseUuid, - }, - { - fromAppRoot: '../../elsewhere/react-native', - fromSrcRoot: '../../elsewhere/react-native', - }, - null, - [], - BUMPED_FRAMEWORKS, - [], + const bumped = TEST_FRAMEWORKS.map(framework => ({ + ...framework, + slices: framework.slices.map(slice => ({ + ...slice, + architectures: [...slice.architectures, 'arm64e'], + libraryIdentifier: `${slice.libraryIdentifier}_arm64e`, + binaryPath: 'React.framework/Versions/Current/React', + })), + })); + const before = inject(PLAIN); + const after = injectRelocated(bumped); + expect(after.generatedFiles[XCCONFIG_PATH]).not.toBe( + before.generatedFiles[XCCONFIG_PATH], ); - expect(other).toBe(text); + expect(after.text).toBe(before.text); }); it('runs every injected shell-script build phase under bash, not /bin/sh', () => { diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index f6931f17de22..45703e4798af 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -259,13 +259,6 @@ function scaffoldPnpmApp() { return {appRoot, xcodeprojPath, rnRoot: fs.realpathSync(store)}; } -function bakedFilesOf(appRoot, xcodeprojPath) { - return [ - ...generatedFileContents(appRoot), - fs.readFileSync(schemePathOf(xcodeprojPath), 'utf8'), - ]; -} - // The xcconfig, scripts and scheme are committed, so they must name // react-native the way the app resolves it, not by its store location. describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { @@ -281,15 +274,18 @@ describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { }).status, ).toBe('injected'); - expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + const generated = generatedFileContents(appRoot); + const [xcconfig, syncScript] = generated; + expect(xcconfig).toContain( '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', ); - for (const content of bakedFilesOf(appRoot, xcodeprojPath)) { + expect(syncScript).toContain('RN_DIR="../node_modules/react-native"'); + for (const content of [ + ...generated, + fs.readFileSync(schemePathOf(xcodeprojPath), 'utf8'), + ]) { expect(content).not.toContain('.pnpm'); } - expect( - fs.readFileSync(path.join(appRoot, GENERATED_FILES[1]), 'utf8'), - ).toContain('RN_DIR="../node_modules/react-native"'); }); it('prefers the nearest node_modules link to react-native', () => { @@ -336,12 +332,11 @@ describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { xcodeprojPath, }); - expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + const [xcconfig, syncScript] = generatedFileContents(appRoot); + expect(xcconfig).toContain( '\nREACT_NATIVE_PATH = $(SRCROOT)/vendor/react-native\n', ); - expect( - fs.readFileSync(path.join(appRoot, GENERATED_FILES[1]), 'utf8'), - ).toContain('RN_DIR="vendor/react-native"'); + expect(syncScript).toContain('RN_DIR="vendor/react-native"'); }); }); @@ -1621,27 +1616,24 @@ describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( // findField's token for a BARE scalar ends AT the `;`, so it includes any // whitespace before it. Deinit must put those bytes back exactly, not a // tidied-up version of them. -describe.each(['HEADER_SEARCH_PATHS = $(inherited) ; /* note */'])( - 'removeSpmInjection with the untrimmed scalar `%s`', - field => { - it('restores it byte-for-byte', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); - const before = pbxprojOf(xcodeprojPath); +describe('removeSpmInjection with an untrimmed scalar', () => { + const field = 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */'; - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + it('restores it byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); + const before = pbxprojOf(xcodeprojPath); - expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( - 'removed', - ); - expect(pbxprojOf(xcodeprojPath)).toBe(before); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, }); - }, -); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); describe("a list setting of the project's own that keeps $(inherited)", () => { const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 66fa2c33511e..2f2386798047 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1167,14 +1167,14 @@ const DEBUG_LIST_SETTINGS = [ ]; /** - * The generated xcconfig every app configuration in `configs` is based on: all - * of the React build settings, shared by every configuration, with the + * The generated xcconfig every app configuration in `configNames` is based on: + * all of the React build settings, shared by every configuration, with the * flavor-specific ones conditioned on each configuration's own name. List * settings extend `$(inherited)`, the project-level value; a target's own * value still outranks all of them (see listSettingsHidingXcconfig). */ function generateReactNativeXcconfig( - configs /*: ReadonlyArray<{uuid: string, name: string}> */, + configNames /*: ReadonlyArray */, reactNativePathFromSrcRoot /*: string */, flavoredFrameworks /*: ReadonlyArray */, ) /*: string */ { @@ -1192,7 +1192,7 @@ function generateReactNativeXcconfig( [...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)] .filter(({values}) => values.length > 0) .map(({key, values}) => listLine(key, values)), - configs.flatMap(({name}) => { + configNames.flatMap(name => { const flavor = flavorForBuildConfiguration(name); return [ `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, @@ -1221,16 +1221,12 @@ function xcconfigFileRefUuid(rootUuid /*: string */) /*: string */ { function buildSettingsRange( text /*: string */, configUuid /*: string */, -) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { +) /*: ?{bodyOpen: number, bodyClose: number} */ { const config = findObjectByUuid(text, configUuid); const settings = config != null ? findField(text, config, 'buildSettings') : null; return settings != null - ? { - uuid: configUuid, - bodyOpen: settings.valueStart, - bodyClose: settings.tokenEnd - 1, - } + ? {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1} : null; } @@ -1620,10 +1616,7 @@ function injectSpmIntoPbxproj( } const generatedFiles /*: {[string]: string} */ = { [GENERATED_XCCONFIG_PATH]: generateReactNativeXcconfig( - plan.configUuids.map(uuid => ({ - uuid, - name: buildConfigurationName(text, uuid), - })), + plan.configUuids.map(uuid => buildConfigurationName(text, uuid)), reactNativePaths.fromSrcRoot, flavoredFrameworks, ), @@ -1926,7 +1919,6 @@ function injectSpmIntoPbxproj( }; } -/** The project's main (root navigator) group. */ function mainGroupObject( text /*: string */, ) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { From b79754743155722ae4a6a3217381434c9d8b3c43 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 5 Oct 2026 12:48:40 +0200 Subject: [PATCH 4/4] Address review: follow #include chains, exact include line, default flavor, embed script input, safe deinit --- .../__tests__/generate-spm-xcodeproj-test.js | 22 ++ .../__tests__/inject-spm-xcodeproj-test.js | 179 +++++++++++++++ .../__tests__/remove-spm-injection-test.js | 125 +++++++++++ .../scripts/spm/generate-spm-xcodeproj.js | 210 ++++++++++++++---- .../scripts/spm/ios-deployment-target.js | 79 ++----- .../scripts/spm/xcconfig-files.js | 149 +++++++++++++ 6 files changed, 657 insertions(+), 107 deletions(-) create mode 100644 packages/react-native/scripts/spm/xcconfig-files.js diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index e96d35b8269b..3a004597a1ca 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -381,6 +381,7 @@ OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)" FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)" LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks +RN_SPM_FLAVOR = release RN_SPM_FLAVOR[config=Debug] = debug SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG RN_SPM_FLAVOR[config=Release] = release @@ -415,6 +416,27 @@ RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/); }); + // A configuration duplicated in Xcode keeps the base reference but has no + // line of its own until the next sync, and an unset flavor would resolve + // every framework path to nothing. + it('falls back to the release flavor for a configuration it does not list', () => { + const lines = generateReactNativeXcconfig( + ['Debug', 'Release'], + '../node_modules/react-native', + [FRAMEWORK], + ).split('\n'); + const fallback = lines.indexOf('RN_SPM_FLAVOR = release'); + expect(fallback).toBeGreaterThan(-1); + expect(fallback).toBeLessThan( + lines.findIndex(line => line.startsWith('RN_SPM_FLAVOR[config=')), + ); + expect( + lines.some(line => + line.startsWith('SWIFT_ACTIVE_COMPILATION_CONDITIONS ='), + ), + ).toBe(false); + }); + it('writes no plist quoting around conditional keys or path values', () => { const xcconfig = generateReactNativeXcconfig( ['Debug'], diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index aaa200a22d79..85840bd0a247 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -20,6 +20,7 @@ const { const {findField, findObjectByUuid, quoteIfNeeded} = require('../spm-pbxproj'); const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const PLAIN = fs.readFileSync( @@ -270,6 +271,171 @@ describe('planInjection', () => { }); }); +// FOREIGN_XCCONFIG with the reference inside a `Config` group, the way Xcode +// files an xcconfig dragged into a folder of the navigator. +const FOREIGN_XCCONFIG_IN_GROUP = FOREIGN_XCCONFIG.replace( + 'path = Config/App.xcconfig;', + 'path = App.xcconfig;', +) + .replace( + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n', + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n\t\t\t\tCC0000000000000000000002 /* Config */,\n', + ) + .replace( + '/* End PBXGroup section */', + '\t\tCC0000000000000000000002 /* Config */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCC0000000000000000000001 /* App.xcconfig */,\n\t\t\t);\n\t\t\tpath = Config;\n\t\t\tsourceTree = "";\n\t\t};\n/* End PBXGroup section */', + ); + +describe("planInjection — a configuration based on the user's own xcconfig", () => { + let roots = []; + afterEach(() => { + for (const root of roots) { + fs.rmSync(root, {recursive: true, force: true}); + } + roots = []; + }); + + // An app root (== SRCROOT) holding `files`, app-root-relative path → content. + function appRootWith(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-include-')); + roots.push(root); + for (const [relPath, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, relPath)), {recursive: true}); + fs.writeFileSync(path.join(root, relPath), content, 'utf8'); + } + return root; + } + + function plan(files, text = FOREIGN_XCCONFIG) { + const root = appRootWith(files); + return planInjection(text, {appRoot: root, srcRoot: root}); + } + + const INCLUDES_GENERATED = `#include "../${XCCONFIG_PATH}"\n`; + + it('accepts it when the xcconfig #includes the generated one', () => { + const result = plan({'Config/App.xcconfig': INCLUDES_GENERATED}); + expect(result.ok).toBe(true); + expect(result.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + }); + + it('includes no configuration it bases on the generated xcconfig itself', () => { + expect(planInjection(PLAIN, {}).includedByConfigUuids).toEqual([]); + }); + + it('finds an xcconfig filed under a navigator group', () => { + expect( + plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG_IN_GROUP, + ).ok, + ).toBe(true); + }); + + it('follows #include? chains, each path relative to the file that holds it', () => { + expect( + plan({ + 'Config/App.xcconfig': + '// Shared settings\n#include? "Shared/Base.xcconfig"\n', + 'Config/Shared/Base.xcconfig': `#include "../../${XCCONFIG_PATH}"\n`, + }).ok, + ).toBe(true); + }); + + it.each([ + [ + 'a path relative to the project directory', + `#include "${XCCONFIG_PATH}"\n`, + ], + ['a commented-out include', `// ${INCLUDES_GENERATED}`], + ['an include of a file that is missing', '#include? "Missing.xcconfig"\n'], + ])('refuses %s', (_label, content) => { + expect(plan({'Config/App.xcconfig': content}).ok).toBe(false); + }); + + it('refuses an include cycle that never reaches the generated xcconfig', () => { + const result = plan({ + 'Config/App.xcconfig': '#include "Base.xcconfig"\n', + 'Config/Base.xcconfig': '#include "App.xcconfig"\n', + }); + expect(result.ok).toBe(false); + }); + + it('still refuses a list setting without $(inherited) on it', () => { + const result = plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + 'HEADER_SEARCH_PATHS = /vendor;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + ), + ); + expect(result.ok).toBe(false); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + }); + + it('names the exact line to add, relative to the xcconfig that needs it', () => { + const result = plan({'Config/App.xcconfig': 'MY_SETTING = 1\n'}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include "../${XCCONFIG_PATH}" to Config/App.xcconfig`, + ); + expect(result.reason).not.toContain('project directory'); + }); + + it('says the path is relative to the xcconfig when it cannot find the file', () => { + const result = plan({}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include ${XCCONFIG_PATH} from Config/App.xcconfig`, + ); + expect(result.reason).toContain( + 'relative to the folder that holds Config/App.xcconfig', + ); + expect(result.reason).not.toContain('project directory'); + }); + + it("keeps the user's base and records the configuration as including the generated xcconfig", () => { + const root = appRootWith({'Config/App.xcconfig': INCLUDES_GENERATED}); + const accepted = planInjection(FOREIGN_XCCONFIG, { + appRoot: root, + srcRoot: root, + }); + const {text, xcconfig, generatedFiles} = injectSpmIntoPbxproj( + FOREIGN_XCCONFIG, + { + rootUuid: accepted.rootUuid, + targetUuid: accepted.target.uuid, + configUuids: accepted.configUuids, + includedByConfigUuids: accepted.includedByConfigUuids, + frameworksPhaseUuid: accepted.frameworksPhaseUuid, + sourcesPhaseUuid: accepted.sourcesPhaseUuid, + }, + RN_PATHS, + null, + [], + TEST_FRAMEWORKS, + ); + const baseOf = configUuid => + findField( + text, + findObjectByUuid(text, configUuid), + 'baseConfigurationReference', + )?.value; + expect(baseOf(APP_DEBUG_CONFIG)).toBe( + 'CC0000000000000000000001 /* App.xcconfig */', + ); + expect(baseOf(APP_RELEASE_CONFIG)).toBe( + `${xcconfig.fileRefUuid} /* ReactNativeSPM.xcconfig */`, + ); + expect(xcconfig.configUuids).toEqual([APP_RELEASE_CONFIG]); + expect(xcconfig.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + expect(text).toContain(`path = ${XCCONFIG_PATH};`); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + 'RN_SPM_FLAVOR[config=Debug] = debug\n', + ); + }); +}); + describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { it('adds the local package references and product dependencies', () => { const {text} = inject(PLAIN); @@ -564,6 +730,19 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { '$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework', ); }); + + // The phase only runs the script file, so the file has to be an input for + // Xcode to re-run the phase when the file alone changes. + it('lists its script file among the embed phase inputs', () => { + const {text} = inject(PLAIN); + const uuid = + /([0-9A-F]{24}) \/\* Embed React Native Flavored Frameworks \*\/ = \{/.exec( + text, + )[1]; + expect( + findField(text, findObjectByUuid(text, uuid), 'inputPaths').value, + ).toContain(`${quoteIfNeeded(`$(SRCROOT)/${EMBED_SCRIPT_PATH}`)},`); + }); }); describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 45703e4798af..f8febf836fb4 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -186,6 +186,42 @@ function generatedFileContents(appRoot) { ); } +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; + +// The app target's Debug configuration based on Config/App.xcconfig, an +// xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n`, + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n`, +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +function writeUserXcconfig(appRoot, content) { + fs.mkdirSync(path.join(appRoot, 'Config'), {recursive: true}); + fs.writeFileSync(path.join(appRoot, 'Config', 'App.xcconfig'), content); +} + +// What re-basing Debug on the user's own xcconfig in Xcode does after `add`. +function rebaseDebugOnUserXcconfig(xcodeprojPath) { + const text = pbxprojOf(xcodeprojPath); + const start = text.indexOf(`${APP_DEBUG_CONFIG} /* Debug */ = {`); + const end = text.indexOf('buildSettings', start); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + text.slice(0, start) + + text + .slice(start, end) + .replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ) + + text.slice(end), + 'utf8', + ); +} + function schemePathOf(xcodeprojPath) { return path.join( xcodeprojPath, @@ -535,6 +571,95 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); }); + it('keeps the xcconfig while one configuration is re-based on a user xcconfig, removing the scripts', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + rebaseDebugOnUserXcconfig(xcodeprojPath); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const logged = logSpy.mock.calls.map(([line]) => line).join('\n'); + logSpy.mockRestore(); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;', + ); + expect(pbxprojOf(xcodeprojPath)).not.toContain('ReactNativeSPM.xcconfig'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual([true, false, false]); + expect(logged).toContain(`Kept ${XCCONFIG_REL}`); + }); + + it('accepts a user xcconfig that #includes the generated one, across syncs and deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(sync().status).toBe('injected'); + const injected = pbxprojOf(xcodeprojPath); + expect(readMarker(xcodeprojPath).xcconfig.includedByConfigUuids).toEqual([ + APP_DEBUG_CONFIG, + ]); + expect(sync().status).toBe('injected'); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(FOREIGN_XCCONFIG); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('keeps the xcconfig a recorded configuration included, even once its base is gone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + '\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n', + '', + ), + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('deletes the xcconfig for a marker written before includes were recorded', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const marker = readMarker(xcodeprojPath); + delete marker.xcconfig.includedByConfigUuids; + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify(marker, null, 2) + '\n', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); + // A ${PODS_ROOT}-anchored value resolves empty without CocoaPods, so putting // it back would only restore a broken setting. it('does not restore the ${PODS_ROOT}-anchored REACT_NATIVE_PATH it removed', () => { diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 2f2386798047..59c39f5cdd43 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -56,6 +56,11 @@ const { makeLogger, remotePackageConfig, } = require('./spm-utils'); +const { + readXcconfig, + xcconfigIncludes, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -176,12 +181,15 @@ type BuildSettingChange = { type CreatedArrayField = {container: 'project' | 'target', key: string}; // How the app configurations were based on the generated xcconfig. Deinit // clears `baseConfigurationReference` only on these, and only while it still -// points at `fileRefUuid`. `removedPodsRootReactNativePath` is sticky and -// informational: deinit does not put that value back, because it resolves -// empty without CocoaPods. +// points at `fileRefUuid`. `includedByConfigUuids` keep a base of the user's +// own whose xcconfig #includes the generated one, so deinit keeps that file; +// markers written before it existed lack it. `removedPodsRootReactNativePath` +// is sticky and informational: deinit does not put that value back, because it +// resolves empty without CocoaPods. type XcconfigRecord = { fileRefUuid: string, configUuids: Array, + includedByConfigUuids?: Array, removedPodsRootReactNativePath: boolean, }; // A plugin-contributed source, normalized for pbxproj emission. `path` is @@ -1192,15 +1200,22 @@ function generateReactNativeXcconfig( [...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)] .filter(({values}) => values.length > 0) .map(({key, values}) => listLine(key, values)), - configNames.flatMap(name => { - const flavor = flavorForBuildConfiguration(name); - return [ - `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, - ...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map( - ({key, values}) => listLine(`${key}[config=${name}]`, values), - ), - ]; - }), + [ + // For a configuration added since the last sync (duplicated in Xcode, + // which copies the base reference): flavorForBuildConfiguration's own + // fallback, rather than an unset flavor that empties every framework + // path. + 'RN_SPM_FLAVOR = release', + ...configNames.flatMap(name => { + const flavor = flavorForBuildConfiguration(name); + return [ + `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, + ...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map( + ({key, values}) => listLine(`${key}[config=${name}]`, values), + ), + ]; + }), + ], frameworkConditionalSettings(flavoredFrameworks).map( ({key, value}) => `${key} = ${value}`, ), @@ -1264,16 +1279,31 @@ function listSettingsHidingXcconfig( return hiding; } +// Xcode writes a PBXFileReference on one line, which findField can't read. +function fileReferenceField( + text /*: string */, + ref /*: {bodyOpen: number, bodyClose: number, ...} */, + key /*: string */, +) /*: ?string */ { + const value = new RegExp( + `[\\s{;]${key} = ("(?:[^"\\\\]|\\\\.)*"|[^;]*);`, + ).exec(text.slice(ref.bodyOpen, ref.bodyClose))?.[1]; + return value != null ? unquotePlist(value) : null; +} + /** - * The first app configuration based on an xcconfig other than the generated - * one, with that file's path — React Native never edits an xcconfig it does not - * own, so it cannot base that configuration on its own. + * The app configurations based on an xcconfig other than the generated one — + * React Native never edits an xcconfig it does not own, so it cannot base + * those configurations on its own. `file` is the reference's path, and + * `absPath`/`content` the file on disk when it can be read. */ -function foreignBaseConfiguration( +function foreignBaseConfigurations( text /*: string */, configUuids /*: ReadonlyArray */, ownFileRefUuid /*: string */, -) /*: ?{config: string, file: string} */ { + srcRoot /*: ?string */, +) /*: Array<{configUuid: string, config: string, file: string, onDisk: ?{absPath: string, content: string}}> */ { + const foreign = []; for (const configUuid of configUuids) { const config = findObjectByUuid(text, configUuid); const base = @@ -1284,20 +1314,35 @@ function foreignBaseConfiguration( if (refUuid == null || refUuid === ownFileRefUuid) { continue; } - // Xcode writes a PBXFileReference on one line, which findField can't read. const ref = findObjectByUuid(text, refUuid); - const refPath = - ref != null - ? /[\s{;]path = ("(?:[^"\\]|\\.)*"|[^;]*);/.exec( - text.slice(ref.bodyOpen, ref.bodyClose), - )?.[1] - : null; - return { + const refPath = ref != null ? fileReferenceField(text, ref, 'path') : null; + let onDisk /*: ?{absPath: string, content: string} */ = null; + if (ref != null && refPath != null && srcRoot != null) { + const candidates = xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: refPath, + sourceTree: fileReferenceField(text, ref, 'sourceTree') ?? '', + }, + srcRoot, + ); + for (const absPath of candidates) { + const content = readXcconfig(absPath); + if (content != null) { + onDisk = {absPath, content}; + break; + } + } + } + foreign.push({ + configUuid, config: buildConfigurationName(text, configUuid), - file: refPath != null ? unquotePlist(refPath) : refUuid, - }; + file: refPath ?? refUuid, + onDisk, + }); } - return null; + return foreign; } /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ @@ -1343,8 +1388,11 @@ function configUsesPods( * the chosen app target + its config/frameworks anchors, or a refusal reason * the caller surfaces (fail-loud). */ -function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: - | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} +function planInjection( + text /*: string */, + opts /*: {appName?: ?string, appRoot?: ?string, srcRoot?: ?string} */, +) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, includedByConfigUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} | {ok: false, reason: string} */ { const project = findProjectObject(text); if (project == null) { @@ -1387,19 +1435,40 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: 'supports SPM-only targets', }; } - const foreign = foreignBaseConfiguration( + const generatedXcconfig = + opts.appRoot != null + ? path.resolve(opts.appRoot, GENERATED_XCCONFIG_PATH) + : null; + const includedByConfigUuids /*: Array */ = []; + for (const foreign of foreignBaseConfigurations( text, configUuids, xcconfigFileRefUuid(project.uuid), - ); - if (foreign != null) { + opts.srcRoot, + )) { + const {onDisk} = foreign; + if ( + onDisk != null && + generatedXcconfig != null && + xcconfigIncludes(onDisk.absPath, onDisk.content, generatedXcconfig) + ) { + includedByConfigUuids.push(foreign.configUuid); + continue; + } + const fix = + onDisk != null && generatedXcconfig != null + ? `add #include "${path + .relative(path.dirname(onDisk.absPath), generatedXcconfig) + .split(path.sep) + .join('/')}" to ${foreign.file} instead` + : `#include ${GENERATED_XCCONFIG_PATH} from ${foreign.file} instead, ` + + `with the path relative to the folder that holds ${foreign.file}`; return { ok: false, reason: `the ${foreign.config} configuration is already based on ` + `${foreign.file}, and React Native does not edit xcconfig files it ` + - `does not own — #include ${GENERATED_XCCONFIG_PATH} (relative to the ` + - `project directory) from ${foreign.file} instead`, + `does not own — ${fix}`, }; } const hiding = listSettingsHidingXcconfig(text, configUuids); @@ -1455,6 +1524,7 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: rootUuid: project.uuid, target, configUuids, + includedByConfigUuids, frameworksPhaseUuid, sourcesPhaseUuid, }; @@ -1470,7 +1540,7 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: */ function injectSpmIntoPbxproj( input /*: string */, - plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, includedByConfigUuids?: ?ReadonlyArray, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, reactNativePaths /*: {fromAppRoot: string, fromSrcRoot: string} */, remote /*: ?RemoteCfg */, generatedSources /*: ReadonlyArray */ = [], @@ -1565,8 +1635,9 @@ function injectSpmIntoPbxproj( } // 5. Every app configuration is based on the generated xcconfig, which - // carries all React build settings. planInjection refused any other - // base configuration, so an existing one here is already ours. + // carries all React build settings — except one whose own xcconfig + // already #includes it. planInjection refused any other base + // configuration, so an existing one here is already ours. const xcconfigName = path.basename(GENERATED_XCCONFIG_PATH); const xcconfigRefUuid = xcconfigFileRefUuid(plan.rootUuid); insertObjects('PBXFileReference', [ @@ -1588,10 +1659,11 @@ function injectSpmIntoPbxproj( {uuid: xcconfigRefUuid, comment: xcconfigName}, ]); } + const includedBy = new Set(plan.includedByConfigUuids ?? []); let removedPodsRootReactNativePath = false; for (const configUuid of plan.configUuids) { const config = findObjectByUuid(text, configUuid); - if (config != null) { + if (config != null && !includedBy.has(configUuid)) { text = ensureScalarField( text, config, @@ -1676,6 +1748,7 @@ function injectSpmIntoPbxproj( ); const embedInputs = [ '$(SRCROOT)/build/xcframeworks/.artifact-stamp', + `$(SRCROOT)/${EMBED_FRAMEWORKS_SCRIPT_PATH}`, ...flavoredFrameworks.map( framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`, ), @@ -1910,7 +1983,8 @@ function injectSpmIntoPbxproj( createdArrayFields, xcconfig: { fileRefUuid: xcconfigRefUuid, - configUuids: [...plan.configUuids], + configUuids: plan.configUuids.filter(uuid => !includedBy.has(uuid)), + includedByConfigUuids: [...includedBy], removedPodsRootReactNativePath, }, generatedFiles, @@ -2493,7 +2567,11 @@ function injectSpmIntoExistingXcodeproj( original, prevMarker?.buildSettingChanges ?? [], ); - const plan = planInjection(withoutRecordedSettings, {appName: opts.appName}); + const plan = planInjection(withoutRecordedSettings, { + appName: opts.appName, + appRoot, + srcRoot: path.dirname(xcodeprojPath), + }); if (!plan.ok) { return {status: 'refused', reason: plan.reason}; } @@ -2563,6 +2641,7 @@ function injectSpmIntoExistingXcodeproj( rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, + includedByConfigUuids: plan.includedByConfigUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, @@ -2808,6 +2887,36 @@ function removeRecordedBuildSettings( return text; } +/** + * The configurations, by name, that may #include the generated xcconfig once + * deinit has un-based its own: those based on any other xcconfig, and those + * recorded as including it. + */ +function configsThatMayInclude( + text /*: string */, + targetUuid /*: string */, + xcconfig /*: XcconfigRecord */, +) /*: Array */ { + const target = findObjectByUuid(text, targetUuid); + const rebased = ( + target != null ? targetBuildConfigUuids(text, target) : [] + ).filter(uuid => { + const config = findObjectByUuid(text, uuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + return base != null && !base.value.includes(xcconfig.fileRefUuid); + }); + return [ + ...new Set([...rebased, ...(xcconfig.includedByConfigUuids ?? [])]), + ].map(uuid => + findObjectByUuid(text, uuid) != null + ? buildConfigurationName(text, uuid) + : uuid, + ); +} + /** * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the * `.spm-injected.json` marker's precise record of every edit, remove only what @@ -2862,8 +2971,9 @@ function removeSpmInjection( ); // 2. Un-base the configurations still based on the generated xcconfig, and - // delete it only then — a configuration the user has since based on an - // xcconfig of their own may #include it. A ${PODS_ROOT}-anchored + // delete it only then, and only while no configuration is based on an + // xcconfig of the user's own — that file may #include it, as the ones + // recorded in `includedByConfigUuids` do. A ${PODS_ROOT}-anchored // REACT_NATIVE_PATH injection removed is deliberately not put back: it // resolves empty without CocoaPods. const xcconfig /*: ?XcconfigRecord */ = marker.xcconfig; @@ -2882,13 +2992,25 @@ function removeSpmInjection( } } } + const includingConfigs = + xcconfig != null + ? configsThatMayInclude(text, marker.targetUuid, xcconfig) + : []; + if (includingConfigs.length > 0) { + log( + `Kept ${GENERATED_XCCONFIG_PATH}: an xcconfig of your own may still ` + + `#include it (configurations: ${includingConfigs.join(', ')})`, + ); + } // Settings a project injected before the generated xcconfig still carries // in project.pbxproj (only what we added). text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); for (const relPath of [ - ...(ownsXcconfig ? [GENERATED_XCCONFIG_PATH] : []), + ...(ownsXcconfig && includingConfigs.length === 0 + ? [GENERATED_XCCONFIG_PATH] + : []), SYNC_AUTOLINKING_SCRIPT_PATH, EMBED_FRAMEWORKS_SCRIPT_PATH, ]) { diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js index b6239b01925e..5d76ac522201 100644 --- a/packages/react-native/scripts/spm/ios-deployment-target.js +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -23,9 +23,12 @@ const { findField, findObjectByUuid, findProjectObject, - forEachObjectInSection, - uuidsInArray, } = require('./spm-pbxproj'); +const { + readXcconfig, + xcconfigIncludePath, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -87,14 +90,6 @@ function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { : MIN_IOS_VERSION_SUPPORTED; } -function defaultReadFile(absPath /*: string */) /*: ?string */ { - try { - return fs.readFileSync(absPath, 'utf8'); - } catch { - return null; - } -} - /** * `IPHONEOS_DEPLOYMENT_TARGET` as an xcconfig defines it, following `#include` * lines in place so a later assignment in the includer wins. Null when the file @@ -116,10 +111,10 @@ function parseXcconfigSetting( let value /*: ?string */ = null; for (const rawLine of content.split('\n')) { const line = rawLine.replace(/\/\/.*$/, '').trim(); - const include = line.match(/^#include\??\s+"([^"]+)"/); - if (include != null) { + const includePath = xcconfigIncludePath(absPath, line); + if (includePath != null) { const included = readXcconfigSetting( - path.resolve(path.dirname(absPath), include[1]), + includePath, readFile, visited, depth + 1, @@ -154,41 +149,6 @@ function readXcconfigSetting( : null; } -/** - * Directory components of the PBXGroup chain holding `uuid`, outermost first — - * how a `""` file reference's path is anchored to the project dir. - */ -function groupPathPrefix( - text /*: string */, - uuid /*: string */, -) /*: Array */ { - const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = - []; - forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { - const children = findField(text, body, 'children'); - const groupPath = findField(text, body, 'path'); - groups.push({ - uuid: groupUuid, - path: groupPath != null ? unquote(groupPath.value) : null, - children: children != null ? uuidsInArray(children.value) : new Set(), - }); - }); - - const parts /*: Array */ = []; - let current = uuid; - for (let i = 0; i < groups.length; i++) { - const parent = groups.find(group => group.children.has(current)); - if (parent == null) { - break; - } - if (parent.path != null) { - parts.unshift(parent.path); - } - current = parent.uuid; - } - return parts; -} - /** * Absolute paths to try for the xcconfig a configuration is based on, in order: * the reference's own anchoring first, then plain `/`. @@ -211,23 +171,16 @@ function xcconfigCandidates( if (pathField == null) { return []; } - const refPath = unquote(pathField.value); - if (path.isAbsolute(refPath)) { - return [refPath]; - } const sourceTreeField = findField(text, ref, 'sourceTree'); - const sourceTree = - sourceTreeField != null ? unquote(sourceTreeField.value) : ''; - const fallback = path.join(srcRoot, refPath); - if (sourceTree !== '') { - return [fallback]; - } - const anchored = path.join( + return xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: unquote(pathField.value), + sourceTree: sourceTreeField != null ? unquote(sourceTreeField.value) : '', + }, srcRoot, - ...groupPathPrefix(text, refUuid), - refPath, ); - return anchored === fallback ? [fallback] : [anchored, fallback]; } function configName( @@ -300,7 +253,7 @@ function readIosDeploymentTargetFromPbxproj( ) /*: ?string */ { const ctx /*: XcconfigContext */ = { srcRoot: opts?.srcRoot, - readFile: opts?.readFile ?? defaultReadFile, + readFile: opts?.readFile ?? readXcconfig, }; const targetUuid = opts?.targetUuid; const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; diff --git a/packages/react-native/scripts/spm/xcconfig-files.js b/packages/react-native/scripts/spm/xcconfig-files.js new file mode 100644 index 000000000000..42a6021901a7 --- /dev/null +++ b/packages/react-native/scripts/spm/xcconfig-files.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * Where the xcconfig file a project references lives on disk, and the files it + * pulls in through `#include` — resolved the way Xcode resolves them. + */ + +const { + findField, + forEachObjectInSection, + uuidsInArray, +} = require('./spm-pbxproj'); +const fs = require('node:fs'); +const path = require('node:path'); + +function unquote(value /*: string */) /*: string */ { + return value.replace(/^"|"$/g, ''); +} + +function readXcconfig(absPath /*: string */) /*: ?string */ { + try { + return fs.readFileSync(absPath, 'utf8'); + } catch { + return null; + } +} + +/** + * Directory components of the PBXGroup chain holding `uuid`, outermost first — + * how a `""` file reference's path is anchored to the project dir. + */ +function groupPathPrefix( + text /*: string */, + uuid /*: string */, +) /*: Array */ { + const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = + []; + forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { + const children = findField(text, body, 'children'); + const groupPath = findField(text, body, 'path'); + groups.push({ + uuid: groupUuid, + path: groupPath != null ? unquote(groupPath.value) : null, + children: children != null ? uuidsInArray(children.value) : new Set(), + }); + }); + + const parts /*: Array */ = []; + let current = uuid; + for (let i = 0; i < groups.length; i++) { + const parent = groups.find(group => group.children.has(current)); + if (parent == null) { + break; + } + if (parent.path != null) { + parts.unshift(parent.path); + } + current = parent.uuid; + } + return parts; +} + +/** + * Absolute paths to try for the file a PBXFileReference names, in order: the + * reference's own anchoring first, then plain `/`. `ref.path` + * and `ref.sourceTree` are unquoted. + */ +function xcconfigReferencePaths( + text /*: string */, + ref /*: {uuid: string, path: string, sourceTree: string} */, + srcRoot /*: string */, +) /*: Array */ { + if (path.isAbsolute(ref.path)) { + return [ref.path]; + } + const fallback = path.join(srcRoot, ref.path); + if (ref.sourceTree !== '') { + return [fallback]; + } + const anchored = path.join( + srcRoot, + ...groupPathPrefix(text, ref.uuid), + ref.path, + ); + return anchored === fallback ? [fallback] : [anchored, fallback]; +} + +/** + * The absolute path an `#include "…"` or `#include? "…"` line of the xcconfig + * at `absPath` names, or null for any other line. Xcode resolves it from the + * folder of the file holding the line. + */ +function xcconfigIncludePath( + absPath /*: string */, + line /*: string */, +) /*: ?string */ { + const include = line + .replace(/\/\/.*$/, '') + .trim() + .match(/^#include\??\s+"([^"]+)"/); + return include != null + ? path.resolve(path.dirname(absPath), include[1]) + : null; +} + +/** + * Whether the xcconfig at `absPath` (holding `content`) pulls in `targetPath` + * through its `#include` chain. An included file that cannot be read ends its + * branch of the chain. + */ +function xcconfigIncludes( + absPath /*: string */, + content /*: string */, + targetPath /*: string */, + visited /*: Set */ = new Set(), +) /*: boolean */ { + visited.add(absPath); + for (const line of content.split('\n')) { + const included = xcconfigIncludePath(absPath, line); + if (included == null || visited.has(included)) { + continue; + } + if (included === path.resolve(targetPath)) { + return true; + } + const next = readXcconfig(included); + if (next != null && xcconfigIncludes(included, next, targetPath, visited)) { + return true; + } + } + return false; +} + +module.exports = { + readXcconfig, + xcconfigIncludePath, + xcconfigIncludes, + xcconfigReferencePaths, +};