diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml index bf835a8f0fa3..12713e06dc9f 100644 --- a/.github/workflows/test-ios-spm-helloworld.yml +++ b/.github/workflows/test-ios-spm-helloworld.yml @@ -94,11 +94,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/.github/workflows/test-ios-spm-newapp.yml b/.github/workflows/test-ios-spm-newapp.yml index 62e66395bae1..8fafbf48fa55 100644 --- a/.github/workflows/test-ios-spm-newapp.yml +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -105,11 +105,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml index 60bfbf0d941e..cce98b0ba3d4 100644 --- a/.github/workflows/test-ios-spm-rntester.yml +++ b/.github/workflows/test-ios-spm-rntester.yml @@ -94,11 +94,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/packages/react-native/scripts/cocoapods/autolinking.rb b/packages/react-native/scripts/cocoapods/autolinking.rb index d8e9c7cec2f5..1d15545e497e 100644 --- a/packages/react-native/scripts/cocoapods/autolinking.rb +++ b/packages/react-native/scripts/cocoapods/autolinking.rb @@ -208,5 +208,6 @@ def link_native_modules!(config) # - config_command: the command to run to get the application's current config, e.g. ['npx', '@react-native-community/cli', 'config'], # you can override this if you'd like to avoid the dependency. e.g. ['cat', 'your_config.json'] def use_native_modules!(config_command = $default_command) + ReactNativePodsUtils.error_if_spm_injected return link_native_modules!(list_native_modules!(config_command)) end diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index 6473278a2e7e..84c24e88b8ee 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -316,6 +316,24 @@ def self.create_xcode_env_if_missing(file_manager: File) end end + # Stops `pod install` on an app that `npx react-native spm add` converted to + # Swift Package Manager. Codegen would rewrite the app's generated + # Package.swift, and Xcode resolves packages before any build phase can + # report it, so this has to run before React Native writes any file. + # prepare_react_native_project!, use_native_modules! and use_react_native! + # all call it, because a Podfile need not call the first. + def self.error_if_spm_injected + podfile_dir = Pod::Config.instance.installation_root.to_s + xcodeproj = Dir.glob('*.xcodeproj', base: podfile_dir).sort.find do |name| + File.exist?(File.join(podfile_dir, name, '.spm-injected.json')) + end + return if xcodeproj.nil? + + raise Pod::Informative, "This app uses Swift Package Manager for React Native " + + "(#{xcodeproj} has .spm-injected.json), so `pod install` would break the Xcode project. " + + "To go back to CocoaPods, run `npx react-native spm deinit` first, then `pod install`." + end + # It examines the target_definition property and sets the appropriate value for # ENV['USE_FRAMEWORKS'] variable. # diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index f8e3f7d729d7..41388454bc25 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -47,6 +47,8 @@ def min_supported_versions # This function prepares the project for React Native, before processing # all the target exposed by the framework. def prepare_react_native_project! + ReactNativePodsUtils.error_if_spm_injected + # Temporary solution to suppress duplicated GUID error & master specs repo warning. # Can be removed once we move to generate files outside pod install. install! 'cocoapods', :deterministic_uuids => false, :warn_for_unused_master_specs_repo => false @@ -96,6 +98,7 @@ def use_react_native! ( config_file_dir: '', privacy_file_aggregation_enabled: true ) + ReactNativePodsUtils.error_if_spm_injected error_if_try_to_use_jsc_from_core() warn_if_new_arch_disabled() diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index b445bb2969b1..38d1e40ea39f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -10,7 +10,11 @@ 'use strict'; -/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ +/*:: import type { + CliConfigJson, + GitignoreRecord, + SetupArgs, +} from './spm/spm-types'; */ /** * setup-apple-spm.js – Entry point for setting up Swift Package Manager support @@ -25,13 +29,14 @@ * settings, the Sync build phase) into your * existing .xcodeproj, in place. Idempotent. * Default on first run. `--deintegrate` first - * runs `pod deintegrate` + strips React Native - * from the Podfile (CocoaPods → SPM migration). + * runs `pod deintegrate` and keeps the Podfile + * (CocoaPods → SPM migration). * update Re-run the pipeline and refresh the existing * injection. Default once a project is injected. * deinit The exact inverse of `add`: surgically remove * only what `add` injected (recorded in - * .spm-injected.json) and drop the marker. + * .spm-injected.json) and drop the marker. Then + * `pod install` goes back to CocoaPods. * scaffold Generate Package.swift for community deps that * lack SPM support. * sync Re-run the build-time autolinking sync and its @@ -39,10 +44,9 @@ * it; run it by hand after precompiling a module. * codegen / download Advanced/internal: run a single pipeline step. * - * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods - * project (clean tree, stock Podfile) → `add --deintegrate`; an injected - * project → `update`; otherwise → `add` (fails loud on a CocoaPods project, - * directing you to `--deintegrate`). + * Zero-arg `npx react-native spm` auto-detects: an injected project → `update`; + * otherwise → `add` (fails loud on a CocoaPods project, directing you to + * `--deintegrate`). * * Options: * --version React Native version. Pinned into @@ -52,8 +56,8 @@ * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). - * [add] --deintegrate Run `pod deintegrate` + strip RN from the - * Podfile before injecting. + * [add] --deintegrate Run `pod deintegrate` before injecting. The + * Podfile is kept. * [advanced] --artifacts Local two-flavor artifact root; * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). @@ -197,7 +201,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { type: 'boolean', default: false, describe: - '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + '[add] Run `pod deintegrate` before injecting (CocoaPods → SPM migration). The Podfile is kept.', }) .option('artifacts', { type: 'string', @@ -266,36 +270,101 @@ const SPM_GITIGNORE_ENTRIES = [ 'build/xcframeworks/', '.build/', ]; +const SPM_GITIGNORE_HEADER = + '# SPM – auto-generated at build time (do not commit)'; /** - * Ensure the project's .gitignore contains entries for SPM-generated - * directories. Called during init so that generated artifacts are not - * accidentally committed. + * The lines `add` appends to the project's .gitignore so SPM-generated + * directories are not committed, and whether that creates the file; null when + * every entry is already there. The injector records them in the marker for + * `spm deinit` to undo. */ -function ensureGitignoreSpmEntries(appRoot /*: string */) { +function missingGitignoreSpmEntries( + appRoot /*: string */, +) /*: ?GitignoreRecord */ { const gitignorePath = path.join(appRoot, '.gitignore'); - let content = ''; - if (fs.existsSync(gitignorePath)) { - content = fs.readFileSync(gitignorePath, 'utf8'); - } - + const exists = fs.existsSync(gitignorePath); + const content = exists ? fs.readFileSync(gitignorePath, 'utf8') : ''; const existingEntries = new Set(content.split('\n').map(l => l.trim())); const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); + return missing.length === 0 + ? null + : {created: !exists, lines: [SPM_GITIGNORE_HEADER, ...missing]}; +} - if (missing.length === 0) { - return; - } - - const block = [ - '', - '# SPM – auto-generated at build time (do not commit)', - ...missing, - ].join('\n'); - - // Append, ensuring we start on a fresh line +function appendGitignoreSpmEntries( + appRoot /*: string */, + appended /*: GitignoreRecord */, +) /*: void */ { + const gitignorePath = path.join(appRoot, '.gitignore'); + const content = fs.existsSync(gitignorePath) + ? fs.readFileSync(gitignorePath, 'utf8') + : ''; const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + // The blank line before the header is what removeGitignoreSpmEntries + // removes with it. + const block = ['', ...appended.lines].join('\n'); fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); - log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); + const entries = appended.lines.filter(line => line !== SPM_GITIGNORE_HEADER); + log(`Updated .gitignore with SPM entries: ${entries.join(', ')}`); +} + +/** + * The inverse of appendGitignoreSpmEntries: removes the lines `record` says + * `add` appended (a header with the blank line written before it), and the + * file when `add` created it and nothing else is left. Without a record, as + * in a marker from before it existed, .gitignore is left untouched. + */ +function removeGitignoreSpmEntries( + appRoot /*: string */, + record /*: ?GitignoreRecord */, +) { + const gitignorePath = path.join(appRoot, '.gitignore'); + if ( + record == null || + record.lines.length === 0 || + !fs.existsSync(gitignorePath) + ) { + return; + } + const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n'); + let changedByUser = 0; + // Last occurrence first: `add` only ever appends. + for (const recorded of [...record.lines].reverse()) { + const index = lines.lastIndexOf(recorded); + if (index === -1) { + changedByUser++; + } else if ( + recorded === SPM_GITIGNORE_HEADER && + index > 0 && + lines[index - 1] === '' + ) { + lines.splice(index - 1, 2); + } else { + lines.splice(index, 1); + } + } + const remaining = lines.join('\n'); + if (record.created && remaining.trim() === '') { + fs.rmSync(gitignorePath); + log('Removed .gitignore, which `spm add` created.'); + return; + } + fs.writeFileSync(gitignorePath, remaining, 'utf8'); + if (changedByUser > 0) { + log( + `.gitignore: ${changedByUser} SPM line(s) changed or removed after ` + + '`spm add` were left as they are. Remove any SPM entries you no ' + + 'longer need.', + ); + } else if (record.created) { + log( + 'Removed the SPM entries from .gitignore. Kept the file, which ' + + '`spm add` created, because it now has lines of your own.', + ); + } else { + log('Removed the SPM entries from .gitignore.'); + } } /** @@ -334,9 +403,8 @@ function resolveAction( return requestedAction; } // Zero-arg default. Once SPM has been injected, default to `update` (regen + - // refresh). Otherwise it's a first run → `add`. Whether `add` should imply - // `--deintegrate` (fresh CocoaPods project) is decided by the safe-gate in - // main(), which only applies on this implicit path. + // refresh). Otherwise it's a first run → `add`, which fails loud on a + // CocoaPods project: converting one takes an explicit `add --deintegrate`. return findInjectedXcodeproj(appRoot) != null ? 'update' : 'add'; } @@ -681,63 +749,58 @@ function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { } } -// True when the Podfile still declares React Native integration — a latent -// landmine even after `pod deintegrate` (a future `pod install` re-breaks the -// SPM graph). Warned about (not refused) once the pbxproj itself is clean. -function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { - const podfilePath = path.join(appRoot, 'Podfile'); - if (!fs.existsSync(podfilePath)) { - return false; - } - return /use_react_native!|use_native_modules!|prepare_react_native_project!/.test( - fs.readFileSync(podfilePath, 'utf8'), +function cocoaPodsIntegratedMessage(xcodeprojPath /*: string */) /*: string */ { + return ( + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. To move it to ` + + 'SwiftPM, run `npx react-native spm add --deintegrate`. It runs ' + + '`pod deintegrate` and keeps the Podfile, so ' + + '`npx react-native spm deinit` and then `pod install` go back to ' + + 'CocoaPods. Pods other than React Native are not installed while the app ' + + 'uses SwiftPM, unless SwiftPM provides them.' ); } -// True when the Podfile declares any explicit `pod '...'` (third-party pods). -function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { +// A `:path` or `:podspec` value, in either Ruby hash syntax, that points into +// the react-native package itself. +const REACT_NATIVE_POD_SOURCE = + /(?::(?:path|podspec)\s*=>|\b(?:path|podspec):)\s*(['"])(?:[^'"]*\/)?node_modules\/react-native(?:\/[^'"]*)?\1/; + +// The names of the pods the Podfile declares explicitly (`pod '...'` or +// `pod('...')`), deduplicated, in order. A pod sourced from the react-native +// package is part of React Native and left out, as is everything +// use_react_native! brings in. +function podfilePodNames(appRoot /*: string */) /*: Array */ { const podfilePath = path.join(appRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { - return false; - } - return /^\s*pod\s+['"]/m.test(fs.readFileSync(podfilePath, 'utf8')); -} - -// The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it -// is provably safe — a first-run CocoaPods RN project with a stock Podfile (no -// third-party pods) whose pbxproj AND Podfile are git-tracked and clean, so the -// conversion is fully revertible. We check only the two files `deintegrate` -// mutates (not the whole tree) — a fresh app typically has a dirty -// node_modules/lockfile/patches after `npm install` + `spm scaffold`, none of -// which affect the revertibility of the CocoaPods → SwiftPM conversion. -// Otherwise false → strict `add` (which fails loud on a CocoaPods project). -function shouldAutoDeintegrate( - appRoot /*: string */, - xcodeprojPath /*: string | null */, -) /*: boolean */ { - if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { - return false; - } - if (podfileHasThirdPartyPods(appRoot)) { - return false; - } - const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); - if (gitTrackedAndClean(appRoot, pbxprojPath) !== true) { - return false; + return []; + } + const statements /*: Array */ = []; + let continued = ''; + for (const line of fs.readFileSync(podfilePath, 'utf8').split('\n')) { + // `#{` is Ruby string interpolation, not a comment. A line that ends in + // `,` continues on the next one. + const code = (continued + line.split(/#(?!\{)/)[0]).trimEnd(); + if (code.endsWith(',')) { + continued = code + ' '; + } else { + continued = ''; + statements.push(...code.split(';')); + } } - const podfilePath = path.join(appRoot, 'Podfile'); - if ( - fs.existsSync(podfilePath) && - gitTrackedAndClean(appRoot, podfilePath) !== true - ) { - return false; + statements.push(continued); + const names /*: Set */ = new Set(); + for (const statement of statements) { + const match = /^\s*pod\s*\(?\s*(['"])(.+?)\1/.exec(statement); + if (match != null && !REACT_NATIVE_POD_SOURCE.test(statement)) { + names.add(match[2]); + } } - return true; + return [...names]; } -// Run `pod deintegrate` then strip React Native from the Podfile (leaving any -// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒ -// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace. +// Run `pod deintegrate` and leave the Podfile as it is, so `spm deinit` and +// then `pod install` go back to CocoaPods. Requires CocoaPods on PATH +// (fail-loud otherwise). Does NOT touch the .xcworkspace. function runDeintegrate(appRoot /*: string */) /*: void */ { try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); @@ -751,24 +814,6 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { } log('Running `pod deintegrate`...'); execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); - - const podfilePath = path.join(appRoot, 'Podfile'); - if (fs.existsSync(podfilePath)) { - const orig = fs.readFileSync(podfilePath, 'utf8'); - const stripped = orig - .split('\n') - .filter( - l => - !/use_react_native!|use_native_modules!|prepare_react_native_project!/.test( - l, - ), - ) - .join('\n'); - if (stripped !== orig) { - fs.writeFileSync(podfilePath, stripped, 'utf8'); - log('Stripped React Native integration from Podfile.'); - } - } } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -852,6 +897,7 @@ async function setupXcodeproj( reactNativeRoot /*: string */, action /*: string */, cxxApiDefines /*: ReadonlyArray */, + gitignoreAppended /*: ?GitignoreRecord */, ) /*: Promise */ { const target = resolveInjectionTarget(args, appRoot); if (target.error != null) { @@ -862,44 +908,10 @@ async function setupXcodeproj( const xcodeprojPath = target.path; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); - // Snapshot the pbxproj's git state BEFORE deintegrate — `pod deintegrate` - // rewrites it (removing the Pods xcconfig layering), so checking afterward - // would always look dirty and trigger a spurious confirmation prompt. - const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); - - if (args.deintegrate) { - runDeintegrate(appRoot); - // `pod deintegrate` strips the build integration but can leave an empty - // `Pods` group in the navigator — remove it so the converted project is - // visually clean. - if (cleanupLeftoverPodsGroup(xcodeprojPath)) { - log('Removed the leftover empty `Pods` group from the project.'); - } - } - - // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. - if (pbxprojUsesCocoaPods(xcodeprojPath)) { - logError( - `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); - process.exitCode = 1; - throw new Error('CocoaPods-integrated project'); - } - if (podfileHasRnIntegration(appRoot)) { - log( - '\x1b[33mNote: your Podfile still declares React Native integration. ' + - 'Remove it and avoid `pod install`, or it will re-break the SPM ' + - 'package graph.\x1b[0m', - ); - } - // No backup is made — git is the safety net. Refuse on a dirty/untracked - // pbxproj (as it was BEFORE any deintegrate edits) unless --yes, so a bad - // inject is always `git checkout`-able. - const clean = cleanBeforeEdits; + // pbxproj unless --yes, so a bad inject is always `git checkout`-able. Asked + // before `pod deintegrate` edits the pbxproj, so "no" changes nothing. + const clean = gitTrackedAndClean(appRoot, pbxprojPath); if (clean === false && !args.yes) { const proceed = await promptYesNo( `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + @@ -918,6 +930,23 @@ async function setupXcodeproj( ); } + if (args.deintegrate) { + runDeintegrate(appRoot); + // `pod deintegrate` strips the build integration but can leave an empty + // `Pods` group in the navigator — remove it so the converted project is + // visually clean. + if (cleanupLeftoverPodsGroup(xcodeprojPath)) { + log('Removed the leftover empty `Pods` group from the project.'); + } + } + + // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. + if (pbxprojUsesCocoaPods(xcodeprojPath)) { + logError(cocoaPodsIntegratedMessage(xcodeprojPath)); + process.exitCode = 1; + throw new Error('CocoaPods-integrated project'); + } + const result = injectSpmIntoExistingXcodeproj({ appRoot, reactNativeRoot, @@ -930,12 +959,17 @@ async function setupXcodeproj( artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), cxxApiDefines, + gitignoreAppended, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); process.exitCode = 1; throw new Error(result.reason); } + // Written at once: the marker now records these lines, whatever fails next. + if (gitignoreAppended != null) { + appendGitignoreSpmEntries(appRoot, gitignoreAppended); + } // The community template has carried a dangling `JavaScriptCore.framework` // reference since RN 0.60 (navigator-only, unlinked, meaningless under @@ -1107,26 +1141,6 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { const action = resolveAction(args.action, appRoot); - // Zero-arg safe-gate: when `add` was resolved implicitly (no action typed) - // on a freshly-scaffolded CocoaPods project, imply `--deintegrate` so the - // common "new app → SPM" path is one command. Never on an explicit `spm add` - // (that stays strict and fails loud on CocoaPods). - if ( - action === 'add' && - args.action == null && - !args.deintegrate && - shouldAutoDeintegrate( - appRoot, - resolveInjectionTarget(args, appRoot).path ?? null, - ) - ) { - log( - 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + - '(running `pod deintegrate`). Revert with `git` or `spm deinit`.', - ); - args.deintegrate = true; - } - log(`Running SPM ${action} in: ${displayPath(appRoot)}`); if (projectRoot !== appRoot) { log(`Project root (package.json): ${displayPath(projectRoot)}`); @@ -1137,32 +1151,56 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { args.xcodeprojPath != null ? path.resolve(appRoot, args.xcodeprojPath) : findInjectedXcodeproj(appRoot); - if (xcodeprojPath == null) { + // removeSpmInjection drops the marker, and the record with it. + const gitignoreRecord = + xcodeprojPath != null ? readMarker(xcodeprojPath)?.gitignore : null; + const removed = + xcodeprojPath != null && + removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed'; + if (removed && xcodeprojPath != null) { + log(`Removed SPM packages from ${path.basename(xcodeprojPath)}.`); + } else { log('No SPM injection found — nothing to remove.'); - return; } - const result = removeSpmInjection({appRoot, xcodeprojPath}); - log( - result.status === 'removed' - ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` - : 'No SPM injection found — nothing to remove.', - ); + try { + removeGitignoreSpmEntries(appRoot, gitignoreRecord); + } catch (e) { + logError( + `Could not remove the SPM entries from .gitignore: ${e.message}. ` + + 'Remove them by hand.', + ); + } + if (removed && fs.existsSync(path.join(appRoot, 'Podfile'))) { + const podInstall = + appRoot === process.cwd() + ? 'pod install' + : `cd ${path.relative(process.cwd(), appRoot)} && pod install`; + log(`To go back to CocoaPods, run \`${podInstall}\`.`); + } return; } + if (args.deintegrate) { + const podNames = podfilePodNames(appRoot); + if (podNames.length > 0) { + log( + '\x1b[33mYour Podfile declares pods that are not part of React ' + + `Native: ${podNames.join(', ')}. They are not installed while the ` + + 'app uses SwiftPM, unless SwiftPM provides them. The Podfile is ' + + 'kept, so `npx react-native spm deinit` and then `pod install` ' + + 'bring them back.\x1b[0m', + ); + } + } + // Fail fast on a CocoaPods-integrated target BEFORE the (expensive) pipeline, // so the user isn't made to wait through codegen + artifact download only to - // be told to re-run with --deintegrate. Skipped when --deintegrate is set - // (explicitly or via the safe-gate) — deintegration happens in setupXcodeproj. + // be told to re-run with --deintegrate. Skipped when --deintegrate is set — + // deintegration happens in setupXcodeproj. if ((action === 'add' || action === 'update') && !args.deintegrate) { const target = resolveInjectionTarget(args, appRoot); if (target.path != null && pbxprojUsesCocoaPods(target.path)) { - logError( - `${path.basename(target.path)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); + logError(cocoaPodsIntegratedMessage(target.path)); process.exitCode = 1; return; } @@ -1379,15 +1417,22 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // so no path-locator JSON is written. buildPerAppHeaderTree(appRoot, {log}); - // First-time setup only adds the gitignore entries on `add`. - if (action === 'add') { - ensureGitignoreSpmEntries(appRoot); - } + // Only `add` adds the .gitignore entries. setupXcodeproj writes them once the + // injector has recorded them in the marker, so `spm deinit` removes them. + const gitignoreAppended = + action === 'add' ? missingGitignoreSpmEntries(appRoot) : null; // Xcodeproj setup: in-place injection into the existing project (the only // strategy — no rename, no from-scratch; git is the safety net). try { - await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines); + await setupXcodeproj( + args, + appRoot, + reactNativeRoot, + action, + cxxApiDefines, + gitignoreAppended, + ); } catch (e) { logError(`xcodeproj setup failed: ${e.message}`); if (process.exitCode == null) { @@ -1407,16 +1452,19 @@ if (require.main === module) { module.exports = { main, + appendGitignoreSpmEntries, detectStandardRnLayoutRedirect, determineVersion, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, + missingGitignoreSpmEntries, parseArgs, + podfilePodNames, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveExplicitConfigCommand, resolveCxxApiDefines, - shouldAutoDeintegrate, + runDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index b04da44fc81f..c5f414f9ae84 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -23,6 +23,16 @@ cd ios npx react-native spm # add on first run, update thereafter ``` +On a CocoaPods app, this stops and changes nothing. To convert the app, run +`npx react-native spm add --deintegrate`. It runs `pod deintegrate` and keeps +the `Podfile`. It warns about pods that are not part of React Native, because +they are not installed while the app uses SwiftPM. + +`pod install` on a converted app stops with an error. To go back to CocoaPods, +run `npx react-native spm deinit`, then `pod install` in `ios/`. If your Podfile +does not call `react_native_post_install`, check `REACT_NATIVE_PATH`; see +[Removing / resetting](./spm-scripts.md#removing--resetting). + **If an autolinked dependency ships no `Package.swift`, setup stops with `error: Package.swift is missing for library ""` and exit code 2.** Run `npx react-native spm scaffold`, persist the manifest with `patch-package`, and diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 4a8460d1a3a7..7e6dc3878a62 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -16,8 +16,7 @@ cd ios # First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, # in place. `npx react-native spm` with no action auto-resolves to `add` (or -# `update` once injected); on a fresh CocoaPods app it converts in one command -# (implies --deintegrate). To do it explicitly: +# `update` once injected). A CocoaPods app needs --deintegrate to convert: npx react-native spm add --deintegrate # Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. @@ -39,31 +38,40 @@ dependency changes. **On a fresh clone or CI checkout, run ## CocoaPods → SwiftPM migration `spm add` injects into a project that is **not** CocoaPods-integrated. On a -CocoaPods app it fails loud and points you at `--deintegrate`, which: +CocoaPods app, `spm add` and a plain `spm` stop before they change anything and +point you at `--deintegrate`. `spm add --deintegrate`: 1. runs `pod deintegrate`. This removes CocoaPods integration from the - `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your - `Podfile` is left on disk. -2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. - Every other line, **including your own `pod '…'` entries, is preserved**. The - strip is line-based: it deletes each line that contains one of those names, - and nothing else. The argument lines of a multi-line `use_react_native!(` - call and any `react_native_post_install(...)` call remain, so remove them by - hand before you run `pod install`. + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). +2. keeps the `Podfile` as it is. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM. No pods are linked yet, because deintegrate -removed the integration. +If the `.xcodeproj` has uncommitted changes, the command asks before step 1. +Answer no, and the `.xcodeproj` and the Podfile stay as they are. + +If the Podfile declares pods that are not part of React Native, `--deintegrate` +converts the app anyway and names those pods in a warning. See +[Keeping non-RN pods](#keeping-non-rn-pods). + +`pod install` on a converted app stops with an error and changes nothing. To go +back to CocoaPods, run `npx react-native spm deinit`, then `pod install`. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the -leftover React Native lines (see step 2 above), then re-integrate the pods with -a normal install: +`--deintegrate` converts an app whose Podfile has pods that are not part of +React Native, and warns. While the app uses SwiftPM, CocoaPods does not install +those pods, unless SwiftPM provides them as an autolinked library or a +[local native module](#local-native-modules). They stay in the Podfile, so +`spm deinit` and then `pod install` bring them back. + +To keep a CocoaPods-only pod next to SwiftPM, convert by hand: ```bash -pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +pod deintegrate # removes the CocoaPods integration from the .xcodeproj +# remove use_react_native!, use_native_modules!, prepare_react_native_project! +# and react_native_post_install from the Podfile; keep your own pods +npx react-native spm add +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes @@ -72,8 +80,7 @@ other pods through CocoaPods. > **Do not re-add `use_react_native!`.** React Native must come from _either_ > SwiftPM _or_ CocoaPods, never both. They share `build/generated/`, so a -> dual-managed RN does not build. If only the Podfile still declares -> `use_react_native!`, `spm add` prints a warning and continues. +> dual-managed RN does not build. The migration is fully reversible — see [Removing / resetting](#removing--resetting). @@ -105,23 +112,21 @@ react-native spm [action] [options] ``` With no action, the command **auto-resolves**: if SwiftPM has been injected -(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On -a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile), the -zero-arg path also implies `--deintegrate` (the safe-gate). So -`npx react-native spm` converts a brand-new app to SwiftPM in one command. +(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. It +does not convert a CocoaPods app; that takes `spm add --deintegrate`. From the JS root of a standard RN app (sibling `ios/` subdir), the command redirects into `ios/` and prints a banner. -| Action | Description | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | +| Action | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` and keeps the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Leaves the Podfile alone; run `pod install` next to go back to CocoaPods. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | ## CLI Options @@ -133,7 +138,7 @@ Flags below use the `react-native spm` (camelCase) form. | `--yes` | Skip the dirty-pbxproj confirmation prompt | | `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | | `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--deintegrate` | [add] Run `pod deintegrate` before injecting. The Podfile is kept. Warns about pods that are not part of React Native; see [Keeping non-RN pods](#keeping-non-rn-pods) | | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | @@ -287,16 +292,15 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | -| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | +| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted, then each `ReactNativeSPM/` directory left empty | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | Yes — only the lines `add` wrote, as recorded in the marker, and the file too if `add` created it and nothing else is left. A line you changed is kept, and `deinit` says so. A marker from an older version has no record, so `.gitignore` is left as it is | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -684,10 +688,21 @@ the context/return contract, lifecycle, and failure behavior. To remove SwiftPM entirely, use `deinit` (the inverse of `add`): ```bash -react-native spm deinit # surgically removes everything `add` injected -pod install # then, to restore CocoaPods +npx react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods ``` +`deinit` also removes the `.gitignore` lines that `add` recorded in the marker. +It leaves the Podfile alone, so `pod install` can use it. + +The conversion (`spm add`) removed the app target's `${PODS_ROOT}`-anchored +`REACT_NATIVE_PATH` and the unused `JavaScriptCore.framework` reference, and +`deinit` does not put them back. `pod install` sets `REACT_NATIVE_PATH` again at +project level, through `react_native_post_install`, and the app target inherits +it. Check it by hand only if your Podfile does not call +`react_native_post_install`, or if the target's own value differed from the +project's. + `deinit` removes each configuration's base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` and deletes the generated scripts. It deletes the xcconfig too, unless a configuration is based on an xcconfig of your @@ -712,7 +727,8 @@ across apps; refresh it with `react-native spm update --download force`. | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | | Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and keeps the Podfile). Pods that are not part of React Native are not installed while the app uses SwiftPM (see [Keeping non-RN pods](#keeping-non-rn-pods)). | +| `pod install` fails: "This app uses Swift Package Manager for React Native" | The app was converted with `spm add`. To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install`. | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | @@ -726,7 +742,7 @@ across apps; refresh it with `react-native spm update --download force`. | "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Want to revert to CocoaPods | `npx react-native spm deinit`, then `pod install` in `ios/`. Without `react_native_post_install` in the Podfile, check `REACT_NATIVE_PATH` (see [Removing / resetting](#removing--resetting)) | --- @@ -754,7 +770,6 @@ my-app/ios/ ReactNativeSPM/ <-- committed, rewritten by add/update ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations Scripts/ <-- scripts the Sync and embed phases run - Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) build/ generated/ autolinking/ <-- gitignored (regenerated at build time) 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 0297e5e2eb36..850eb96df502 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 @@ -567,6 +567,83 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + it('removes the ReactNativeSPM directories its file removal leaves empty', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.existsSync(path.join(appRoot, 'ReactNativeSPM'))).toBe(false); + }); + + it('keeps a ReactNativeSPM directory that still holds a file of the user', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const userScript = path.join(appRoot, 'ReactNativeSPM', 'Scripts', 'my.sh'); + fs.writeFileSync(userScript, 'echo mine\n', 'utf8'); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.readFileSync(userScript, 'utf8')).toBe('echo mine\n'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual(GENERATED_FILES.map(() => false)); + }); + + it('prunes no directory outside the app through a symlinked ReactNativeSPM', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-outside-')); + scaffoldedAppRoots.push(outside); + fs.mkdirSync(path.join(outside, 'Scripts')); + fs.symlinkSync(outside, path.join(appRoot, 'ReactNativeSPM'), 'dir'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.statSync(path.join(outside, 'Scripts')).isDirectory()).toBe(true); + expect( + fs.lstatSync(path.join(appRoot, 'ReactNativeSPM')).isSymbolicLink(), + ).toBe(true); + }); + + it('warns and carries on when an empty directory cannot be removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const rmdirSpy = jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + try { + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(warnSpy.mock.calls.join('\n')).toContain('EACCES'); + } finally { + rmdirSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + it('round-trips WITH a generated-sources manifest (add then deinit is byte-identical)', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); writeManifest(appRoot, [PROVIDER_REL]); @@ -1855,3 +1932,68 @@ describe("a list setting of the project's own that keeps $(inherited)", () => { expect(after).not.toContain(SCALAR); }); }); + +// --------------------------------------------------------------------------- +// gitignore — the marker field recording exactly what `spm add` wrote to the +// app's .gitignore, so `deinit` (setup-apple-spm.js) undoes only that. +// --------------------------------------------------------------------------- +describe('gitignore marker field', () => { + const RECORD = {created: true, lines: ['# SPM', 'Package.resolved']}; + + it('records the .gitignore record add passes in', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: RECORD, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('preserves the record on a later run that passes none (update)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: RECORD, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('extends the record with the lines a later add appended', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: RECORD, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: {created: false, lines: ['# SPM', '.build/']}, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual({ + created: true, + lines: ['# SPM', 'Package.resolved', '# SPM', '.build/'], + }); + }); + + it('writes no record when add never passed one', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('gitignore'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 02ece867a323..0a916ba63ee9 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -10,19 +10,93 @@ 'use strict'; +// `pod` is faked so the CocoaPods conversion tests need no CocoaPods install, +// and the autolinking config command (spawnSync → npx) fails fast instead of +// reaching the network. A test that sets mockAutolinkingSourceDir lets the +// config command succeed instead, so `main` gets far enough to write +// build/generated/ — what "refuses before any change" has to rule out. git and +// everything else run for real. +const mockPodCalls = []; +let mockAutolinkingSourceDir = null; +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { + ...actual, + execFileSync: (command, args, options) => { + if (command === 'pod') { + mockPodCalls.push(args); + return ''; + } + return actual.execFileSync(command, args, options); + }, + spawnSync: () => + mockAutolinkingSourceDir == null + ? {status: 1, stdout: '', stderr: 'stubbed in test'} + : { + status: 0, + stdout: JSON.stringify({ + project: {ios: {sourceDir: mockAutolinkingSourceDir}}, + dependencies: {}, + }), + stderr: '', + }, + }; +}); + +// A test that sets mockInjectorCalls to an array runs `add` to the end: the +// xcframeworks package (which needs real XCFramework slices) is skipped, and +// the injector (which needs that package's output) only records its options, +// then refuses with mockInjectorRefusal when a test sets one. A test that sets +// mockCleanupError makes the cleanup that follows a successful injection throw. +let mockInjectorCalls = null; +let mockInjectorRefusal = null; +let mockCleanupError = null; +jest.mock('../generate-spm-package', () => { + const actual = jest.requireActual('../generate-spm-package'); + return { + ...actual, + main: argv => (mockInjectorCalls == null ? actual.main(argv) : undefined), + }; +}); +jest.mock('../generate-spm-xcodeproj', () => { + const actual = jest.requireActual('../generate-spm-xcodeproj'); + return { + ...actual, + injectSpmIntoExistingXcodeproj: opts => { + if (mockInjectorCalls == null) { + return actual.injectSpmIntoExistingXcodeproj(opts); + } + mockInjectorCalls.push(opts); + return mockInjectorRefusal == null + ? {status: 'injected', target: 'MyApp'} + : {status: 'refused', reason: mockInjectorRefusal}; + }, + cleanupDanglingJavaScriptCoreRef: xcodeprojPath => { + if (mockCleanupError != null) { + throw new Error(mockCleanupError); + } + return actual.cleanupDanglingJavaScriptCoreRef(xcodeprojPath); + }, + }; +}); + const { + appendGitignoreSpmEntries, detectStandardRnLayoutRedirect, determineVersion, ensureBothArtifactFlavors, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, + main, + missingGitignoreSpmEntries, parseArgs, + podfilePodNames, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveCxxApiDefines, resolveExplicitConfigCommand, - shouldAutoDeintegrate, + runDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); @@ -31,6 +105,7 @@ const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const readline = require('node:readline'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). @@ -63,6 +138,26 @@ function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { return dir; } +// A Debug or Release artifact slot that passes validation: every required +// XCFramework directory, listed in artifacts.json. +function writeCompleteArtifactSlot(root, flavor) { + const slot = path.join(root, flavor); + fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { + recursive: true, + }); + const artifacts = {}; + for (const name of REQUIRED_ARTIFACTS) { + const xcframeworkPath = path.join(slot, `${name}.xcframework`); + fs.mkdirSync(xcframeworkPath, {recursive: true}); + artifacts[name] = {xcframeworkPath}; + } + fs.writeFileSync( + path.join(slot, 'artifacts.json'), + JSON.stringify(artifacts), + ); + return slot; +} + function gitInitAndCommit(dir) { const opts = {cwd: dir, stdio: 'ignore'}; execFileSync('git', ['init'], opts); @@ -461,21 +556,7 @@ describe('dual-flavor artifact input', () => { afterEach(() => fs.rmSync(root, {recursive: true, force: true})); function writeCompleteSlot(flavor) { - const slot = path.join(root, flavor); - fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { - recursive: true, - }); - const artifacts = {}; - for (const name of REQUIRED_ARTIFACTS) { - const xcframeworkPath = path.join(slot, `${name}.xcframework`); - fs.mkdirSync(xcframeworkPath, {recursive: true}); - artifacts[name] = {xcframeworkPath}; - } - fs.writeFileSync( - path.join(slot, 'artifacts.json'), - JSON.stringify(artifacts), - ); - return slot; + return writeCompleteArtifactSlot(root, flavor); } function args() { @@ -549,74 +630,705 @@ describe('detectStandardRnLayoutRedirect', () => { }); }); +describe('podfilePodNames', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pod-names-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function namesIn(lines) { + fs.writeFileSync(path.join(tempDir, 'Podfile'), lines.join('\n')); + return podfilePodNames(tempDir); + } + + it('lists each declared pod once, in declaration order', () => { + expect( + namesIn([ + "target 'MyApp' do", + ' use_react_native!', + " pod 'A'", + " pod('B', :path => 'x')", + ' target \'MyAppTests\' do; pod "C"; end', + " pod 'A', :path => 'y'", + 'end', + ]), + ).toEqual(['A', 'B', 'C']); + }); + + it.each([ + [" pod 'MBProgressHUD'", 'MBProgressHUD'], + [' pod "MBProgressHUD"', 'MBProgressHUD'], + [" pod('Firebase/Analytics')", 'Firebase/Analytics'], + [' pod ("Firebase/Analytics")', 'Firebase/Analytics'], + [" pod( 'Firebase/Analytics', '~> 10.0')", 'Firebase/Analytics'], + ["target 'App' do; pod('Foo'); end", 'Foo'], + [" use_frameworks!; pod 'Foo'", 'Foo'], + ])('finds the pod in %s', (line, name) => { + expect(namesIn([line])).toEqual([name]); + }); + + it('ignores commented-out pods and pod-like words', () => { + expect( + namesIn([ + "# pod 'Commented'", + " use_frameworks! # pod 'Trailing'", + " use_frameworks!; # pod 'AfterSemicolon'", + " podspec :path => '../MyLib.podspec'", + " use_frameworks!; podspec :path => '../MyLib.podspec'", + " pods_root = 'Pods'", + " pod_targets = ['MyApp']", + ]), + ).toEqual([]); + }); + + it('reads a pod statement that continues on the next lines', () => { + expect( + namesIn([ + " pod 'React-Core',", + " :path => '../node_modules/react-native/'", + " pod 'React-RCTText', # text", + ' :modular_headers => true,', + " :path => '../node_modules/react-native/Libraries/Text'", + " pod 'RNSVG',", + " :path => '../node_modules/react-native-svg'", + " pod 'OCMock', '~> 3.9.1'", + ]), + ).toEqual(['RNSVG', 'OCMock']); + }); + + it('reads past blank and comment lines inside a continued pod statement', () => { + expect( + namesIn([ + " pod 'React-Core',", + ' # comment', + " :path => '../node_modules/react-native/'", + " pod 'React-RCTText',", + '', + " :path => '../node_modules/react-native/Libraries/Text'", + " pod 'OCMock', '~> 3.9.1'", + ]), + ).toEqual(['OCMock']); + }); + + it('keeps pods whose names use Ruby interpolation', () => { + expect( + namesIn([ + ' pod "#{prefix}Core"', + ' pod "Foo#{suffix}", :path => \'x\' # comment', + ]), + ).toEqual(['#{prefix}Core', 'Foo#{suffix}']); + }); + + it('leaves out pods that come from the react-native package', () => { + expect( + namesIn([ + " pod 'React-Core', :path => '../node_modules/react-native/'", + " pod 'React', :path => '../node_modules/react-native'", + ' pod "React-RCTText", :path => "../../node_modules/react-native/Libraries/Text"', + " pod 'glog', :podspec => '../node_modules/react-native/third-party-podspecs/glog.podspec'", + " pod('RCT-Folly', podspec: '../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec')", + " pod 'RNSVG', :path => '../node_modules/react-native-svg'", + " pod 'Local', :path => '../native/react-native/Local'", + ]), + ).toEqual(['RNSVG', 'Local']); + }); + + it('is empty without a Podfile', () => { + expect(podfilePodNames(tempDir)).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- -// shouldAutoDeintegrate — the zero-arg safe-gate. Auto-convert ONLY a fresh -// CocoaPods RN project: CocoaPods pbxproj + stock Podfile (no third-party pods) -// + clean git tree. Anything else → false (strict `add`, fail-loud). +// CocoaPods conversion keeps the Podfile: `--deintegrate` only runs +// `pod deintegrate`, and `deinit` + `pod install` go back to CocoaPods. // --------------------------------------------------------------------------- -describe('shouldAutoDeintegrate', () => { - let tempDir; +describe('CocoaPods conversion', () => { + // Same shape as private/helloworld/ios/Podfile: a multi-line + // use_react_native! call and a post_install hook, which line-based + // stripping would leave as invalid Ruby. + const STOCK_PODFILE = [ + 'require_relative "../node_modules/react-native/scripts/react_native_pods"', + '', + 'platform :ios, min_ios_version_supported', + 'prepare_react_native_project!', + '', + "target 'MyApp' do", + ' config = use_native_modules!', + '', + ' use_react_native!(', + ' :path => config[:reactNativePath],', + ' :app_path => "#{Pod::Config.instance.installation_root}/.."', + ' )', + '', + ' post_install do |installer|', + ' react_native_post_install(', + ' installer,', + ' config[:reactNativePath],', + ' :mac_catalyst_enabled => false', + ' )', + ' end', + 'end', + '', + ].join('\n'); + // Shaped like packages/rn-tester/Podfile: a React Native pod by path, local + // example pods, then a test pod in a nested target. + const PODFILE_WITH_THIRD_PARTY_PODS = STOCK_PODFILE.replace( + ' config = use_native_modules!\n', + [ + ' config = use_native_modules!', + " pod 'React-Core', :path => '../node_modules/react-native/'", + " pod 'ScreenshotManager', :path => 'NativeModuleExample'", + " pod 'MyNativeView', :path => 'NativeComponentExample'", + " target 'MyAppTests' do", + " pod 'OCMock', '~> 3.9.1'", + ' end', + '', + ].join('\n'), + ); + const POD_INSTALL_LINE = 'To go back to CocoaPods, run `pod install`.'; + + let appRoot; + let podfilePath; + let backupPath; + let logSpy; + let warnSpy; + let errorSpy; + beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-safegate-')); + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-cocoapods-')); + podfilePath = path.join(appRoot, 'Podfile'); + backupPath = path.join(appRoot, 'Podfile.bak'); + mockPodCalls.length = 0; + jest.spyOn(process, 'cwd').mockReturnValue(appRoot); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); + afterEach(() => { - fs.rmSync(tempDir, {recursive: true, force: true}); + jest.restoreAllMocks(); + process.exitCode = undefined; + mockAutolinkingSourceDir = null; + mockInjectorCalls = null; + mockInjectorRefusal = null; + mockCleanupError = null; + fs.rmSync(appRoot, {recursive: true, force: true}); }); - it('false when the project is not CocoaPods-integrated', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: false, + // `spm add` that runs to the end offline: a successful config command, + // complete local artifact slots, and a recording injector. + function runAdd(...flags) { + const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-artifacts-')); + writeCompleteArtifactSlot(artifacts, 'debug'); + writeCompleteArtifactSlot(artifacts, 'release'); + mockAutolinkingSourceDir = appRoot; + mockInjectorCalls = []; + return main([ + 'add', + ...flags, + '--skip-codegen', + '--version', + '0.85.0', + '--download', + 'skip', + '--artifacts', + artifacts, + ]).finally(() => fs.rmSync(artifacts, {recursive: true, force: true})); + } + + function expectCocoaPodsAdvice() { + expect(errors()).toContain('CocoaPods-integrated'); + expect(errors()).toContain('`npx react-native spm add --deintegrate`'); + expect(errors()).toMatch(/keeps the Podfile/); + expect(errors()).toMatch(/`npx react-native spm deinit`[^]*`pod install`/); + expect(errors()).toMatch(/other than React Native[^]*SwiftPM provides/); + expect(errors()).not.toContain('Podfile.bak'); + expect(errors()).not.toContain('https://'); + } + + function expectNoPipelineOutput(xcodeproj) { + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(false); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(false); + expect(fs.existsSync(path.join(xcodeproj, SPM_INJECTED_MARKER))).toBe( + false, + ); + } + + function output(spy) { + return spy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + function errors() { + return output(warnSpy) + '\n' + output(errorSpy); + } + + function mkInjectedProject(markerFields = {}) { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', markerFields); + return xcodeproj; + } + + describe('add --deintegrate', () => { + it('runs `pod deintegrate` and leaves the Podfile byte-identical, with no Podfile.bak', () => { + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + runDeintegrate(appRoot); + + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); }); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); - }); - it('false when there is no target project at all', () => { - expect(shouldAutoDeintegrate(tempDir, null)).toBe(false); + it('converts anyway and warns about the pods that are not part of React Native', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); + + await runAdd('--deintegrate'); + + const warning = output(logSpy) + .split('\n') + .find(line => line.includes('ScreenshotManager')); + expect(warning).toContain('ScreenshotManager, MyNativeView, OCMock.'); + expect(warning).not.toContain('React-Core'); + expect(warning).toMatch(/not installed while the app uses SwiftPM/); + expect(warning).toMatch(/Podfile is kept/); + expect(warning).toMatch(/`npx react-native spm deinit`[^]*`pod install`/); + expect(warning).not.toContain('Podfile.bak'); + expect(errors()).not.toContain('ScreenshotManager'); + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe( + PODFILE_WITH_THIRD_PARTY_PODS, + ); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); + }); + + // Shows that expectNoPipelineOutput can fail: this run gets past every + // refusal and writes what the refusal tests rule out. + it('runs the pipeline when nothing refuses the conversion', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBeUndefined(); + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(true); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + }); + + it('says when --deintegrate applies if the project is still CocoaPods-integrated after it', async () => { + // The faked `pod deintegrate` leaves the pbxproj integrated. + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + }); + + describe('on a project with uncommitted changes', () => { + let isTTY; + let question; + + beforeEach(() => { + isTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + question = jest.fn((_, answer) => answer('n')); + jest + .spyOn(readline, 'createInterface') + .mockReturnValue({question, close: () => {}}); + }); + + afterEach(() => { + if (isTTY == null) { + delete process.stdin.isTTY; + } else { + Object.defineProperty(process.stdin, 'isTTY', isTTY); + } + }); + + it('asks before `pod deintegrate`, and "no" changes nothing', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + const pbxprojPath = path.join(xcodeproj, 'project.pbxproj'); + fs.appendFileSync(pbxprojPath, '// local edit\n'); + const pbxprojBefore = fs.readFileSync(pbxprojPath, 'utf8'); + + await runAdd('--deintegrate'); + + expect(question).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + expect(output(logSpy)).toContain('Aborted'); + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(mockInjectorCalls).toEqual([]); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(pbxprojBefore); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(false); + }); + }); }); - it('false for a CocoaPods project whose Podfile has third-party pods', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + it('says when --deintegrate applies on an explicit `spm add` of a CocoaPods project', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { cocoapods: true, }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n use_react_native!\n pod 'MBProgressHUD'\nend\n", - ); - gitInitAndCommit(tempDir); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); + + await runAdd(); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + expectNoPipelineOutput(xcodeproj); }); - it('false when the pbxproj has uncommitted edits (not revertible)', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + it('does not convert a fresh CocoaPods app on a plain `spm`, and changes nothing', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { cocoapods: true, }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n use_react_native!\nend\n", - ); - gitInitAndCommit(tempDir); - // Dirty the pbxproj itself after the commit → conversion not revertible. - fs.appendFileSync( - path.join(xcodeproj, 'project.pbxproj'), - '\n// local edit\n', - ); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + mockAutolinkingSourceDir = appRoot; + + await main([]); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); }); - it('true despite an unrelated dirty file when pbxproj + Podfile are clean', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, + describe('deinit', () => { + it('leaves the Podfile byte-identical and says to run `pod install`', async () => { + mkInjectedProject(); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); + }); + + it('does not mention `pod install` without a Podfile', async () => { + mkInjectedProject(); + + await main(['deinit']); + + expect(output(logSpy)).not.toContain('pod install'); + expect(fs.existsSync(podfilePath)).toBe(false); + }); + + it('says there is nothing to remove when there is nothing to do', async () => { + await main(['deinit']); + + expect(output(logSpy)).toContain( + 'No SPM injection found — nothing to remove.', + ); + }); + + it('does not mention `pod install` when it removed nothing', async () => { + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).not.toContain('pod install'); + }); + + it('says where to run `pod install` when run from the JS root', async () => { + const iosDir = path.join(appRoot, 'ios'); + fs.mkdirSync(iosDir); + fs.writeFileSync(path.join(appRoot, 'package.json'), '{}'); + mkXcodeproj(iosDir, 'MyApp.xcodeproj'); + mkInjectedXcodeproj(iosDir, 'MyApp.xcodeproj'); + fs.writeFileSync(path.join(iosDir, 'Podfile'), STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).toContain( + 'To go back to CocoaPods, run `cd ios && pod install`.', + ); + }); + + it('still says to run `pod install` when removing an empty directory fails', async () => { + mkInjectedProject(); + fs.mkdirSync(path.join(appRoot, 'ReactNativeSPM', 'Scripts'), { + recursive: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await main(['deinit']); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); + expect(errors()).toContain('EACCES'); + }); + }); + + describe('deinit undoes exactly the .gitignore lines add recorded', () => { + const HEADER = '# SPM – auto-generated at build time (do not commit)'; + const ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', + ]; + let gitignorePath; + + beforeEach(() => { + gitignorePath = path.join(appRoot, '.gitignore'); + }); + + // What a first `add` does to .gitignore, with its record in the marker. + function add() { + const appended = missingGitignoreSpmEntries(appRoot); + if (appended != null) { + appendGitignoreSpmEntries(appRoot, appended); + } + mkInjectedProject(appended != null ? {gitignore: appended} : {}); + return appended; + } + + // Only deinit's output counts as a note; add's own log line does not. + async function deinit() { + jest.clearAllMocks(); + await main(['deinit']); + } + + function readGitignore() { + return fs.readFileSync(gitignorePath, 'utf8'); + } + + function gitignoreNotes() { + return [output(logSpy), errors()] + .join('\n') + .split('\n') + .filter(line => line.includes('.gitignore')); + } + + it('records the lines it appended, and none the file already had', () => { + fs.writeFileSync(gitignorePath, 'node_modules/\nPackage.resolved\n'); + + expect(add()).toEqual({ + created: false, + lines: [HEADER, 'build/generated/', 'build/xcframeworks/', '.build/'], + }); + }); + + it('records that it created the file', () => { + expect(add()).toEqual({created: true, lines: [HEADER, ...ENTRIES]}); + }); + + it('appends nothing on a later run', () => { + add(); + + expect(missingGitignoreSpmEntries(appRoot)).toBeNull(); + }); + + it('deletes a .gitignore that add created and nobody changed', async () => { + add(); + + await deinit(); + + expect(fs.existsSync(gitignorePath)).toBe(false); + }); + + it('removes only the lines add appended to an existing .gitignore', async () => { + const userContent = 'node_modules/\n*.log\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('keeps the entries the user already had', async () => { + const userContent = 'node_modules/\nPackage.resolved\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + expect(readGitignore()).not.toBe(userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('leaves a .gitignore that already had every entry untouched', async () => { + const userContent = ['node_modules/', HEADER, ...ENTRIES, ''].join('\n'); + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + expect(gitignoreNotes()).toEqual([]); + }); + + it('keeps an empty .gitignore the user had', async () => { + fs.writeFileSync(gitignorePath, ''); + add(); + + await deinit(); + + expect(readGitignore()).toBe(''); + }); + + it('keeps lines the user added to a .gitignore add created, and says so in one line', async () => { + add(); + fs.appendFileSync(gitignorePath, '\n# mine\nsecrets/\n'); + + await deinit(); + + expect(readGitignore()).toBe('\n# mine\nsecrets/\n'); + expect(gitignoreNotes()).toHaveLength(1); + }); + + it('keeps a recorded line the user edited, and says so in one line', async () => { + add(); + fs.writeFileSync( + gitignorePath, + readGitignore().replace('build/generated/\n', 'build/generated/**\n'), + ); + + await deinit(); + + expect(readGitignore()).toBe('build/generated/**\n'); + expect(gitignoreNotes()).toHaveLength(1); + expect(process.exitCode).toBeUndefined(); + }); + + it('leaves .gitignore alone when the marker has no record (an older add)', async () => { + appendGitignoreSpmEntries(appRoot, missingGitignoreSpmEntries(appRoot)); + mkInjectedProject(); + const before = readGitignore(); + + await deinit(); + + expect(readGitignore()).toBe(before); + expect(gitignoreNotes()).toEqual([]); + }); + + it('leaves .gitignore alone when there is no marker', async () => { + const userContent = ['node_modules/', '', HEADER, ...ENTRIES, ''].join( + '\n', + ); + fs.writeFileSync(gitignorePath, userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('also removes an entry that a later add appended', async () => { + const userContent = 'node_modules/\n'; + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync( + gitignorePath, + userContent + ['', ...olderLines, ''].join('\n'), + ); + const appended = missingGitignoreSpmEntries(appRoot); + expect(appended).toEqual({created: false, lines: [HEADER, '.build/']}); + appendGitignoreSpmEntries(appRoot, appended); + // The record as the injector merges it (remove-spm-injection-test.js). + mkInjectedProject({ + gitignore: {created: false, lines: [...olderLines, ...appended.lines]}, + }); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('still says to run `pod install` when the .gitignore cleanup fails', async () => { + add(); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + const readFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (file === gitignorePath) { + throw new Error('EIO: i/o error'); + } + return readFileSync(file, ...rest); + }); + + await deinit(); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); + expect(gitignoreNotes().join('\n')).toContain('EIO'); + }); + + describe('add hands the injector the lines it appends', () => { + it('writes and hands over a .gitignore that add created', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + await runAdd(); + + expect(process.exitCode).toBeUndefined(); + expect(mockInjectorCalls).toHaveLength(1); + expect(mockInjectorCalls[0].gitignoreAppended).toEqual({ + created: true, + lines: [HEADER, ...ENTRIES], + }); + expect(readGitignore()).toBe(['', HEADER, ...ENTRIES, ''].join('\n')); + }); + + it('hands over only the lines this run appends', async () => { + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync(gitignorePath, ['', ...olderLines, ''].join('\n')); + mkInjectedProject({gitignore: {created: true, lines: olderLines}}); + + await runAdd(); + + expect(mockInjectorCalls[0].gitignoreAppended).toEqual({ + created: false, + lines: [HEADER, '.build/'], + }); + }); + + it('writes no .gitignore when the injection fails', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mockInjectorRefusal = 'no app target'; + + await runAdd(); + + expect(process.exitCode).toBe(1); + expect(mockInjectorCalls).toHaveLength(1); + expect(fs.existsSync(gitignorePath)).toBe(false); + }); + + it('writes the lines the marker records even when a later step fails', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mockCleanupError = 'EACCES: permission denied'; + + await runAdd(); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('EACCES'); + expect(mockInjectorCalls[0].gitignoreAppended.lines).toEqual([ + HEADER, + ...ENTRIES, + ]); + expect(readGitignore()).toBe(['', HEADER, ...ENTRIES, ''].join('\n')); + }); }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n config = use_native_modules!\n use_react_native!(:path => config[:reactNativePath])\nend\n", - ); - gitInitAndCommit(tempDir); - // A dirty lockfile / untracked file elsewhere must NOT block — the - // conversion only touches the pbxproj + Podfile, which stay clean. - fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); }); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index aa37bb05cec4..0b65ed16b188 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -73,11 +73,12 @@ const path = require('node:path'); /*:: import type { FlavoredFrameworkManifestEntry, + GitignoreRecord, PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ -const {log} = makeLogger('generate-spm-xcodeproj'); +const {log, warn} = makeLogger('generate-spm-xcodeproj'); // Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in // place. Records the host project's root UUID + every edit so `spm deinit` @@ -2467,7 +2468,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, gitignore?: GitignoreRecord, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2803,7 +2804,7 @@ function linkedReactNativeRoot( * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray, gitignoreAppended?: ?GitignoreRecord} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2957,6 +2958,18 @@ function injectSpmIntoExistingXcodeproj( // the whole marker, this field with it. const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // `add` passes the lines it appends to .gitignore; `update` passes none and + // keeps the record. Without one (an older marker), deinit leaves .gitignore + // untouched. + const appended = opts.gitignoreAppended; + const gitignore /*: ?GitignoreRecord */ = + appended == null + ? prevMarker?.gitignore + : { + created: prevMarker?.gitignore?.created === true || appended.created, + lines: [...(prevMarker?.gitignore?.lines ?? []), ...appended.lines], + }; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -3004,6 +3017,7 @@ function injectSpmIntoExistingXcodeproj( schemeResult.status === 'created' || prevMarker?.scheme?.created === true, }, + gitignore, }, null, 2, @@ -3281,6 +3295,7 @@ function removeSpmInjection( EMBED_FRAMEWORKS_SCRIPT_PATH, ]) { fs.rmSync(path.join(appRoot, relPath), {force: true}); + removeEmptyDirsUpTo(path.dirname(path.join(appRoot, relPath)), appRoot); } // 3. Scheme: delete it if injection created it AND still owns its contents, @@ -3316,6 +3331,26 @@ function removeSpmInjection( return {status: 'removed', target: marker.target}; } +// Never follows a symlink, which could lead outside `stopAt`. A failure only +// warns: deinit has more to undo after this. +function removeEmptyDirsUpTo(dir /*: string */, stopAt /*: string */) { + let current = dir; + try { + const root = fs.realpathSync(stopAt); + while ( + fs.existsSync(current) && + fs.lstatSync(current).isDirectory() && + fs.realpathSync(current).startsWith(root + path.sep) && + fs.readdirSync(current).length === 0 + ) { + fs.rmdirSync(current); + current = path.dirname(current); + } + } catch (e) { + warn(`Could not remove the empty directory ${current}: ${e.message}`); + } +} + module.exports = { generateXcscheme, buildSyncAutolinkingScript, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index d9af84f39652..70e5269efdae 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -26,15 +26,21 @@ export type SetupArgs = { // `add` target selection: which app target (when several) and which project. productName: string | null, xcodeprojPath: string | null, - // `add`: run `pod deintegrate` + strip RN from the Podfile before injecting. - // Also implied on the zero-arg path when resolveAction's safe-gate detects a - // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). + // `add`: run `pod deintegrate` before injecting. The Podfile is kept. deintegrate: boolean, // Skip the single remaining confirmation (the add/update dirty-pbxproj // warning). Non-TTY auto-proceeds regardless (git is the safety net). yes: boolean, }; +// What `spm add` wrote to the app's .gitignore, kept in the injection marker +// so `spm deinit` removes exactly that: whether it created the file, and every +// line it appended (header included), in order, across all `add` runs. +export type GitignoreRecord = { + created: boolean, + lines: Array, +}; + export type DownloadArgs = { version: string | null, flavor: string,